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 }