1 //! M2 acceptance: budgets enforced on real binary output + init skeleton.2 //! Budgets: root <=12 lines <=300B; sub -h <=8 lines <=250B;3 //! --skill <=30 lines <=1000B; errors 1 line + code.4 5 use std::process::Command;6 7 fn bin() -> Command {8 Command::new(env!("CARGO_BIN_EXE_f0rge"))9 }10 11 fn run(args: &[&str]) -> (String, String, i32) {12 let out = bin().args(args).output().expect("run f0rge");13 (14 String::from_utf8_lossy(&out.stdout).into_owned(),15 String::from_utf8_lossy(&out.stderr).into_owned(),16 out.status.code().unwrap_or(-1),17 )18 }19 20 fn lines_and_bytes(s: &str) -> (usize, usize) {21 (s.lines().count(), s.len())22 }23 24 const SUBS: &[&str] =25 &["issue", "pr", "task", "label", "milestone", "ci", "wiki", "release", "merge", "init", "skill", "spec"];26 27 #[test]28 fn version_is_clean() {29 let (out, err, code) = run(&["--version"]);30 assert_eq!(code, 0);31 assert_eq!(out.trim(), concat!("f0rge ", env!("CARGO_PKG_VERSION")));32 assert!(err.is_empty());33 }34 35 #[test]36 fn root_help_within_budget() {37 let (out, _, code) = run(&[]);38 assert_eq!(code, 0);39 let (lines, bytes) = lines_and_bytes(&out);40 assert!(lines <= 12, "root {lines} lines:\n{out}");41 assert!(bytes <= 300, "root {bytes} bytes:\n{out}");42 }43 44 #[test]45 fn every_sub_help_within_budget() {46 for sub in SUBS {47 let (out, _, code) = run(&[sub, "-h"]);48 assert_eq!(code, 0, "{sub} -h exit {code}");49 let (lines, bytes) = lines_and_bytes(&out);50 assert!(lines <= 8, "{sub} {lines} lines:\n{out}");51 assert!(bytes <= 250, "{sub} {bytes} bytes:\n{out}");52 }53 }54 55 #[test]56 fn skill_within_budget() {57 let (out, _, code) = run(&["--skill"]);58 assert_eq!(code, 0);59 let (lines, bytes) = lines_and_bytes(&out);60 assert!(lines <= 30, "skill {lines} lines:\n{out}");61 assert!(bytes <= 1000, "skill {bytes} bytes:\n{out}");62 // content gates: decisions from spec/schema.md must appear.63 for needle in ["uuid4", "(created,uuid)", "one file per entity", "unknown field warning",64 "missing required error", "exit code", "close/archive only", "mirrors folder tree"]65 {66 assert!(out.contains(needle), "skill missing `{needle}`:\n{out}");67 }68 }69 70 #[test]71 fn skill_single_source_body_equals_skill_text() {72 let (out, _, code) = run(&["--skill"]);73 assert_eq!(code, 0);74 let md = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../skill/skill.md"))75 .expect("read skill/skill.md");76 let mut parts = md.splitn(3, "---\n");77 let _ = parts.next().expect("leading fence");78 let fm = parts.next().expect("frontmatter");79 let body = parts.next().expect("body");80 assert!(fm.contains("name:"), "frontmatter name missing");81 assert!(fm.contains("description:"), "frontmatter description missing");82 assert_eq!(body, out, "skill.md body != --skill output");83 let (root, _, _) = run(&[]);84 let root_first = root.lines().next().unwrap_or("");85 let skill_first = out.lines().next().unwrap_or("");86 assert!(skill_first.contains("CONTRACT"), "contract must self-identify: {skill_first:?}");87 assert_ne!(root_first, skill_first, "openings confusable");88 assert!(root.contains("contract: f0rge --skill"), "root must point at contract");89 }90 91 #[test]92 fn init_creates_skeleton_idempotent() {93 let dir = std::env::temp_dir().join(format!("f0rge-cli-init-{}-{}", std::process::id(), line!()));94 let _ = std::fs::remove_dir_all(&dir);95 std::fs::create_dir_all(&dir).unwrap();96 let s = dir.to_string_lossy().into_owned();97 let (_, _, code) = run(&["init", &s]);98 assert_eq!(code, 0);99 let forge = dir.join(".forge");100 for d in ["issues", "prs", "tasks", "wiki/docs", "wiki/research", "wiki/experiments",101 "ci/steps", "releases/build", "merges"]102 {103 assert!(forge.join(d).is_dir(), "missing dir {d}");104 }105 for f in ["labels.md", "milestones.md", "ci/ci.env"] {106 assert!(forge.join(f).is_file(), "missing file {f}");107 }108 let gi = std::fs::read_to_string(dir.join(".gitignore")).unwrap();109 for w in [".forge/ci/runs/", ".forge/releases/out/", "*.tmp"] {110 assert!(gi.lines().any(|l| l.trim() == w), ".gitignore missing {w}:\n{gi}");111 }112 // idempotent: second run keeps content, no dup gitignore lines.113 std::fs::write(forge.join("labels.md"), "- name: x\n").unwrap();114 let (_, _, code) = run(&["init", &s]);115 assert_eq!(code, 0);116 assert_eq!(std::fs::read_to_string(forge.join("labels.md")).unwrap(), "- name: x\n");117 let gi2 = std::fs::read_to_string(dir.join(".gitignore")).unwrap();118 for w in [".forge/ci/runs/", ".forge/releases/out/", "*.tmp"] {119 assert_eq!(gi2.lines().filter(|l| l.trim() == w).count(), 1, "dup {w}:\n{gi2}");120 }121 let _ = std::fs::remove_dir_all(&dir);122 }123 124 #[test]125 fn errors_are_one_line_with_codes() {126 let (_, err, code) = run(&["--bogus"]);127 assert_eq!(code, 2);128 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");129 let (_, err, code) = run(&["issue"]);130 assert_eq!(code, 2);131 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");132 let (_, err, code) = run(&["nope"]);133 assert_eq!(code, 2);134 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");135 }136 137 #[test]138 fn jq_missing_is_one_line_note() {139 // jq exists on most dev boxes; either filtered output or the note.140 let (out, err, code) = run(&["--jq", ".", "--skill"]);141 assert_eq!(code, 0);142 if err.is_empty() {143 assert!(!out.is_empty());144 } else {145 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");146 }147 }148 149 #[test]150 fn epipe_closed_stdout_is_clean_exit_zero() {151 // #18: closed stdout pipe = clean end, exit 0, no panic, no stderr.152 use std::process::Stdio;153 let dir = std::env::temp_dir()154 .join(format!("f0rge-cli-epipe-{}-{}", std::process::id(), line!()));155 let _ = std::fs::remove_dir_all(&dir);156 std::fs::create_dir_all(&dir).unwrap();157 let out = bin().current_dir(&dir).arg("init").output().expect("init");158 assert!(out.status.success());159 // Wide listing so output exceeds the pipe buffer: reader close => EPIPE mid-write.160 for i in 0..300 {161 let out = bin()162 .current_dir(&dir)163 .args(["issue", "create", "--title", &format!("bulk-{i}")])164 .output()165 .expect("create");166 assert!(out.status.success(), "seed {i} failed");167 }168 for args in [vec!["issue", "list", "--json"], vec!["issue", "list"]] {169 let mut child = bin()170 .current_dir(&dir)171 .args(&args)172 .stdout(Stdio::piped())173 .stderr(Stdio::piped())174 .spawn()175 .expect("spawn");176 // Close the reader at once: `| true` with the read end already gone.177 drop(child.stdout.take());178 let res = child.wait_with_output().expect("wait");179 let code = res.status.code().unwrap_or(-1);180 let err = String::from_utf8_lossy(&res.stderr);181 assert_eq!(code, 0, "{args:?} exit {code}, stderr: {err}");182 assert!(!err.contains("panicked"), "{args:?} panicked: {err}");183 assert!(err.is_empty(), "{args:?} stderr not empty: {err}");184 }185 let _ = std::fs::remove_dir_all(&dir);186 }187 #[test]188 fn sub_help_single_style_with_contract_pointer() {189 // #19: every `f0rge <cmd> -h` = title + usage + truthful detail + contract pointer.190 for sub in SUBS {191 let (out, _, code) = run(&[sub, "-h"]);192 assert_eq!(code, 0, "{sub} -h exit {code}");193 let first = out.lines().next().unwrap_or("");194 assert!(first.starts_with(&format!("f0rge {sub}")), "{sub} style: {first:?}");195 assert!(out.contains("usage:"), "{sub} missing usage:\n{out}");196 assert!(197 out.trim_end().ends_with("contract: f0rge --skill"),198 "{sub} missing contract pointer:\n{out}"199 );200 }201 }202 203 204 #[test]205 fn spec_prints_embedded_schema() {206 let (out, _, code) = run(&["spec"]);207 assert_eq!(code, 0);208 let canon = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/schema.md"))209 .expect("read spec/schema.md");210 assert_eq!(out, canon, "f0rge spec != spec/schema.md");211 }212 213 #[test]214 fn spec_write_overwrites_and_init_never_clobbers() {215 use std::process::Command;216 let dir = std::env::temp_dir().join(format!("f0rge-cli-spec-{}-{}", std::process::id(), line!()));217 let _ = std::fs::remove_dir_all(&dir);218 std::fs::create_dir_all(&dir).unwrap();219 let s = dir.to_string_lossy().into_owned();220 let st = Command::new(env!("CARGO_BIN_EXE_f0rge")).args(["init", &s]).output().unwrap();221 assert!(st.status.success(), "init: {st:?}");222 let schema = dir.join("spec/schema.md");223 assert!(schema.is_file(), "init must write spec/schema.md");224 let canon = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/schema.md")).unwrap();225 assert_eq!(std::fs::read_to_string(&schema).unwrap(), canon);226 // init never clobbers: hand-edit then re-init keeps edit.227 std::fs::write(&schema, "custom\n").unwrap();228 let st = Command::new(env!("CARGO_BIN_EXE_f0rge")).args(["init", &s]).output().unwrap();229 assert!(st.status.success());230 assert_eq!(std::fs::read_to_string(&schema).unwrap(), "custom\n");231 // spec --write re-clobbers back to canonical.232 let st = Command::new(env!("CARGO_BIN_EXE_f0rge")).arg("spec").arg("--write").current_dir(&dir).output().unwrap();233 assert!(st.status.success(), "spec --write: {st:?}");234 assert_eq!(std::fs::read_to_string(&schema).unwrap(), canon);235 let _ = std::fs::remove_dir_all(&dir);236 }