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 }