crates/cli/tests/m5.rs 10.2 KiB 273 lines Raw
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 }