crates/cli/src/cmd/label.rs 6.9 KiB 217 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge label: definitions in .forge/labels.md (M3c). Never entity files.3 use crate::args::{parse_flags, spec_from_lists, FlagSet};4 use crate::output;5 use crate::CliError;6 use f0rge_core::{Forge, Label};7 8 pub fn help() -> &'static str {9     "f0rge label: definitions\nusage: f0rge label <list|create|edit> [--json]\ncreate: label create <name>\nfile: .forge/labels.md\ndocs: spec/schema.md §Label\ncontract: f0rge --skill\n"10 }11 12 const VALS: &[&str] = &["name", "color", "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("label <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("label <list|create|edit>".to_string())),28     }29 }30 31 fn load(fg: &Forge) -> Result<Vec<Label>, CliError> {32     let bytes = match fg.read_capped(&fg.labels_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("labels.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::<Label>(&text, "labels.md").map_err(CliError::from)?;44     for x in &w {45         eprintln!("warning: labels.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`, `5319E7`, `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: &[Label]) -> Vec<u8> {109     if items.is_empty() {110         return b"[]\n".to_vec();111     }112     let mut o = String::new();113     for l in items {114         o.push_str("- name: ");115         o.push_str(&esc(&l.name));116         o.push('\n');117         if let Some(c) = &l.color {118             o.push_str("  color: ");119             o.push_str(&esc(c));120             o.push('\n');121         }122         if let Some(d) = &l.description {123             o.push_str("  description: ");124             o.push_str(&esc(d));125             o.push('\n');126         }127     }128     o.into_bytes()129 }130 131 fn save(fg: &Forge, items: &[Label]) -> Result<(), CliError> {132     fg.atomic_write(&fg.labels_file(), &render(items)).map_err(CliError::from)133 }134 135 fn name_of(fs: &FlagSet, p: &[String], verb: &str) -> Result<String, CliError> {136     if let Some(n) = fs.get("name").filter(|s| !s.is_empty()) {137         return Ok(n.to_string());138     }139     if let Some(n) = p.get(1).filter(|s| !s.is_empty()) {140         return Ok(n.clone());141     }142     Err(CliError::Usage(format!("label {verb} --name <name>")))143 }144 145 fn desc_of(fs: &FlagSet) -> Option<String> {146     fs.get("description")147         .or_else(|| fs.get("desc"))148         .filter(|s| !s.is_empty())149         .map(str::to_string)150 }151 152 fn list(fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {153     let mut items = load(fg)?;154     items.sort_by(|a, b| a.name.cmp(&b.name));155     if json {156         if items.is_empty() {157             output::emit_str("[]", jq);158         } else {159             let rows: Vec<String> = items.iter().map(output::json_line).collect();160             output::emit_str(&rows.join("\n"), jq);161         }162     } else if !items.is_empty() {163         let rows: Vec<Vec<String>> = items164             .iter()165             .map(|l| {166                 vec![167                     l.name.clone(),168                     l.color.clone().unwrap_or_else(|| "-".to_string()),169                     l.description.clone().unwrap_or_default(),170                 ]171             })172             .collect();173         output::out(&format!("{}\n", output::align(&rows)));174     }175     Ok(())176 }177 178 fn create(fs: &FlagSet, p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {179     let name = name_of(fs, p, "create")?;180     let mut items = load(fg)?;181     if items.iter().any(|l| l.name == name) {182         return Err(CliError::Usage(format!("label {name}: exists")));183     }184     let l = Label {185         name,186         color: fs.get("color").filter(|s| !s.is_empty()).map(str::to_string),187         description: desc_of(fs),188     };189     items.push(l.clone());190     save(fg, &items)?;191     if json {192         output::emit_json(&l, jq);193     } else {194         output::out(&format!("{}\n", l.name));195     }196     Ok(())197 }198 199 fn edit(fs: &FlagSet, p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {200     let name = name_of(fs, p, "edit")?;201     let mut items = load(fg)?;202     let at = items.iter().position(|l| l.name == name).ok_or_else(|| CliError::Usage(format!("label {name}: not found")))?;203     if let Some(c) = fs.get("color") {204         items[at].color = if c.is_empty() { None } else { Some(c.to_string()) };205     }206     if fs.get("description").or_else(|| fs.get("desc")).is_some() {207         items[at].description = desc_of(fs);208     }209     let done = items[at].clone();210     save(fg, &items)?;211     if json {212         output::emit_json(&done, jq);213     } else {214         output::out(&format!("{}\n", done.name));215     }216     Ok(())217 }