crates/cli/src/cmd/wiki.rs 10.3 KiB 317 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge wiki: derived index over .forge/wiki. Files are the API.3 //! Verbs: get <path>, put <path> -, list. Index never stored.4 5 use crate::{args, output, CliError};6 use f0rge_core::{Forge, ForgeError};7 use serde::Serialize;8 use std::path::{Component, Path};9 10 pub fn help() -> &'static str {11     "f0rge wiki: memory\nusage: f0rge wiki <verb> [--json] [flags]\nverbs: get put list\nput: <path> [stdin|-]; get: <path>\nindex: tree + first H1\ndocs: spec/schema.md §Wiki\ncontract: f0rge --skill\n"12 }13 14 // Per-verb helps next to parser arms (#36, #39). Each <=250B/8ln.15 const GET_H: &str = "f0rge wiki get: read\nusage: f0rge wiki get <path>\ncontract: f0rge --skill\n";16 const PUT_H: &str = "f0rge wiki put: write\nusage: f0rge wiki put <path> [-]\nstdin when omitted; - explicit\ncontract: f0rge --skill\n";17 const LIST_H: &str = "f0rge wiki list: index\nusage: f0rge wiki list\ncontract: f0rge --skill\n";18 19 /// Verb-depth help (#36).20 pub fn verb_help(verb: &str) -> Option<&'static str> {21     Some(match verb {22         "get" => GET_H,23         "put" => PUT_H,24         "list" => LIST_H,25         _ => return None,26     })27 }28 29 fn with_try(e: CliError, verb: &str) -> CliError {30     match e {31         CliError::Usage(m) if !m.contains("(try:") => {32             CliError::Usage(format!("{m} (try: f0rge wiki {verb} --help)"))33         }34         other => other,35     }36 }37 38 fn spec() -> Vec<args::FlagSpec> {39     args::spec_from_lists(&["jq"], &["json"])40 }41 42 fn usage(msg: &str) -> CliError {43     CliError::Usage(msg.to_string())44 }45 46 /// One-line safe echo (SEC-10).47 fn clean(s: &str) -> String {48     s.chars().filter(|c| *c != '\n' && *c != '\r').take(120).collect::<String>().trim().to_string()49 }50 51 /// Wiki-relative path; anything escaping wiki/ -> Runtime (exit 1).52 fn check_rel(raw: &str) -> Result<String, CliError> {53     let mut p = clean(raw);54     if p.len() > 1 && p.ends_with('/') {55         p.pop(); // `docs/` names the page `docs`; dirs fail later as such56     }57     if p.is_empty() || p.starts_with('/') || p == "." || p.ends_with('/') {58         return Err(CliError::Runtime(format!("{p}: escapes wiki")));59     }60     for c in Path::new(&p).components() {61         match c {62             Component::ParentDir | Component::RootDir | Component::Prefix(_) => {63                 return Err(CliError::Runtime(format!("{p}: escapes wiki")));64             }65             _ => {}66         }67     }68     Ok(p)69 }70 71 /// stdin capped at SEC-01 limit.72 fn read_stdin_capped() -> Result<Vec<u8>, CliError> {73     use std::io::Read;74     let mut buf = Vec::new();75     std::io::stdin()76         .lock()77         .take(f0rge_core::MAX_FILE_BYTES as u64 + 1)78         .read_to_end(&mut buf)79         .map_err(|_| CliError::Runtime("stdin: io error".to_string()))?;80     if buf.len() > f0rge_core::MAX_FILE_BYTES {81         return Err(CliError::Runtime("stdin: file over 256 KiB cap".to_string()));82     }83     Ok(buf)84 }85 86 fn first_h1(bytes: &[u8]) -> Option<String> {87     String::from_utf8_lossy(bytes).lines().find_map(|l| {88         l.trim().strip_prefix("# ").map(|s| s.trim().to_string()).filter(|s| !s.is_empty())89     })90 }91 92 fn stem_of(rel: &str) -> String {93     Path::new(rel)94         .file_stem()95         .map(|s| s.to_string_lossy().into_owned())96         .unwrap_or_else(|| rel.to_string())97 }98 99 #[derive(Debug, Clone, Serialize)]100 struct PutJson {101     path: String,102     title: String,103     size: u64,104 }105 106 #[derive(Debug, Clone, Serialize)]107 struct GetJson {108     path: String,109     body: String,110 }111 112 #[derive(Debug, Clone, Serialize)]113 struct ListJson {114     path: String,115     title: String,116     size: u64,117 }118 119 fn get(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {120     let rel = check_rel(raw)?;121     if fg.root().join(fg.wiki_file(&rel)).is_dir() {122         return Err(CliError::Runtime(format!("{rel}: is a directory")));123     }124     let bytes = fg.read_capped(&fg.wiki_file(&rel)).map_err(|e| match e {125         ForgeError::NotFound { .. } => CliError::Runtime(format!("{rel}: not found")),126         ForgeError::Escape { .. } => CliError::Runtime(format!("{rel}: escapes wiki")),127         _ => CliError::from(e),128     })?;129     if fs.is("json") {130         output::emit_json(131             &GetJson { path: rel, body: String::from_utf8_lossy(&bytes).into_owned() },132             fs.get("jq"),133         );134         return Ok(());135     }136     use std::io::Write;137     let _ = std::io::stdout().write_all(&bytes);138     Ok(())139 }140 141 fn put(fs: &args::FlagSet, fg: &Forge, raw: &str, src: &str) -> Result<(), CliError> {142     let rel = check_rel(raw)?;143     if src != "-" {144         return Err(usage("f0rge wiki put <path> -"));145     }146     if fg.root().join(fg.wiki_file(&rel)).is_dir() {147         return Err(CliError::Runtime(format!("{rel}: is a directory")));148     }149     let bytes = read_stdin_capped()?;150     fg.atomic_write(&fg.wiki_file(&rel), &bytes).map_err(|e| match e {151         ForgeError::Escape { .. } => CliError::Runtime(format!("{rel}: escapes wiki")),152         _ => CliError::from(e),153     })?;154     let title = first_h1(&bytes).unwrap_or_else(|| stem_of(&rel));155     let size = bytes.len() as u64;156     if fs.is("json") {157         output::emit_json(&PutJson { path: rel, title, size }, fs.get("jq"));158         return Ok(());159     }160     output::out(&format!(".forge/wiki/{rel}\n"));161     Ok(())162 }163 164 /// Walk .forge/wiki; symlinks never followed; oversize/unreadable skipped.165 fn walk_wiki(fg: &Forge) -> Vec<(String, String, u64)> {166     let root = fg.root().join("wiki");167     let mut out = Vec::new();168     let mut stack = vec![root.clone()];169     while let Some(dir) = stack.pop() {170         let rd = match std::fs::read_dir(&dir) {171             Ok(r) => r,172             Err(_) => continue,173         };174         for e in rd.flatten() {175             let ft = match e.file_type() {176                 Ok(t) => t,177                 Err(_) => continue,178             };179             if ft.is_symlink() {180                 continue;181             }182             let p = e.path();183             if ft.is_dir() {184                 stack.push(p);185                 continue;186             }187             if !ft.is_file() {188                 continue;189             }190             let bytes = match std::fs::read(&p) {191                 Ok(b) => b,192                 Err(_) => continue,193             };194             if bytes.len() > f0rge_core::MAX_FILE_BYTES {195                 continue;196             }197             let size = bytes.len() as u64;198             let rel = match p.strip_prefix(&root) {199                 Ok(r) => r,200                 Err(_) => continue,201             };202             let mut s = String::new();203             for c in rel.components() {204                 if let Component::Normal(os) = c {205                     if !s.is_empty() {206                         s.push('/');207                     }208                     s.push_str(&os.to_string_lossy());209                 }210             }211             if s.is_empty() {212                 continue;213             }214             out.push((s, String::from_utf8_lossy(&bytes).into_owned(), size));215         }216     }217     out218 }219 220 fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {221     if !fs.positional.is_empty() {222         return Err(usage("f0rge wiki list"));223     }224     let files = walk_wiki(fg);225     let sizes: std::collections::BTreeMap<String, u64> =226         files.iter().map(|f| (f.0.clone(), f.2)).collect();227     let pairs: Vec<(String, String)> = files.into_iter().map(|f| (f.0, f.1)).collect();228     let idx = f0rge_core::wiki_index(&pairs); // sorted by path229     if fs.is("json") {230         if idx.is_empty() {231             output::emit_empty_array(fs.get("jq"));232             return Ok(());233         }234         let rows: Vec<ListJson> = idx235             .into_iter()236             .map(|e| ListJson {237                 size: sizes.get(e.path.as_str()).copied().unwrap_or(0),238                 path: e.path,239                 title: e.title,240             })241             .collect();242         output::emit_json(&rows, fs.get("jq"));243         return Ok(());244     }245     if idx.is_empty() {246         return Ok(());247     }248     let grid: Vec<Vec<String>> = idx249         .into_iter()250         .map(|e| {251             let sz = sizes.get(e.path.as_str()).copied().unwrap_or(0);252             vec![e.path, e.title, sz.to_string()]253         })254         .collect();255     output::out(&format!("{}\n", output::align(&grid)));256     Ok(())257 }258 259 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {260     if args_in.is_empty() {261         return Err(usage("f0rge wiki <get|put|list> <path> (try: f0rge wiki -h)"));262     }263     let verb = clean(&args_in[0]);264     if verb.is_empty() || verb.starts_with('-') || !["get", "put", "list"].contains(&verb.as_str()) {265         let shown = if verb.is_empty() { "<none>".to_string() } else { verb };266         return Err(CliError::Usage(format!("unknown verb: {shown} (try: f0rge wiki -h)")));267     }268     let fs = args::parse_flags(&args_in[1..], &spec()).map_err(|e| with_try(e, &verb))?;269     if fs.is("help") {270         if let Some(h) = verb_help(&verb) {271             output::out(h);272             return Ok(());273         }274     }275     let r: Result<(), CliError> = match verb.as_str() {276         "get" => {277             if fs.positional.len() != 1 {278                 return Err(with_try(usage("f0rge wiki get <path>"), &verb));279             }280             get(&fs, fg, &fs.positional[0].clone())281         }282         "put" => {283             // #39: bare stdin accepted; `-` stays explicit.284             if fs.positional.len() == 1 {285                 let p = fs.positional[0].clone();286                 put(&fs, fg, &p, "-")287             } else if fs.positional.len() == 2 {288                 let (p, s) = (fs.positional[0].clone(), fs.positional[1].clone());289                 put(&fs, fg, &p, &s)290             } else {291                 return Err(with_try(usage("f0rge wiki put <path> [-]"), &verb));292             }293         }294         "list" => list(&fs, fg),295         _ => Err(usage("f0rge wiki <get|put|list> <path> (try: f0rge wiki -h)")),296     };297     r.map_err(|e| with_try(e, &verb))298 }299 300 #[cfg(test)]301 mod verb_help_tests {302     use super::*;303     fn budget(s: &str) {304         assert!(s.lines().count() <= 8, "lines:\n{s}");305         assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());306     }307     #[test]308     fn per_verb_budgets_and_needles() {309         let h = verb_help("put").expect("put");310         budget(h);311         assert!(h.contains("<path>"), "put path:\n{h}");312         assert!(h.contains("stdin"), "put stdin:\n{h}");313         budget(verb_help("get").expect("get"));314         budget(verb_help("list").expect("list"));315         assert!(verb_help("frobnicate").is_none());316     }317 }