1 //! M5 acceptance: wiki round-trip + derived index + path escape,2 //! release notes truth + build side effect + list/view, via the binary.3 4 use std::io::Write;5 use std::path::{Path, PathBuf};6 use std::process::{Command, Stdio};7 8 fn bin() -> Command {9 Command::new(env!("CARGO_BIN_EXE_f0rge"))10 }11 12 fn run_in(dir: &Path, args: &[&str]) -> (String, String, i32) {13 let out = bin().current_dir(dir).args(args).output().expect("run f0rge");14 (15 String::from_utf8_lossy(&out.stdout).into_owned(),16 String::from_utf8_lossy(&out.stderr).into_owned(),17 out.status.code().unwrap_or(-1),18 )19 }20 21 fn run_in_stdin(dir: &Path, args: &[&str], input: &[u8]) -> (String, String, i32) {22 let mut child =23 bin().current_dir(dir).args(args).stdin(Stdio::piped()).stdout(Stdio::piped())24 .stderr(Stdio::piped()).spawn().expect("spawn f0rge");25 // Early-exit children (usage/path checks run before stdin is read)26 // may close the pipe first; EPIPE here is fine, the exit-code and27 // output assertions below still verify behavior.28 let _ = child.stdin.take().unwrap().write_all(input);29 let out = child.wait_with_output().expect("wait f0rge");30 (31 String::from_utf8_lossy(&out.stdout).into_owned(),32 String::from_utf8_lossy(&out.stderr).into_owned(),33 out.status.code().unwrap_or(-1),34 )35 }36 37 fn tmp(tag: &str) -> PathBuf {38 let d =39 std::env::temp_dir().join(format!("f0rge-m5-{tag}-{}-{}", std::process::id(), line!()));40 let _ = std::fs::remove_dir_all(&d);41 std::fs::create_dir_all(&d).unwrap();42 d43 }44 45 fn jv(s: &str) -> serde_json::Value {46 serde_json::from_str(s.trim()).expect("json")47 }48 49 #[test]50 fn wiki_round_trip_and_index() {51 let dir = tmp("wiki");52 assert_eq!(run_in(&dir, &["init"]).2, 0);53 54 let (o, _, code) = run_in(&dir, &["wiki", "list", "--json"]);55 assert_eq!((code, o.as_str()), (0, "[]\n"));56 57 let body = "# Glossary\n\nTerm: forge.\n";58 let (o, err, code) = run_in_stdin(&dir, &["wiki", "put", "docs/glossary.md", "-"], body.as_bytes());59 assert_eq!((code, err.as_str()), (0, ""));60 assert!(o.contains(".forge/wiki/docs/glossary.md"), "put prints path: {o}");61 assert_eq!(std::fs::read(dir.join(".forge/wiki/docs/glossary.md")).unwrap(), body.as_bytes());62 63 // nested dirs created64 let (o, _, code) =65 run_in_stdin(&dir, &["wiki", "put", "research/exp/a.md", "--json", "-"], b"no h1 here\n");66 assert_eq!(code, 0);67 let v = jv(&o);68 assert_eq!(v["path"], "research/exp/a.md");69 assert_eq!(v["title"], "a"); // filename stem fallback70 assert_eq!(v["size"], 11);71 72 // get round-trip, exact bytes73 let (o, _, code) = run_in(&dir, &["wiki", "get", "docs/glossary.md"]);74 assert_eq!(code, 0);75 assert_eq!(o, body);76 77 // list derives index: tree + first H178 let (o, _, code) = run_in(&dir, &["wiki", "list", "--json"]);79 assert_eq!(code, 0);80 let v: serde_json::Value = jv(&o);81 let rows = v.as_array().unwrap();82 assert_eq!(rows.len(), 2);83 assert_eq!(rows[0]["path"], "docs/glossary.md");84 assert_eq!(rows[0]["title"], "Glossary");85 assert_eq!(rows[1]["path"], "research/exp/a.md");86 let (h, _, code) = run_in(&dir, &["wiki", "list"]);87 assert_eq!(code, 0);88 assert!(h.contains("docs/glossary.md") && h.contains("Glossary"), "human:\n{h}");89 90 let _ = std::fs::remove_dir_all(&dir);91 }92 93 #[test]94 fn wiki_escape_rejected() {95 let dir = tmp("esc");96 assert_eq!(run_in(&dir, &["init"]).2, 0);97 for args in [&["wiki", "get", "../etc/passwd"] as &[&str], &["wiki", "get", "/etc/passwd"]] {98 let (o, err, code) = run_in(&dir, args);99 assert_eq!(code, 1, "args {args:?}");100 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");101 assert_eq!(o, "");102 }103 let (_, err, code) = run_in_stdin(&dir, &["wiki", "put", "../evil", "-"], b"x");104 assert_eq!(code, 1);105 assert_eq!(err.lines().count(), 1);106 assert!(!dir.join(".forge/evil").exists());107 assert!(!dir.join(".forge/wiki").join("../evil").exists());108 let (_, err, code) = run_in(&dir, &["wiki", "get", "missing.md"]);109 assert_eq!(code, 1);110 assert_eq!(err.lines().count(), 1);111 let (_, err, code) = run_in(&dir, &["wiki"]);112 assert_eq!(code, 2);113 assert_eq!(err.lines().count(), 1);114 let _ = std::fs::remove_dir_all(&dir);115 }116 117 #[cfg(unix)]118 fn mkexec(p: &Path, body: &str) {119 use std::os::unix::fs::PermissionsExt;120 std::fs::write(p, body).unwrap();121 std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap();122 }123 124 #[test]125 fn release_notes_truth_and_build() {126 let dir = tmp("rel");127 assert_eq!(run_in(&dir, &["init"]).2, 0);128 let (o, _, code) = run_in(&dir, &["release", "list", "--json"]);129 assert_eq!((code, o.as_str()), (0, "[]\n"));130 131 // build script writes into $OUT (== releases/out/<ver>/)132 #[cfg(unix)]133 mkexec(134 &dir.join(".forge/releases/build/01_mk.sh"),135 "#!/bin/sh\nmkdir -p \"$OUT\"\necho ok > \"$OUT/marker.txt\"\n",136 );137 std::fs::write(dir.join("notes.md"), "## Notes\n- hi\n").unwrap();138 let (o, err, code) = run_in(&dir, &["release", "create", "v0.1.0", "--notes", "notes.md"]);139 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");140 let v = jv(&o);141 assert_eq!(v["version"], "v0.1.0");142 #[cfg(unix)]143 {144 // M8: sha256 block appended after the build left artifacts145 assert!(v["body"].as_str().unwrap().starts_with("## Notes\n- hi\n"));146 assert!(v["body"].as_str().unwrap().contains("## sha256"));147 }148 #[cfg(not(unix))]149 assert_eq!(v["body"], "## Notes\n- hi\n");150 // notes saved with frontmatter per spec151 let saved = std::fs::read_to_string(dir.join(".forge/releases/v0.1.0.md")).unwrap();152 assert!(saved.starts_with("---\n") && saved.contains("version: v0.1.0"), "saved:\n{saved}");153 assert!(saved.contains("- hi"));154 // build side effect ran (no git/tag in temp repo: current-tree fallback)155 #[cfg(unix)]156 assert_eq!(157 std::fs::read_to_string(dir.join(".forge/releases/out/v0.1.0/marker.txt")).unwrap(),158 "ok\n"159 );160 161 let (o, _, code) = run_in(&dir, &["release", "list", "--json"]);162 assert_eq!(code, 0);163 let rows = jv(&o).as_array().unwrap().clone();164 assert_eq!(rows.len(), 1);165 assert_eq!(rows[0]["version"], "v0.1.0");166 #[cfg(unix)]167 assert_eq!(rows[0]["status"], "built");168 let (h, _, code) = run_in(&dir, &["release", "list"]);169 assert_eq!(code, 0);170 assert!(h.contains("v0.1.0"), "human:\n{h}");171 172 let (o, _, code) = run_in(&dir, &["release", "view", "v0.1.0"]);173 assert_eq!(code, 0);174 #[cfg(unix)]175 {176 assert!(o.starts_with("## Notes\n- hi\n"));177 assert!(o.contains("## sha256") && o.contains("marker.txt"));178 }179 #[cfg(not(unix))]180 assert_eq!(o, "## Notes\n- hi\n");181 182 // failing build: exit 1 one line, notes still saved183 #[cfg(unix)]184 mkexec(&dir.join(".forge/releases/build/01_mk.sh"), "#!/bin/sh\nexit 3\n");185 let (o, err, code) = run_in(&dir, &["release", "create", "v0.2.0", "--notes", "notes.md"]);186 assert_eq!(code, 1);187 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");188 assert_eq!(o, "");189 assert!(dir.join(".forge/releases/v0.2.0.md").is_file(), "notes still saved");190 191 // errors: unknown version, missing notes flag, bad ver192 let (_, err, code) = run_in(&dir, &["release", "view", "v9.9.9"]);193 assert_eq!(code, 1);194 assert_eq!(err.lines().count(), 1);195 let (_, err, code) = run_in(&dir, &["release", "create", "v0.3.0"]);196 assert_eq!(code, 2);197 assert_eq!(err.lines().count(), 1);198 199 // stdin notes200 #[cfg(unix)]201 mkexec(202 &dir.join(".forge/releases/build/01_mk.sh"),203 "#!/bin/sh\nmkdir -p \"$OUT\"\n: > \"$OUT/m2.txt\"\n",204 );205 let (o, _, code) =206 run_in_stdin(&dir, &["release", "create", "v0.3.0", "--notes", "-"], b"from stdin\n");207 assert_eq!(code, 0);208 #[cfg(unix)]209 {210 let b = jv(&o)["body"].as_str().unwrap().to_string();211 assert!(b.starts_with("from stdin\n") && b.contains("## sha256") && b.contains("m2.txt"), "{b:?}");212 }213 #[cfg(not(unix))]214 assert_eq!(jv(&o)["body"], "from stdin\n");215 216 let _ = std::fs::remove_dir_all(&dir);217 }218 219 #[test]220 fn wiki_directory_pages_clean_error() {221 let dir = tmp("wikidir");222 assert_eq!(run_in(&dir, &["init"]).2, 0);223 for args in [&["wiki", "get", "docs"] as &[&str], &["wiki", "get", "docs/"]] {224 let (o, err, code) = run_in(&dir, args);225 assert_eq!(code, 1, "args {args:?}");226 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");227 assert!(err.contains("is a directory"), "stderr:\n{err}");228 assert_eq!(o, "");229 }230 let (o, err, code) = run_in_stdin(&dir, &["wiki", "put", "docs", "-"], b"x");231 assert_eq!(code, 1);232 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");233 assert!(err.contains("is a directory"), "stderr:\n{err}");234 assert_eq!(o, "");235 let _ = std::fs::remove_dir_all(&dir);236 }237 238 #[test]239 fn release_unknown_field_warns() {240 let dir = tmp("relwarn");241 assert_eq!(run_in(&dir, &["init"]).2, 0);242 std::fs::write(243 dir.join(".forge/releases/v0.0.1.md"),244 "---\nversion: v0.0.1\ncreated: 2025-09-08T06:00:00Z\nzzz: 1\n---\nnotes\n",245 )246 .unwrap();247 let (o, err, code) = run_in(&dir, &["release", "view", "v0.0.1"]);248 assert_eq!(code, 0);249 assert_eq!(o, "notes\n");250 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");251 assert!(err.contains("v0.0.1.md") && err.contains("zzz"), "stderr:\n{err}");252 let (_, err, code) = run_in(&dir, &["release", "list", "--json"]);253 assert_eq!(code, 0);254 assert!(err.lines().any(|l| l.contains("zzz")), "stderr:\n{err}");255 let _ = std::fs::remove_dir_all(&dir);256 }257 258 #[test]259 fn wiki_human_list_shows_size() {260 // #29: human table gains size column; json unchanged.261 let dir = tmp("wikisize");262 assert_eq!(run_in(&dir, &["init"]).2, 0);263 let body = "# T\n\nhello\n";264 let (_, _, code) = run_in_stdin(&dir, &["wiki", "put", "docs/s.md", "-"], body.as_bytes());265 assert_eq!(code, 0);266 let (h, _, code) = run_in(&dir, &["wiki", "list"]);267 assert_eq!(code, 0);268 assert!(h.contains("docs/s.md") && h.contains("11"), "human:\n{h}");269 let (j, _, code) = run_in(&dir, &["wiki", "list", "--json"]);270 assert_eq!(code, 0);271 assert_eq!(jv(&j)[0]["size"], body.len() as u64);272 let _ = std::fs::remove_dir_all(&dir);273 }