crates/cli/src/cmd/task.rs 10.8 KiB 343 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge task: kanban cards (PLAN M3d). Columns derived from status.3 use crate::{args, output, CliError};4 use f0rge_core::{Forge, Task};5 use serde::Serialize;6 7 pub fn help() -> &'static str {8     "f0rge task: kanban cards\nusage: f0rge task <create|list|move|done> [--json]\ncreate: task create --title S [--body S --column C]\ncols: todo doing blocked done\ndocs: spec/schema.md §Task\ncontract: f0rge --skill\n"9 }10 11 const COLS: &[&str] = &["todo", "doing", "blocked", "done"];12 const VERBS: &[&str] = &["create", "list", "move", "done"];13 14 fn spec() -> Vec<args::FlagSpec> {15     args::spec_from_lists(&["title", "body", "column", "issue", "jq"], &["json"])16 }17 18 fn usage(m: &str) -> CliError {19     CliError::Usage(m.to_string())20 }21 22 fn clean(s: &str) -> String {23     s.chars().filter(|c| *c != '\n' && *c != '\r').take(80).collect::<String>().trim().to_string()24 }25 26 fn norm_body(b: &str) -> String {27     if b.is_empty() {28         return String::new();29     }30     if b.ends_with('\n') {31         b.to_string()32     } else {33         format!("{b}\n")34     }35 }36 37 /// True when a plain scalar would re-parse as bool/null/number instead38 /// of a string (e.g. a task titled `123`). Quoting is always safe.39 fn risky(s: &str) -> bool {40     match s.to_ascii_lowercase().as_str() {41         "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~" | "nan" | "inf"42         | ".nan" | ".inf" | "+.nan" | "+.inf" | "-.nan" | "-.inf" => return true,43         _ => {}44     }45     if !s.bytes().any(|b| b.is_ascii_digit()) {46         return false;47     }48     if s.parse::<f64>().is_ok() {49         return true;50     }51     let t = s.strip_prefix('+').or_else(|| s.strip_prefix('-')).unwrap_or(s);52     if t.starts_with("0x")53         || t.starts_with("0X")54         || t.starts_with("0o")55         || t.starts_with("0O")56         || t.starts_with("0b")57         || t.starts_with("0B")58     {59         return true;60     }61     let u: String = t.chars().filter(|c| *c != '_').collect();62     if !u.is_empty() && u.bytes().all(|b| b.is_ascii_digit()) {63         return true;64     }65     let f: String = s.chars().filter(|c| *c != '_').collect();66     f.parse::<f64>().is_ok()67 }68 69 fn plain(s: &str) -> bool {70     !s.is_empty()71         && !risky(s)72         && s.bytes().all(|b| {73             b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/'74         })75 }76 77 fn esc(s: &str) -> String {78     if plain(s) {79         return s.to_string();80     }81     let mut o = String::with_capacity(s.len() + 2);82     o.push('"');83     for c in s.chars() {84         match c {85             '"' => o.push_str("\\\""),86             '\\' => o.push_str("\\\\"),87             '\n' => o.push_str("\\n"),88             '\r' => o.push_str("\\r"),89             '\t' => o.push_str("\\t"),90             _ => o.push(c),91         }92     }93     o.push('"');94     o95 }96 97 #[derive(Debug, Clone, Serialize)]98 struct TaskJson {99     id: String,100     status: String,101     title: String,102     body: String,103     #[serde(rename = "createdAt")]104     created_at: String,105     #[serde(skip_serializing_if = "Option::is_none")]106     issue: Option<String>,107 }108 109 fn view_of(t: &Task, body: &str, issue: Option<String>) -> TaskJson {110     TaskJson {111         id: t.id.clone(),112         status: t.status.clone(),113         title: t.title.clone(),114         body: body.to_string(),115         created_at: t.created.clone(),116         issue,117     }118 }119 120 /// Top-level `issue:` in frontmatter, if present (extra knob, never stored schema).121 fn front_issue(text: &str) -> Option<String> {122     let (front, _) = f0rge_core::split_frontmatter(text, "task.md").ok()?;123     for line in front.lines() {124         if let Some(v) = line.strip_prefix("issue:") {125             let v = v.trim().trim_matches('"').trim().to_string();126             if !v.is_empty() && v != "~" && v != "null" {127                 return Some(v);128             }129         }130     }131     None132 }133 134 fn render(t: &Task, issue: Option<&str>, body: &str) -> String {135     let mut s = String::from("---\n");136     s.push_str(&format!("id: {}\ntitle: {}\nstatus: {}\n", t.id, esc(&t.title), t.status));137     if let Some(i) = issue {138         s.push_str(&format!("issue: {i}\n"));139     }140     if let Some(a) = &t.assignee {141         s.push_str(&format!("assignee: {}\n", esc(a)));142     }143     s.push_str(&format!("created: {}\n", t.created));144     if let Some(u) = &t.updated {145         s.push_str(&format!("updated: {u}\n"));146     }147     if let Some(c) = &t.closed {148         s.push_str(&format!("closed: {c}\n"));149     }150     s.push_str("---\n");151     s.push_str(body);152     s153 }154 155 fn check_uuid(raw: &str, verb: &str) -> Result<String, CliError> {156     let id = clean(raw);157     if f0rge_core::is_uuid4_lower(&id) {158         return Ok(id);159     }160     Err(usage(&format!("f0rge task {verb} <id>")))161 }162 163 fn load(fg: &Forge, uuid: &str) -> Result<(Task, Option<String>, String), CliError> {164     let rel = fg.task_file(uuid);165     let bytes = fg.read_capped(&rel).map_err(|e| match e {166         f0rge_core::ForgeError::NotFound { .. } => {167             CliError::Runtime(format!("task {uuid}: not found"))168         }169         _ => CliError::from(e),170     })?;171     let text =172         String::from_utf8(bytes).map_err(|_| CliError::Runtime("task: not utf-8".to_string()))?;173     let label = format!("{uuid}.md");174     let p: f0rge_core::Parsed<Task> =175         f0rge_core::parse_entity(&text, &label).map_err(CliError::from)?;176     for w in &p.warnings {177         eprintln!("warning: {label}: {w}");178     }179     output::warn_schema(&p.schema_note);180     if p.value.id != uuid {181         return Err(CliError::Runtime(format!("task {uuid}: id mismatch")));182     }183     let issue = front_issue(&text);184     Ok((p.value, issue, p.body))185 }186 187 fn load_all(fg: &Forge) -> Result<Vec<(Task, String, Option<String>)>, CliError> {188     let dir = fg.root().join("tasks");189     let rd = match std::fs::read_dir(&dir) {190         Ok(r) => r,191         Err(_) => return Ok(Vec::new()),192     };193     let mut out = Vec::new();194     for e in rd.flatten() {195         let ft = match e.file_type() {196             Ok(t) => t,197             Err(_) => continue,198         };199         if ft.is_symlink() || !ft.is_file() {200             continue;201         }202         let name = e.file_name().to_string_lossy().into_owned();203         let Some(stem) = name.strip_suffix(".md") else { continue };204         if !f0rge_core::is_uuid4_lower(stem) {205             continue;206         }207         match load(fg, stem) {208             Ok((t, issue, body)) => out.push((t, body, issue)),209             Err(CliError::Runtime(_)) => continue,210             Err(e) => return Err(e),211         }212     }213     Ok(out)214 }215 216 fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {217     if !fs.positional.is_empty() {218         return Err(usage("f0rge task create --title T"));219     }220     let title = fs.get("title").unwrap_or("");221     if clean(title).is_empty() {222         return Err(usage("f0rge task create --title T"));223     }224     let issue = match fs.get("issue") {225         Some(i) => {226             let c = clean(i);227             if !f0rge_core::is_uuid4_lower(&c) {228                 return Err(usage("f0rge task create --issue UUID"));229             }230             Some(c)231         }232         None => None,233     };234     let id = uuid::Uuid::new_v4().to_string();235     let body = norm_body(fs.get("body").unwrap_or(""));236     let col = match fs.get("column") {237         Some(c) => {238             let cc = clean(c);239             if !COLS.contains(&cc.as_str()) {240                 return Err(usage("f0rge task create --column todo|doing|blocked|done"));241             }242             cc243         }244         None => "todo".to_string(),245     };246     let now = f0rge_core::now_rfc3339();247     let t = Task {248         id: id.clone(),249         title: title.to_string(),250         status: col.clone(),251         assignee: None,252         created: now.clone(),253         updated: if col == "done" { Some(now.clone()) } else { None },254         closed: if col == "done" { Some(now) } else { None },255     };256     fg.atomic_write(&fg.task_file(&id), render(&t, issue.as_deref(), &body).as_bytes())257         .map_err(CliError::from)?;258     output::emit_json(&view_of(&t, &body, issue), fs.get("jq"));259     Ok(())260 }261 262 fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {263     if !fs.positional.is_empty() {264         return Err(usage("f0rge task list"));265     }266     let mut all = load_all(fg)?;267     all.sort_by(|a, b| (a.0.created.clone(), a.0.id.clone()).cmp(&(b.0.created.clone(), b.0.id.clone())));268     if fs.is("json") {269         if all.is_empty() {270             output::emit_empty_array(fs.get("jq"));271             return Ok(());272         }273         for (t, body, issue) in &all {274             output::emit_str(&output::json_line(&view_of(t, body, issue.clone())), fs.get("jq"));275         }276         return Ok(());277     }278     if all.is_empty() {279         return Ok(());280     }281     let mut human = String::new();282     for col in COLS {283         human.push_str(col);284         human.push_str(":\n");285         for (t, _, _) in all.iter().filter(|(t, _, _)| &t.status == col) {286             human.push_str(&format!("  {}  {}\n", t.id, t.title));287         }288     }289     output::out(&human);290     Ok(())291 }292 293 fn mv(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {294     if fs.positional.len() != 2 {295         return Err(usage("f0rge task move <id> <col>"));296     }297     let uuid = check_uuid(&fs.positional[0], "move")?;298     let col = clean(&fs.positional[1]);299     if !COLS.contains(&col.as_str()) {300         return Err(usage("f0rge task move <id> todo|doing|blocked|done"));301     }302     let (mut t, issue, body) = load(fg, &uuid)?;303     t.status = col;304     t.updated = Some(f0rge_core::now_rfc3339());305     fg.atomic_write(&fg.task_file(&uuid), render(&t, issue.as_deref(), &body).as_bytes())306         .map_err(CliError::from)?;307     output::emit_json(&view_of(&t, &body, issue), fs.get("jq"));308     Ok(())309 }310 311 fn done(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {312     if fs.positional.len() != 1 {313         return Err(usage("f0rge task done <id>"));314     }315     let uuid = check_uuid(&fs.positional[0], "done")?;316     let (mut t, issue, body) = load(fg, &uuid)?;317     let now = f0rge_core::now_rfc3339();318     t.status = "done".to_string();319     t.closed = Some(now.clone());320     t.updated = Some(now);321     fg.atomic_write(&fg.task_file(&uuid), render(&t, issue.as_deref(), &body).as_bytes())322         .map_err(CliError::from)?;323     output::emit_json(&view_of(&t, &body, issue), fs.get("jq"));324     Ok(())325 }326 327 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {328     if args_in.is_empty() {329         return Err(usage("f0rge task <create|list|move|done>"));330     }331     let verb = clean(&args_in[0]);332     if verb.is_empty() || verb.starts_with('-') || !VERBS.contains(&verb.as_str()) {333         return Err(usage("f0rge task <create|list|move|done>"));334     }335     let fs = args::parse_flags(&args_in[1..], &spec())?;336     match verb.as_str() {337         "create" => create(&fs, fg),338         "list" => list(&fs, fg),339         "move" => mv(&fs, fg),340         "done" => done(&fs, fg),341         _ => Err(usage("f0rge task <create|list|move|done>")),342     }343 }