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 }