mod api; mod cli; mod db; mod tui; mod types; use std::io::Write; use clap::Parser; use cli::{Command, ExportFormat}; use color_eyre::eyre::{Result, WrapErr}; use db::ListFilter; use types::{NewTicket, Status}; fn main() -> Result<()> { color_eyre::install()?; let cli = cli::Cli::parse(); let conn = db::open_db()?; let node_id = db::get_or_create_node_id(&conn)?; match cli.command { None => tui::run(conn, node_id)?, Some(Command::Create { title, body, priority, channel, source, source_ref }) => { let ticket = db::create_ticket(&conn, &NewTicket { title, body, priority, channel, source: Some(source), source_ref, }, &node_id)?; println!("created {} [{}] ({})", ticket.short_id(), ticket.channel, ticket.title); } Some(Command::List { status, priority, channel, source }) => { let tickets = db::list_tickets(&conn, &ListFilter { status, priority, channel, source: source.as_deref(), ..Default::default() })?; if tickets.is_empty() { println!("no tickets"); return Ok(()); } println!("{:<10} {:<8} {:<5} {:<12} {:<30} {}", "ID", "Channel", "Pri", "Status", "Title", "Node"); println!("{}", "-".repeat(80)); for t in &tickets { println!( "{:<10} {:<8} {:<5} {:<12} {:<30} {}", t.short_id(), t.channel, t.priority, t.status, truncate(&t.title, 30), t.short_node(), ); } println!("\n{} ticket(s)", tickets.len()); } Some(Command::Show { id }) => { let t = db::get_ticket(&conn, &id)?; println!("ID: {}", t.id); println!("Title: {}", t.title); println!("Channel: {}", t.channel); println!("Priority: {}", t.priority); println!("Status: {} {}", t.status.indicator(), t.status); println!("Node: {}", t.node_id); println!("Source: {}", t.source.as_deref().unwrap_or("-")); println!("Ref: {}", t.source_ref.as_deref().unwrap_or("-")); println!("Created: {}", t.created_at.format("%Y-%m-%d %H:%M UTC")); println!("Updated: {}", t.updated_at.format("%Y-%m-%d %H:%M UTC")); if let Some(ref resolved) = t.resolved_at { println!("Resolved: {}", resolved.format("%Y-%m-%d %H:%M UTC")); } if let Some(ref body) = t.body { println!("\n{body}"); } } Some(Command::Resolve { id }) => { let t = db::get_ticket(&conn, &id)?; db::update_status(&conn, &t.id, Status::Resolved)?; println!("resolved {} ({})", t.short_id(), t.title); } Some(Command::Close { id }) => { let t = db::get_ticket(&conn, &id)?; db::update_status(&conn, &t.id, Status::Closed)?; println!("closed {} ({})", t.short_id(), t.title); } Some(Command::Serve { port, peer }) => { let rt = tokio::runtime::Runtime::new()?; rt.block_on(api::serve(conn, port, peer))?; } Some(Command::Stats) => print_stats(&conn)?, Some(Command::Export { format, output }) => { let tickets = db::list_tickets(&conn, &ListFilter::default())?; let mut writer: Box = match output { Some(path) => Box::new( std::fs::File::create(&path) .wrap_err_with(|| format!("create {}", path.display()))?, ), None => Box::new(std::io::stdout().lock()), }; match format { ExportFormat::Json => { serde_json::to_writer_pretty(&mut writer, &tickets)?; writeln!(writer)?; } ExportFormat::Csv => write_csv(&mut writer, &tickets)?, } } Some(Command::Prune { older_than, status, dry_run }) => { let dur = cli::parse_duration(&older_than) .map_err(|e| color_eyre::eyre::eyre!(e))?; if dry_run { let cutoff = chrono::Utc::now() - dur; let candidates: Vec<_> = db::list_tickets(&conn, &ListFilter { status: Some(status), ..Default::default() })? .into_iter() .filter(|t| t.updated_at < cutoff) .collect(); if candidates.is_empty() { println!("no tickets to prune"); } else { for t in &candidates { println!("{} {} ({})", t.short_id(), t.title, t.age()); } println!("\nwould delete {} ticket(s)", candidates.len()); } } else { let n = db::prune_tickets(&conn, dur, status)?; println!("pruned {n} ticket(s)"); } } } Ok(()) } fn print_stats(conn: &rusqlite::Connection) -> Result<()> { let s = db::stats(conn)?; println!("Total: {}", s.total); if !s.by_status.is_empty() { println!("\nBy status:"); for (st, n) in &s.by_status { println!(" {st:<12} {n}"); } } if !s.open_by_priority.is_empty() { println!("\nOpen by priority:"); for (p, n) in &s.open_by_priority { println!(" {p:<9} {n}"); } } if !s.open_by_source.is_empty() { println!("\nOpen by source:"); for (src, n) in &s.open_by_source { println!(" {src:<20} {n}"); } } if let Some(avg) = s.avg_resolution_seconds { println!("\nAverage resolution time: {}", format_duration(avg)); } else { println!("\nAverage resolution time: -"); } Ok(()) } fn format_duration(secs: i64) -> String { let d = secs / 86_400; let h = (secs % 86_400) / 3_600; let m = (secs % 3_600) / 60; if d > 0 { format!("{d}d {h}h") } else if h > 0 { format!("{h}h {m}m") } else { format!("{m}m") } } fn write_csv(w: &mut W, tickets: &[types::Ticket]) -> Result<()> { writeln!( w, "id,title,body,priority,status,channel,node_id,source,source_ref,created_at,updated_at,resolved_at" )?; for t in tickets { writeln!( w, "{},{},{},{},{},{},{},{},{},{},{},{}", csv_escape(&t.id), csv_escape(&t.title), csv_escape(t.body.as_deref().unwrap_or("")), t.priority, t.status, t.channel, csv_escape(&t.node_id), csv_escape(t.source.as_deref().unwrap_or("")), csv_escape(t.source_ref.as_deref().unwrap_or("")), t.created_at.to_rfc3339(), t.updated_at.to_rfc3339(), t.resolved_at.map(|d| d.to_rfc3339()).unwrap_or_default(), )?; } Ok(()) } fn csv_escape(s: &str) -> String { if s.contains(',') || s.contains('"') || s.contains('\n') { let escaped = s.replace('"', "\"\""); format!("\"{escaped}\"") } else { s.to_string() } } fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { format!("{}...", &s[..max - 3]) } }