crates/cli/src/cmd/milestone.rs 7.5 KiB 235 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge milestone: definitions in .forge/milestones.md (M3c).3 use crate::args::{parse_flags, spec_from_lists, FlagSet};4 use crate::output;5 use crate::CliError;6 use f0rge_core::{Forge, Milestone};7 8 pub fn help() -> &'static str {9     "f0rge milestone: spans\nusage: f0rge milestone <list|create|edit> [--json]\ncreate: milestone create <name> [--state S]\nfile: .forge/milestones.md\ndocs: spec/schema.md §Milestone\ncontract: f0rge --skill\n"10 }11 12 const VALS: &[&str] = &["name", "state", "due", "description", "desc", "jq"];13 const BOOLS: &[&str] = &["json"];14 15 pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {16     let spec = spec_from_lists(VALS, BOOLS);17     let fs = parse_flags(args, &spec)?;18     let (json, jq) = (fs.is("json"), fs.get("jq").map(|s| s.to_string()));19     let p = &fs.positional;20     if p.is_empty() {21         return Err(CliError::Usage("milestone <list|create|edit>".to_string()));22     }23     match p[0].as_str() {24         "list" => list(fg, json, jq.as_deref()),25         "create" => create(&fs, p, fg, json, jq.as_deref()),26         "edit" => edit(&fs, p, fg, json, jq.as_deref()),27         _ => Err(CliError::Usage("milestone <list|create|edit>".to_string())),28     }29 }30 31 fn load(fg: &Forge) -> Result<Vec<Milestone>, CliError> {32     let bytes = match fg.read_capped(&fg.milestones_file()) {33         Ok(b) => b,34         Err(f0rge_core::ForgeError::NotFound { .. }) => return Ok(Vec::new()),35         Err(e) => return Err(CliError::from(e)),36     };37     let text = String::from_utf8(bytes)38         .map_err(|_| CliError::Runtime("milestones.md: not utf-8".to_string()))?;39     if text.trim().is_empty() || text.trim() == "[]" {40         return Ok(Vec::new());41     }42     let (items, w) =43         f0rge_core::parse_list::<Milestone>(&text, "milestones.md").map_err(CliError::from)?;44     for x in &w {45         eprintln!("warning: milestones.md: {x}");46     }47     Ok(items)48 }49 50 /// True when a plain scalar would re-parse as bool/null/number instead51 /// of a string (e.g. `123`, `true`). Quoting is always safe.52 fn risky(s: &str) -> bool {53     match s.to_ascii_lowercase().as_str() {54         "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~" | "nan" | "inf"55         | ".nan" | ".inf" | "+.nan" | "+.inf" | "-.nan" | "-.inf" => return true,56         _ => {}57     }58     if !s.bytes().any(|b| b.is_ascii_digit()) {59         return false;60     }61     if s.parse::<f64>().is_ok() {62         return true;63     }64     let t = s.strip_prefix('+').or_else(|| s.strip_prefix('-')).unwrap_or(s);65     if t.starts_with("0x")66         || t.starts_with("0X")67         || t.starts_with("0o")68         || t.starts_with("0O")69         || t.starts_with("0b")70         || t.starts_with("0B")71     {72         return true;73     }74     let u: String = t.chars().filter(|c| *c != '_').collect();75     if !u.is_empty() && u.bytes().all(|b| b.is_ascii_digit()) {76         return true;77     }78     let f: String = s.chars().filter(|c| *c != '_').collect();79     f.parse::<f64>().is_ok()80 }81 82 fn plain(s: &str) -> bool {83     !s.is_empty()84         && !risky(s)85         && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/')86 }87 88 fn esc(s: &str) -> String {89     if plain(s) {90         return s.to_string();91     }92     let mut o = String::with_capacity(s.len() + 2);93     o.push('"');94     for c in s.chars() {95         match c {96             '"' => o.push_str("\\\""),97             '\\' => o.push_str("\\\\"),98             '\n' => o.push_str("\\n"),99             '\r' => o.push_str("\\r"),100             '\t' => o.push_str("\\t"),101             _ => o.push(c),102         }103     }104     o.push('"');105     o106 }107 108 fn render(items: &[Milestone]) -> Vec<u8> {109     if items.is_empty() {110         return b"[]\n".to_vec();111     }112     let mut o = String::new();113     for m in items {114         o.push_str("- name: ");115         o.push_str(&esc(&m.name));116         o.push('\n');117         o.push_str("  state: ");118         o.push_str(&esc(&m.state));119         o.push('\n');120         if let Some(d) = &m.due {121             o.push_str("  due: ");122             o.push_str(&esc(d));123             o.push('\n');124         }125         if let Some(d) = &m.description {126             o.push_str("  description: ");127             o.push_str(&esc(d));128             o.push('\n');129         }130     }131     o.into_bytes()132 }133 134 fn save(fg: &Forge, items: &[Milestone]) -> Result<(), CliError> {135     fg.atomic_write(&fg.milestones_file(), &render(items)).map_err(CliError::from)136 }137 138 fn name_of(fs: &FlagSet, p: &[String], verb: &str) -> Result<String, CliError> {139     if let Some(n) = fs.get("name").filter(|s| !s.is_empty()) {140         return Ok(n.to_string());141     }142     if let Some(n) = p.get(1).filter(|s| !s.is_empty()) {143         return Ok(n.clone());144     }145     Err(CliError::Usage(format!("milestone {verb} --name <name>")))146 }147 148 fn desc_of(fs: &FlagSet) -> Option<String> {149     fs.get("description")150         .or_else(|| fs.get("desc"))151         .filter(|s| !s.is_empty())152         .map(str::to_string)153 }154 155 fn check_state(s: &str) -> Result<String, CliError> {156     match s {157         "open" | "closed" => Ok(s.to_string()),158         _ => Err(CliError::Usage("milestone --state <open|closed>".to_string())),159     }160 }161 162 fn list(fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {163     let mut items = load(fg)?;164     items.sort_by(|a, b| a.name.cmp(&b.name));165     if json {166         if items.is_empty() {167             output::emit_str("[]", jq);168         } else {169             let rows: Vec<String> = items.iter().map(output::json_line).collect();170             output::emit_str(&rows.join("\n"), jq);171         }172     } else if !items.is_empty() {173         let rows: Vec<Vec<String>> = items174             .iter()175             .map(|m| {176                 vec![177                     m.name.clone(),178                     m.state.clone(),179                     m.due.clone().unwrap_or_else(|| "-".to_string()),180                     m.description.clone().unwrap_or_default(),181                 ]182             })183             .collect();184         output::out(&format!("{}\n", output::align(&rows)));185     }186     Ok(())187 }188 189 fn create(fs: &FlagSet, p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {190     let name = name_of(fs, p, "create")?;191     let mut items = load(fg)?;192     if items.iter().any(|m| m.name == name) {193         return Err(CliError::Usage(format!("milestone {name}: exists")));194     }195     let m = Milestone {196         name,197         state: match fs.get("state") {198             Some(s) => check_state(s)?,199             None => "open".to_string(),200         },201         due: fs.get("due").filter(|s| !s.is_empty()).map(str::to_string),202         description: desc_of(fs),203     };204     items.push(m.clone());205     save(fg, &items)?;206     if json {207         output::emit_json(&m, jq);208     } else {209         output::out(&format!("{}\n", m.name));210     }211     Ok(())212 }213 214 fn edit(fs: &FlagSet, p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {215     let name = name_of(fs, p, "edit")?;216     let mut items = load(fg)?;217     let at = items.iter().position(|m| m.name == name).ok_or_else(|| CliError::Usage(format!("milestone {name}: not found")))?;218     if let Some(s) = fs.get("state") {219         items[at].state = check_state(s)?;220     }221     if let Some(d) = fs.get("due") {222         items[at].due = if d.is_empty() { None } else { Some(d.to_string()) };223     }224     if fs.get("description").or_else(|| fs.get("desc")).is_some() {225         items[at].description = desc_of(fs);226     }227     let done = items[at].clone();228     save(fg, &items)?;229     if json {230         output::emit_json(&done, jq);231     } else {232         output::out(&format!("{}\n", done.name));233     }234     Ok(())235 }