crates/cli/tests/install.rs 12.0 KiB 297 lines Raw
1 //! #32/#49: release install resolve+stage+handoff + dry-run + 03_release artifact.2 3 use std::path::{Path, PathBuf};4 use std::process::Command;5 6 fn bin() -> Command {7     Command::new(env!("CARGO_BIN_EXE_f0rge"))8 }9 10 fn run_in(dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> (String, String, i32) {11     let mut c = bin();12     c.current_dir(dir).args(args);13     for (k, v) in envs {14         c.env(k, v);15     }16     let out = c.output().expect("run f0rge");17     (18         String::from_utf8_lossy(&out.stdout).into_owned(),19         String::from_utf8_lossy(&out.stderr).into_owned(),20         out.status.code().unwrap_or(-1),21     )22 }23 24 fn tmp(tag: &str) -> PathBuf {25     let d =26         std::env::temp_dir().join(format!("f0rge-install-{tag}-{}-{}", std::process::id(), line!()));27     let _ = std::fs::remove_dir_all(&d);28     std::fs::create_dir_all(&d).unwrap();29     d30 }31 32 fn record(dir: &Path, ver: &str, created: &str) {33     let body = format!("---\nversion: {ver}\ncreated: {created}\n---\nnotes {ver}\n");34     std::fs::write(dir.join(".forge/releases").join(format!("{ver}.md")), body).unwrap();35 }36 37 fn record_sha(dir: &Path, ver: &str, created: &str, sha: &str) {38     let body =39         format!("---\nversion: {ver}\ncreated: {created}\nsha: {sha}\n---\nnotes {ver}\n");40     std::fs::write(dir.join(".forge/releases").join(format!("{ver}.md")), body).unwrap();41 }42 43 fn head_short(dir: &Path) -> String {44     let o = Command::new("git").current_dir(dir).args(["rev-parse", "--short", "HEAD"]).output().expect("git rev-parse");45     assert!(o.status.success());46     String::from_utf8(o.stdout).unwrap().trim().to_string()47 }48 49 fn git_init(dir: &Path) {50     git(dir, &["init", "-q"]);51     git(dir, &["config", "user.email", "t@t"]);52     git(dir, &["config", "user.name", "t"]);53     git(dir, &["add", "-A"]);54     git(dir, &["commit", "-qm", "init"]);55 }56 57 fn git(dir: &Path, args: &[&str]) {58     let st = Command::new("git").current_dir(dir).args(args).status().expect("git");59     assert!(st.success(), "git {args:?} failed");60 }61 62 fn cargo_manifest(dir: &Path) {63     std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"f0rge\"\nversion = \"0.0.0\"\n").unwrap();64 }65 66 fn write_sh(dir: &Path, body: &str) {67     let p = dir.join("install.sh");68     std::fs::write(&p, body).unwrap();69     #[cfg(unix)]70     {71         use std::os::unix::fs::PermissionsExt;72         std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();73     }74 }75 76 #[test]77 fn install_dry_run_latest_and_main() {78     let dir = tmp("dry");79     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);80     cargo_manifest(&dir);81     record(&dir, "v0.1.0", "2026-01-01T00:00:00Z");82     record(&dir, "v0.1.1", "2026-01-02T00:00:00Z");83     let home = dir.join("home");84     let ch = home.join("cargo-home");85     let dest = ch.join("bin").join("f0rge");86 87     // latest = newest record by (created, version): v0.1.1, cargo project -> compile dry-run.88     let (o, err, code) =89         run_in(&dir, &["release", "install", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);90     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");91     assert!(o.contains("v0.1.1"), "latest must resolve v0.1.1:\n{o}");92     assert!(o.contains("would run:") && o.contains("cargo install"), "dry compile:\n{o}");93     assert_eq!(o.lines().count(), 1, "one line per step:\n{o}");94     assert!(!dest.exists(), "dry-run touches nothing");95 96     // main = current checkout.97     let (o, err, code) =98         run_in(&dir, &["release", "install", "main", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);99     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");100     assert!(o.contains("cargo install") && o.contains("main"), "main dry:\n{o}");101     assert_eq!(o.lines().count(), 1, "one line:\n{o}");102     assert!(!dest.exists(), "dry-run touches nothing");103 104     let _ = std::fs::remove_dir_all(&dir);105 }106 107 #[test]108 fn install_ver_pinned_tag_optional_falls_back() {109     let dir = tmp("pin");110     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);111     cargo_manifest(&dir);112     record(&dir, "v9.9.9", "2026-01-01T00:00:00Z");113     git_init(&dir);114     git(&dir, &["tag", "v9.9.9"]);115 116     let (o, err, code) =117         run_in(&dir, &["release", "install", "v9.9.9", "--dry-run"], &[("HOME", "/nonexistent-x")]);118     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");119     assert!(o.contains("v9.9.9"), "pinned resolves tag:\n{o}");120 121     // unknown version (no record, no tag) -> usage, one line.122     let (o, err, code) = run_in(&dir, &["release", "install", "v0.0.0", "--dry-run"], &[]);123     assert_eq!(code, 2, "stdout:\n{o}\nstderr:\n{err}");124     assert_eq!(err.lines().count(), 1, "stderr:\n{err}");125     assert!(err.contains("no such release: v0.0.0"), "stderr:\n{err}");126     assert_eq!(o, "");127 128     // record exists but tag missing -> falls back to current checkout (#48).129     record(&dir, "v8.8.8", "2026-01-03T00:00:00Z");130     let (o, err, code) = run_in(&dir, &["release", "install", "v8.8.8", "--dry-run"], &[]);131     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");132     assert!(o.contains("v8.8.8") && o.contains("cargo install"), "fallback:\n{o}");133     assert!(o.contains("current checkout"), "labels fallback:\n{o}");134     assert_eq!(o.lines().count(), 1, "one line:\n{o}");135 136     let _ = std::fs::remove_dir_all(&dir);137 }138 139 #[test]140 fn install_sh_handoff_dry_and_real() {141     // #49: CWD project installer owns placement (stale record sha still uses CWD).142     let dir = tmp("sh");143     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);144     git_init(&dir);145     let sha = head_short(&dir);146     record_sha(&dir, "v3.0.0", "2026-01-01T00:00:00Z", &sha);147     // installer added AFTER the record pin (record sha is stale) + committed.148     write_sh(&dir, "#!/bin/sh\necho \"$1 $F0RGE_RELEASE_SHA\" > ran.txt\nexit 0\n");149     git(&dir, &["add", "-A"]);150     git(&dir, &["commit", "-qm", "installer"]);151 152     let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0", "--dry-run"], &[]);153     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");154     assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "dry exec:\n{o}");155     assert_eq!(o.lines().count(), 1, "one line:\n{o}");156     assert!(!dir.join("ran.txt").exists(), "dry-run touches nothing");157 158     let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0"], &[]);159     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");160     assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "ran:\n{o}");161     let ran = std::fs::read_to_string(dir.join("ran.txt")).unwrap();162     assert!(ran.contains("v3.0.0"), "script got ver:\n{ran}");163     assert!(ran.contains(&sha), "script got pin env:\n{ran}");164 165     let _ = std::fs::remove_dir_all(&dir);166 }167 168 #[test]169 fn install_cargo_only_dry() {170     // #49: cargo project with no install.sh -> cargo path.171     let dir = tmp("cargo");172     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);173     cargo_manifest(&dir);174     assert!(!dir.join("install.sh").exists());175     record(&dir, "v4.0.0", "2026-01-01T00:00:00Z");176     git_init(&dir);177     let (o, err, code) = run_in(&dir, &["release", "install", "v4.0.0", "--dry-run"], &[]);178     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");179     assert!(o.contains("cargo install"), "dry cargo:\n{o}");180     assert_eq!(o.lines().count(), 1, "one line:\n{o}");181     let _ = std::fs::remove_dir_all(&dir);182 }183 184 #[test]185 fn install_no_installer_errors() {186     // #49: neither install.sh nor Cargo.toml -> one line + exit 1.187     let dir = tmp("none");188     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);189     assert!(!dir.join("install.sh").exists());190     assert!(!dir.join("Cargo.toml").exists());191     record(&dir, "v5.0.0", "2026-01-01T00:00:00Z");192     git_init(&dir);193     for args in [&["release", "install", "v5.0.0"][..], &["release", "install", "v5.0.0", "--dry-run"][..]] {194         let (o, err, code) = run_in(&dir, args, &[]);195         assert_eq!(code, 1, "args {args:?} stdout:\n{o}\nstderr:\n{err}");196         assert_eq!(o, "", "stdout empty:\n{o}");197         assert_eq!(err.lines().count(), 1, "one line:\n{err}");198         assert!(err.contains("no installer for this project (install.sh or Cargo.toml)"), "stderr:\n{err}");199     }200     let _ = std::fs::remove_dir_all(&dir);201 }202 203 #[test]204 fn release_step_green_on_temp_repo_with_artifact() {205     let dir = tmp("step");206     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);207     record(&dir, "v0.0.1", "2026-01-01T00:00:00Z");208     // stub cargo: pretend the build produced target/release/f0rge.209     let stub = dir.join("stubbin");210     std::fs::create_dir_all(&stub).unwrap();211     std::fs::write(212         stub.join("cargo"),213         "#!/bin/sh\nmkdir -p target/release\necho stub-f0rge > target/release/f0rge\nchmod +x target/release/f0rge\nexit 0\n",214     )215     .unwrap();216     #[cfg(unix)]217     {218         use std::os::unix::fs::PermissionsExt;219         std::fs::set_permissions(stub.join("cargo"), std::fs::Permissions::from_mode(0o755)).unwrap();220     }221     let step = concat!(222         env!("CARGO_MANIFEST_DIR"),223         "/../../.forge/ci/steps/03_release.sh"224     );225     let path = format!("{}:{}", stub.display(), std::env::var("PATH").unwrap_or_default());226     let st = Command::new("sh")227         .current_dir(&dir)228         .arg(step)229         .env("PATH", &path)230         .env("CARGO_BUILD_JOBS", "2")231         .status()232         .expect("run 03_release.sh");233     assert!(st.success(), "03_release green on temp repo");234     let art = dir.join(".forge/releases/build/v0.0.1/f0rge");235     assert!(art.is_file(), "artifact dropped per record");236     assert_eq!(std::fs::read(&art).unwrap(), b"stub-f0rge\n");237 238     let _ = std::fs::remove_dir_all(&dir);239 }240 241 #[test]242 fn create_rejects_bare_version_with_v_hint() {243     // #47: `create 0.2.0` -> usage error (exit 2), one line, try-path.244     let dir = tmp("vpre");245     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);246     std::fs::write(dir.join("notes.md"), "hi\n").unwrap();247     let (o, err, code) = run_in(&dir, &["release", "create", "0.2.0", "--notes", "notes.md"], &[]);248     assert_eq!(code, 2, "stdout:\n{o}\nstderr:\n{err}");249     assert_eq!(err.lines().count(), 1, "stderr:\n{err}");250     assert!(err.contains("versions use the v prefix: v0.2.0"), "stderr:\n{err}");251     assert!(err.contains("(try: f0rge release create --help)"), "stderr:\n{err}");252     assert_eq!(o, "");253     assert!(!dir.join(".forge/releases/0.2.0.md").exists(), "nothing written");254     let _ = std::fs::remove_dir_all(&dir);255 }256 257 #[test]258 fn create_records_head_sha() {259     // #47: `create v0.9.9` writes the record with sha = HEAD (short).260     let dir = tmp("csha");261     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);262     std::fs::write(dir.join("notes.md"), "hi\n").unwrap();263     git_init(&dir);264     let sha = head_short(&dir);265     let (o, err, code) = run_in(&dir, &["release", "create", "v0.9.9", "--notes", "notes.md"], &[]);266     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");267     let md = std::fs::read_to_string(dir.join(".forge/releases/v0.9.9.md")).unwrap();268     assert!(269         md.contains(&format!("sha: {sha}")) || md.contains(&format!("sha: '{sha}'")),270         "record:\n{md}"271     );272     // old records without sha still parse (view green, no warning).273     record(&dir, "v0.1.0", "2026-01-01T00:00:00Z");274     let (o, err, code) = run_in(&dir, &["release", "view", "v0.1.0"], &[]);275     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");276     let _ = std::fs::remove_dir_all(&dir);277 }278 279 #[test]280 fn install_resolves_record_sha_with_no_tags() {281     // #47/#48: record sha wins with zero git tags present.282     let dir = tmp("rsha");283     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);284     cargo_manifest(&dir);285     git_init(&dir);286     let sha = head_short(&dir);287     record_sha(&dir, "v0.9.9", "2026-01-01T00:00:00Z", &sha);288     let tags = Command::new("git").current_dir(&dir).args(["tag", "-l"]).output().unwrap();289     assert!(String::from_utf8(tags.stdout).unwrap().trim().is_empty(), "no tags");290     let (o, err, code) =291         run_in(&dir, &["release", "install", "v0.9.9", "--dry-run"], &[("HOME", "/nonexistent-x")]);292     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");293     assert!(o.contains("v0.9.9") && o.contains(&sha), "record-sha resolution:\n{o}");294     assert!(o.contains("cargo install"), "compile fallback:\n{o}");295     assert_eq!(o.lines().count(), 1, "one line:\n{o}");296     let _ = std::fs::remove_dir_all(&dir);297 }