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 }