use std::fs;
use std::path::Path;
use chrono::Utc;
use clap::{ArgAction, Subcommand};
use netsky_db::{Db, TaskRecord, TaskUpdate};
use serde_json::{Value, json};
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum TaskCommand {
#[command(visible_alias = "ls")]
List {
#[arg(long)]
status: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
json: bool,
},
#[command(visible_alias = "add")]
Create {
title: String,
#[arg(long)]
body: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long = "label")]
labels: Vec<String>,
#[arg(long, default_value = "owner")]
source: String,
#[arg(long, action = ArgAction::SetTrue)]
sync_calendar: bool,
#[arg(long)]
calendar_task_id: Option<String>,
#[arg(long)]
json: bool,
},
Update {
id: i64,
#[arg(long)]
status: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long = "reason")]
closed_reason: Option<String>,
#[arg(long = "evidence")]
closed_evidence: Option<String>,
#[arg(long)]
sync_calendar: Option<bool>,
#[arg(long)]
calendar_task_id: Option<String>,
#[arg(long)]
json: bool,
},
Show {
id: i64,
#[arg(long)]
json: bool,
},
Import {
path: String,
#[arg(long)]
json: bool,
},
}
pub fn run(cmd: TaskCommand) -> netsky_core::Result<()> {
let db =
super::db_diag::with_lock_retry(Db::open).map_err(super::db_diag::wrap_open_retry_error)?;
super::db_diag::with_lock_retry(|| db.migrate())
.map_err(super::db_diag::wrap_open_retry_error)?;
match cmd {
TaskCommand::List {
status,
priority,
json,
} => list(&db, status.as_deref(), priority.as_deref(), json),
TaskCommand::Create {
title,
body,
priority,
labels,
source,
sync_calendar,
calendar_task_id,
json,
} => create(
&db,
CreateInput {
title: &title,
body: body.as_deref(),
priority: priority.as_deref(),
labels: &labels,
source: &source,
sync_calendar,
calendar_task_id: calendar_task_id.as_deref(),
},
json,
),
TaskCommand::Update {
id,
status,
priority,
agent,
closed_reason,
closed_evidence,
sync_calendar,
calendar_task_id,
json,
} => update(
&db,
UpdateInput {
id,
status: status.as_deref(),
priority: priority.as_deref(),
agent: agent.as_deref(),
closed_reason: closed_reason.as_deref(),
closed_evidence: closed_evidence.as_deref(),
sync_calendar,
calendar_task_id: calendar_task_id.as_deref(),
},
json,
),
TaskCommand::Show { id, json } => show(&db, id, json),
TaskCommand::Import { path, json } => import(&db, Path::new(&path), json),
}
}
fn envelope(summary: &str, data: Value) -> Value {
json!({
"command": "task",
"status": "green",
"summary": summary,
"generated_at": Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"data": data,
})
}
fn emit(env: &Value) -> netsky_core::Result<()> {
println!("{}", serde_json::to_string_pretty(env)?);
Ok(())
}
fn list(
db: &Db,
status: Option<&str>,
priority: Option<&str>,
json: bool,
) -> netsky_core::Result<()> {
let tasks = super::db_diag::with_lock_retry(|| db.list_tasks(status, priority))
.map_err(super::db_diag::wrap_retry_error)
.map_err(|e| netsky_core::anyhow!("list tasks: {e}"))?;
if json {
let summary = format!(
"{} task{}",
tasks.len(),
if tasks.len() == 1 { "" } else { "s" }
);
return emit(&envelope(
&summary,
json!({
"filters": {
"status": status,
"priority": priority,
},
"tasks": tasks,
"count": tasks.len(),
}),
));
}
print_table(&tasks);
Ok(())
}
struct CreateInput<'a> {
title: &'a str,
body: Option<&'a str>,
priority: Option<&'a str>,
labels: &'a [String],
source: &'a str,
sync_calendar: bool,
calendar_task_id: Option<&'a str>,
}
fn create(db: &Db, input: CreateInput<'_>, json: bool) -> netsky_core::Result<()> {
validate_status("open")?;
validate_priority(input.priority)?;
let labels = labels_csv(input.labels);
let id = super::db_diag::with_lock_retry(|| {
db.record_task(TaskRecord {
title: input.title,
body: input.body,
status: "open",
priority: input.priority,
labels: labels.as_deref(),
source: Some(input.source),
source_ref: None,
closed_at: None,
closed_reason: None,
closed_evidence: None,
agent: None,
sync_calendar: input.sync_calendar,
calendar_task_id: input.calendar_task_id,
})
})
.map_err(super::db_diag::wrap_retry_error)
.map_err(|e| netsky_core::anyhow!("create task: {e}"))?;
if json {
return emit(&envelope(
&format!("created task {id}"),
json!({
"id": id,
"title": input.title,
"status": "open",
"priority": input.priority,
"labels": labels,
"source": input.source,
}),
));
}
println!("created task {id}");
Ok(())
}
struct UpdateInput<'a> {
id: i64,
status: Option<&'a str>,
priority: Option<&'a str>,
agent: Option<&'a str>,
closed_reason: Option<&'a str>,
closed_evidence: Option<&'a str>,
sync_calendar: Option<bool>,
calendar_task_id: Option<&'a str>,
}
fn update(db: &Db, input: UpdateInput<'_>, json: bool) -> netsky_core::Result<()> {
if let Some(status) = input.status {
validate_status(status)?;
}
validate_priority(input.priority)?;
let task = super::db_diag::with_lock_retry(|| {
db.update_task(TaskUpdate {
id: input.id,
status: input.status,
priority: input.priority,
agent: input.agent,
closed_reason: input.closed_reason,
closed_evidence: input.closed_evidence,
sync_calendar: input.sync_calendar,
calendar_task_id: input.calendar_task_id,
})
})
.map_err(super::db_diag::wrap_retry_error)
.map_err(|e| netsky_core::anyhow!("update task: {e}"))?;
if json {
return emit(&envelope(
&format!("updated task {} {}", task.id, task.status),
json!({ "task": task }),
));
}
println!("updated task {} {}", task.id, task.status);
Ok(())
}
fn show(db: &Db, id: i64, json: bool) -> netsky_core::Result<()> {
let task = super::db_diag::with_lock_retry(|| db.get_task(id))
.map_err(super::db_diag::wrap_retry_error)
.map_err(|e| netsky_core::anyhow!("show task: {e}"))?
.ok_or_else(|| netsky_core::Error::Invalid(format!("task {id} not found")))?;
if json {
return emit(&envelope(
&format!("task {} {}", task.id, task.status),
json!({ "task": task }),
));
}
println!("id: {}", task.id);
println!("title: {}", task.title);
println!("status: {}", task.status);
println!("priority: {}", task.priority.as_deref().unwrap_or(""));
println!("labels: {}", task.labels.as_deref().unwrap_or(""));
println!("agent: {}", task.agent.as_deref().unwrap_or(""));
println!("source: {}", task.source.as_deref().unwrap_or(""));
println!("source_ref: {}", task.source_ref.as_deref().unwrap_or(""));
println!("sync_calendar: {}", task.sync_calendar);
println!(
"calendar_task_id: {}",
task.calendar_task_id.as_deref().unwrap_or("")
);
println!("created_at: {}", task.created_at);
println!("updated_at: {}", task.updated_at);
println!("closed_at: {}", task.closed_at.as_deref().unwrap_or(""));
println!(
"closed_reason: {}",
task.closed_reason.as_deref().unwrap_or("")
);
println!(
"closed_evidence: {}",
task.closed_evidence.as_deref().unwrap_or("")
);
if let Some(body) = task.body {
println!("body:\n{body}");
}
Ok(())
}
fn import(db: &Db, path: &Path, json: bool) -> netsky_core::Result<()> {
let text = fs::read_to_string(path)?;
let records = parse_recovered_backlog(&text);
let mut count = 0;
for record in records {
validate_status(&record.status)?;
let closed_at = if record.status == "closed" {
Some("")
} else {
None
};
super::db_diag::with_lock_retry(|| {
db.record_task(TaskRecord {
title: &record.title,
body: record.evidence.as_deref(),
status: &record.status,
priority: None,
labels: None,
source: Some("backlog-sweep"),
source_ref: record.source_ref.as_deref(),
closed_at,
closed_reason: record.closed_reason.as_deref(),
closed_evidence: None,
agent: None,
sync_calendar: false,
calendar_task_id: None,
})
})
.map_err(super::db_diag::wrap_retry_error)
.map_err(|e| netsky_core::anyhow!("import task: {e}"))?;
count += 1;
}
if json {
return emit(&envelope(
&format!("imported {count} tasks"),
json!({
"path": path.display().to_string(),
"count": count,
}),
));
}
println!("imported {count} tasks");
Ok(())
}
#[derive(Default)]
struct ImportedTask {
status: String,
closed_reason: Option<String>,
title: String,
source_ref: Option<String>,
evidence: Option<String>,
}
fn parse_recovered_backlog(text: &str) -> Vec<ImportedTask> {
let mut out = Vec::new();
let mut current: Option<ImportedTask> = None;
for line in text.lines() {
let trimmed = line.trim();
if let Some(status) = trimmed.strip_prefix("- Status: ") {
push_backlog(&mut out, current.take());
let (status, closed_reason) = normalize_status(status.trim());
current = Some(ImportedTask {
status,
closed_reason,
..ImportedTask::default()
});
continue;
}
let Some(item) = current.as_mut() else {
continue;
};
if let Some(title) = trimmed.strip_prefix("- Item: ") {
item.title = title.trim().to_string();
} else if let Some(source) = trimmed.strip_prefix("- Source: ") {
item.source_ref = Some(source.trim().to_string());
} else if let Some(evidence) = trimmed.strip_prefix("- Evidence: ") {
item.evidence = Some(evidence.trim().to_string());
}
}
push_backlog(&mut out, current);
out
}
fn push_backlog(out: &mut Vec<ImportedTask>, item: Option<ImportedTask>) {
let Some(item) = item else {
return;
};
if item.title.is_empty() {
return;
}
out.push(item);
}
fn normalize_status(status: &str) -> (String, Option<String>) {
match status {
"done" => ("closed".to_string(), Some("shipped".to_string())),
"superseded" => ("closed".to_string(), Some("superseded".to_string())),
"in-progress" => ("in_progress".to_string(), None),
other => (other.to_string(), None),
}
}
fn labels_csv(labels: &[String]) -> Option<String> {
if labels.is_empty() {
None
} else {
Some(labels.join(","))
}
}
fn validate_status(status: &str) -> netsky_core::Result<()> {
match status {
"open" | "in_progress" | "closed" | "stale" => Ok(()),
_ => Err(netsky_core::Error::Invalid(format!(
"status must be open, in_progress, closed, or stale: {status}"
))),
}
}
fn validate_priority(priority: Option<&str>) -> netsky_core::Result<()> {
match priority {
None => Ok(()),
Some(value) => {
let lower = value.to_ascii_lowercase();
if matches!(
lower.as_str(),
"p0" | "p1" | "p2" | "p3" | "p4" | "p5" | "p6" | "p7" | "p8"
) {
Ok(())
} else {
Err(netsky_core::Error::Invalid(format!(
"priority must be one of p0..p8 (case-insensitive): {value}"
)))
}
}
}
}
fn print_table(tasks: &[netsky_db::TaskRow]) {
println!("id status pr agent title");
for task in tasks {
println!(
"{:<3} {:<12} {:<3} {:<7} {}",
task.id,
task.status,
task.priority.as_deref().unwrap_or(""),
task.agent.as_deref().unwrap_or(""),
task.title
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_recovered_backlog_items() {
let text = "- Status: open\n - Item: One\n - Source: `a:1`\n - Evidence: nope\n\n- Status: done\n - Item: Two\n - Evidence: yep\n";
let records = parse_recovered_backlog(text);
assert_eq!(records.len(), 2);
assert_eq!(records[0].title, "One");
assert_eq!(records[0].status, "open");
assert_eq!(records[1].status, "closed");
assert_eq!(records[1].closed_reason.as_deref(), Some("shipped"));
}
#[test]
fn parses_hyphenated_in_progress_status() {
let records = parse_recovered_backlog("- Status: in-progress\n - Item: One\n");
assert_eq!(records[0].status, "in_progress");
}
#[test]
fn parses_superseded_as_closed() {
let records = parse_recovered_backlog("- Status: superseded\n - Item: One\n");
assert_eq!(records[0].status, "closed");
assert_eq!(records[0].closed_reason.as_deref(), Some("superseded"));
}
}