crates/cli/src/cmd/release.rs 28.6 KiB 885 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge release: notes are truth, build is a side effect.3 //! Verbs: create <ver> --notes <file|->, list, view <ver>.4 //! Build: tag checkout in temp worktree, scripts run argv-only,5 //! outputs into releases/out/ (gitignored). No binaries in git.6 7 use crate::{args, output, CliError};8 use f0rge_core::{Forge, ForgeError};9 use serde::{Deserialize, Serialize};10 use std::path::{Component, Path, PathBuf};11 use std::process::Command;12 use std::sync::atomic::{AtomicU64, Ordering};13 14 pub fn help() -> &'static str {15     "f0rge release: versions\nusage: f0rge release <create|list|view|install> [ver|main] [--json|--dry-run]\nnotes: .forge/releases/<ver>.md\ncreate: --notes <file|-> [--title S]; install [--dry-run]\ndocs: spec/schema.md §Release\ncontract: f0rge --skill\n"16 }17 18 fn spec() -> Vec<args::FlagSpec> {19     args::spec_from_lists(&["notes", "title", "jq"], &["json", "dry-run"])20 }21 22 fn usage(msg: &str) -> CliError {23     CliError::Usage(msg.to_string())24 }25 26 // Per-verb helps next to parser arms (#36). Each <=250B/8ln.27 const CREATE_H: &str = "f0rge release create: notes\nusage: f0rge release create <ver> --notes <file|-> [--title S]\ncontract: f0rge --skill\n";28 const LIST_H: &str = "f0rge release list: all\nusage: f0rge release list\ncontract: f0rge --skill\n";29 const VIEW_H: &str = "f0rge release view: notes\nusage: f0rge release view <ver>\ncontract: f0rge --skill\n";30 const INSTALL_H: &str = "f0rge release install: setup\nusage: f0rge release install [ver|main] [--dry-run]\ncontract: f0rge --skill\n";31 32 /// Verb-depth help (#36). None when unknown.33 pub fn verb_help(verb: &str) -> Option<&'static str> {34     Some(match verb {35         "create" => CREATE_H,36         "list" => LIST_H,37         "view" => VIEW_H,38         "install" => INSTALL_H,39         _ => return None,40     })41 }42 43 fn with_try(e: CliError, verb: &str) -> CliError {44     match e {45         CliError::Usage(m) if !m.contains("(try:") => {46             CliError::Usage(format!("{m} (try: f0rge release {verb} --help)"))47         }48         other => other,49     }50 }51 52 fn clean(s: &str) -> String {53     s.chars().filter(|c| *c != '\n' && *c != '\r').take(120).collect::<String>().trim().to_string()54 }55 56 /// Release record (#47): `sha` is HEAD (short) at create time;57 /// absent on old records and non-git trees. Local mirror of the58 /// core Release + optional sha, so core stays untouched.59 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]60 struct ReleaseRec {61     version: String,62     #[serde(default, skip_serializing_if = "Option::is_none")]63     title: Option<String>,64     created: String,65     #[serde(default, skip_serializing_if = "Option::is_none")]66     sha: Option<String>,67 }68 69 impl f0rge_core::Validate for ReleaseRec {70     const KNOWN: &'static [&'static str] =71         &["version", "title", "created", "sha", "schema"];72     fn validate(&self, file: &str) -> Result<(), ForgeError> {73         if !f0rge_core::is_rfc3339_utc(&self.created) {74             return Err(ForgeError::BadValue { file: file.to_string(), field: "created".to_string() });75         }76         Ok(())77     }78 }79 80 /// HEAD short sha via argv-only git; None outside git (#47).81 fn head_sha(repo: &Path) -> Option<String> {82     let o = Command::new("git")83         .arg("-C")84         .arg(repo)85         .args(["rev-parse", "--short", "HEAD"])86         .output()87         .ok()?;88     if !o.status.success() {89         return None;90     }91     let s = String::from_utf8(o.stdout).ok()?.trim().to_string();92     if s.is_empty() || s.len() > 64 || !s.chars().all(|c| c.is_ascii_alphanumeric()) {93         return None;94     }95     Some(s)96 }97 98 /// Record sha when it looks like a git sha (else fall through).99 fn good_sha(s: &str) -> bool {100     !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric())101 }102 103 fn record_sha(fg: &Forge, ver: &str) -> Option<String> {104     let p: f0rge_core::Parsed<ReleaseRec> = fg.load_entity(&fg.release_file(ver)).ok()?;105     let s = p.value.sha?;106     if good_sha(&s) { Some(s) } else { None }107 }108 109 /// Version becomes `<ver>.md`; slashes/escapes -> Runtime (exit 1).110 fn check_ver(raw: &str) -> Result<String, CliError> {111     let v = clean(raw);112     if v.is_empty() || v == "." || v == ".." {113         return Err(usage("f0rge release <create|list|view> [ver]"));114     }115     for c in Path::new(&v).components() {116         match c {117             Component::Normal(_) => {}118             _ => return Err(CliError::Runtime(format!("{v}: escapes releases"))),119         }120     }121     if v.contains('\\') {122         return Err(CliError::Runtime(format!("{v}: escapes releases")));123     }124     Ok(v)125 }126 127 /// Create enforces the v prefix (#47): bare `0.2.0` -> usage (exit 2).128 fn check_ver_create(raw: &str) -> Result<String, CliError> {129     let v = check_ver(raw)?;130     if v.starts_with('v') {131         return Ok(v);132     }133     Err(usage(&format!("versions use the v prefix: v{v}")))134 }135 136 fn norm_body(b: &str) -> String {137     if b.is_empty() {138         return String::new();139     }140     if b.ends_with('\n') {141         b.to_string()142     } else {143         format!("{b}\n")144     }145 }146 147 /// Notes from `-` (stdin) or a local file; capped at SEC-01 limit.148 fn read_notes(src: &str) -> Result<String, CliError> {149     let bytes: Vec<u8> = if src == "-" {150         use std::io::Read;151         let mut buf = Vec::new();152         std::io::stdin()153             .lock()154             .take(f0rge_core::MAX_FILE_BYTES as u64 + 1)155             .read_to_end(&mut buf)156             .map_err(|_| CliError::Runtime("stdin: io error".to_string()))?;157         buf158     } else {159         std::fs::read(Path::new(src)).map_err(|_| {160             CliError::Runtime(format!("{}: not found", base_of(src)))161         })?162     };163     if bytes.len() > f0rge_core::MAX_FILE_BYTES {164         return Err(CliError::Runtime(format!("{}: file over 256 KiB cap", base_of(src))));165     }166     String::from_utf8(bytes)167         .map_err(|_| CliError::Runtime(format!("{}: not utf-8", base_of(src))))168         .map(|s| norm_body(&s))169 }170 171 fn base_of(p: &str) -> String {172     Path::new(p)173         .file_name()174         .map(|s| s.to_string_lossy().into_owned())175         .unwrap_or_else(|| clean(p))176 }177 178 /// Unknown-field warnings + schema note, one line each on stderr.179 fn warn(file: &str, w: &[String], note: &Option<String>) {180     for x in w {181         eprintln!("warning: {file}: {x}");182     }183     output::warn_schema(note);184 }185 186 #[derive(Debug, Clone, Serialize)]187 struct ReleaseJson {188     version: String,189     title: Option<String>,190     created: String,191     body: String,192     status: String,193 }194 195 #[derive(Debug, Clone, Serialize)]196 struct ListJson {197     version: String,198     title: Option<String>,199     created: String,200     status: String,201 }202 203 /// Derived build status: artifacts under out/<ver>/ -> built, else none.204 fn build_status(fg: &Forge, ver: &str) -> String {205     let dir = fg.root().join("releases").join("out").join(ver);206     match std::fs::read_dir(&dir) {207         Ok(mut rd) => {208             if rd.next().is_some() {209                 "built".to_string()210             } else {211                 "none".to_string()212             }213         }214         Err(_) => "none".to_string(),215     }216 }217 218 // ---- build side effect ----219 220 static BUILD_CTR: AtomicU64 = AtomicU64::new(0);221 222 /// Executable scripts in dir, filename order. Symlinks skipped (SEC-02).223 fn list_scripts(dir: &Path) -> Vec<PathBuf> {224     let rd = match std::fs::read_dir(dir) {225         Ok(r) => r,226         Err(_) => return Vec::new(),227     };228     let mut out = Vec::new();229     for e in rd.flatten() {230         let ft = match e.file_type() {231             Ok(t) => t,232             Err(_) => continue,233         };234         if ft.is_symlink() || !ft.is_file() {235             continue;236         }237         out.push(e.path());238     }239     out.sort();240     out241 }242 243 #[cfg(unix)]244 fn is_executable(p: &Path) -> bool {245     use std::os::unix::fs::PermissionsExt;246     std::fs::metadata(p).map(|m| m.permissions().mode() & 0o111 != 0).unwrap_or(false)247 }248 249 #[cfg(not(unix))]250 fn is_executable(_p: &Path) -> bool {251     true252 }253 254 /// Run scripts with cwd=work, env VERSION/OUT (argv only, SEC-04).255 /// First nonzero exit -> one-line error; notes already saved.256 fn run_each(scripts: &[PathBuf], work: &Path, ver: &str, out: &Path) -> Result<(), CliError> {257     for s in scripts {258         let output = if is_executable(s) {259             Command::new(s).current_dir(work).env("VERSION", ver).env("OUT", out).output()260         } else {261             Command::new("sh").arg(s).current_dir(work).env("VERSION", ver).env("OUT", out).output()262         };263         match output {264             Ok(o) if o.status.success() => {}265             _ => return Err(CliError::Runtime(format!("release {ver}: build failed"))),266         }267     }268     Ok(())269 }270 271 fn git(repo: &Path, argv: &[&str]) -> bool {272     Command::new("git")273         .arg("-C")274         .arg(repo)275         .args(argv)276         .stdout(std::process::Stdio::null())277         .stderr(std::process::Stdio::null())278         .status()279         .map(|s| s.success())280         .unwrap_or(false)281 }282 283 /// Checkout a tag or sha into a temp worktree; None when no git/no ref.284 fn try_worktree(repo: &Path, gitref: &str, label: &str) -> Option<PathBuf> {285     if !git(repo, &["rev-parse", "--verify", gitref]) {286         return None;287     }288     let n = BUILD_CTR.fetch_add(1, Ordering::Relaxed);289     let tmp =290         std::env::temp_dir().join(format!("f0rge-rel-{}-{}-{n}", std::process::id(), label));291     let _ = std::fs::remove_dir_all(&tmp);292     let ok = Command::new("git")293         .arg("-C")294         .arg(repo)295         .args(["worktree", "add", "--detach"])296         .arg(&tmp)297         .arg(gitref)298         .stdout(std::process::Stdio::null())299         .stderr(std::process::Stdio::null())300         .status()301         .map(|s| s.success())302         .unwrap_or(false);303     if ok {304         Some(tmp)305     } else {306         let _ = std::fs::remove_dir_all(&tmp);307         None308     }309 }310 311 fn cleanup_worktree(repo: &Path, wt: &Path) {312     git(repo, &["worktree", "remove", "--force", &wt.to_string_lossy()]);313     let _ = std::fs::remove_dir_all(wt);314 }315 316 /// sha256 of one file via the system tool (argv-only, SEC-04).317 /// None when the tool is missing or the file unreadable (block skips it).318 fn sha256_of(p: &Path) -> Option<String> {319     let o = Command::new("sha256sum").arg(p).output().ok()?;320     if !o.status.success() {321         return None;322     }323     String::from_utf8(o.stdout).ok()?.split_whitespace().next().map(|s| s.to_string())324 }325 326 /// M8: append a `## sha256` block for artifacts in `out/<ver>/` onto327 /// `releases/<ver>.md` (body only; frontmatter untouched). Empty-safe:328 /// missing/empty out dir, or no hashable files, is a no-op.329 fn append_hashes(fg: &Forge, ver: &str) -> Result<(), CliError> {330     let dir = fg.root().join("releases").join("out").join(ver);331     let rd = match std::fs::read_dir(&dir) {332         Ok(r) => r,333         Err(_) => return Ok(()),334     };335     let mut rows: Vec<(String, String)> = Vec::new();336     for e in rd.flatten() {337         let ft = match e.file_type() {338             Ok(t) => t,339             Err(_) => continue,340         };341         if ft.is_symlink() || !ft.is_file() {342             continue;343         }344         let name = e.file_name().to_string_lossy().into_owned();345         if let Some(h) = sha256_of(&e.path()) {346             rows.push((name, h));347         }348     }349     if rows.is_empty() {350         return Ok(());351     }352     rows.sort();353     let rel = fg.release_file(ver);354     let p: f0rge_core::Parsed<ReleaseRec> = fg.load_entity(&rel).map_err(CliError::from)?;355     warn(&format!("{ver}.md"), &p.warnings, &p.schema_note);356     let mut body = p.body;357     if !body.is_empty() && !body.ends_with('\n') {358         body.push('\n');359     }360     body.push_str("\n## sha256\n");361     for (name, h) in &rows {362         body.push_str(&format!("{h}  {name}\n"));363     }364     fg.save_entity(&rel, &p.value, &body).map_err(CliError::from)?;365     Ok(())366 }367 368 /// Notes are truth: returns Ok unless a build script fails (exit 1).369 fn run_build(fg: &Forge, ver: &str) -> Result<(), CliError> {370     let repo = match fg.root().parent() {371         Some(p) => p.to_path_buf(),372         None => return Ok(()),373     };374     let out = repo.join(".forge").join("releases").join("out").join(ver);375     if let Some(wt) = try_worktree(&repo, ver, ver) {376         let scripts = list_scripts(&wt.join(".forge").join("releases").join("build"));377         let r = if scripts.is_empty() {378             Ok(())379         } else {380             std::fs::create_dir_all(&out)381                 .map_err(|_| CliError::Runtime(format!("release {ver}: build failed")))?;382             run_each(&scripts, &wt, ver, &out)383         };384         cleanup_worktree(&repo, &wt);385         return r;386     }387     // No git/tag here: run current-tree scripts against the checkout.388     let scripts = list_scripts(&repo.join(".forge").join("releases").join("build"));389     if scripts.is_empty() {390         return Ok(());391     }392     std::fs::create_dir_all(&out)393         .map_err(|_| CliError::Runtime(format!("release {ver}: build failed")))?;394     run_each(&scripts, &repo, ver, &out)395 }396 397 // ---- verbs ----398 399 fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {400     if fs.positional.len() != 1 {401         return Err(usage("f0rge release create <ver> --notes <file|->"));402     }403     let ver = check_ver_create(&fs.positional[0].clone())?;404     let src = fs.get("notes").ok_or_else(|| usage("f0rge release create <ver> --notes <file|->"))?;405     let notes = read_notes(src)?;406     let repo = fg.root().parent().map(|p| p.to_path_buf());407     let sha = repo.as_ref().and_then(|r| head_sha(r));408     let rel = ReleaseRec {409         version: ver.clone(),410         title: fs.get("title").map(|s| clean(s)).filter(|s| !s.is_empty()),411         created: f0rge_core::now_rfc3339(),412         sha,413     };414     fg.save_entity(&fg.release_file(&ver), &rel, &notes)?;415     run_build(fg, &ver)?; // side effect; notes already saved416     append_hashes(fg, &ver)?; // no-op when out/ is missing or empty417     let final_body: String = fg418         .load_entity::<ReleaseRec>(&fg.release_file(&ver))419         .map(|p| p.body)420         .unwrap_or(notes);421     output::emit_json(422         &ReleaseJson {423             version: ver.clone(),424             title: rel.title,425             created: rel.created,426             body: final_body,427             status: build_status(fg, &ver),428         },429         fs.get("jq"),430     );431     Ok(())432 }433 434 struct Row {435     version: String,436     title: Option<String>,437     created: String,438 }439 440 fn load_all(fg: &Forge) -> Vec<Row> {441     let dir = fg.root().join("releases");442     let rd = match std::fs::read_dir(&dir) {443         Ok(r) => r,444         Err(_) => return Vec::new(),445     };446     let mut out = Vec::new();447     for e in rd.flatten() {448         let ft = match e.file_type() {449             Ok(t) => t,450             Err(_) => continue,451         };452         if ft.is_symlink() || !ft.is_file() {453             continue;454         }455         let name = e.file_name().to_string_lossy().into_owned();456         let Some(ver) = name.strip_suffix(".md") else { continue };457         if check_ver(ver).is_err() {458             continue;459         }460         let rel = Path::new("releases").join(&name);461         match fg.load_entity::<ReleaseRec>(&rel) {462             Ok(p) => {463                 warn(&name, &p.warnings, &p.schema_note);464                 out.push(Row { version: ver.to_string(), title: p.value.title, created: p.value.created })465             }466             Err(_) => continue,467         }468     }469     out.sort_by(|a, b| (a.created.clone(), a.version.clone()).cmp(&(b.created.clone(), b.version.clone())));470     out471 }472 473 fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {474     if !fs.positional.is_empty() {475         return Err(usage("f0rge release list"));476     }477     let rows = load_all(fg);478     if fs.is("json") {479         if rows.is_empty() {480             output::emit_empty_array(fs.get("jq"));481             return Ok(());482         }483         let js: Vec<ListJson> = rows484             .into_iter()485             .map(|r| {486                 let st = build_status(fg, &r.version);487                 ListJson { version: r.version, title: r.title, created: r.created, status: st }488             })489             .collect();490         output::emit_json(&js, fs.get("jq"));491         return Ok(());492     }493     if rows.is_empty() {494         return Ok(());495     }496     let grid: Vec<Vec<String>> = rows497         .into_iter()498         .map(|r| {499             let st = build_status(fg, &r.version);500             vec![r.version, r.created, st]501         })502         .collect();503     output::out(&format!("{}\n", output::align(&grid)));504     Ok(())505 }506 507 fn view(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {508     let ver = check_ver(raw)?;509     let p: f0rge_core::Parsed<ReleaseRec> =510         fg.load_entity(&fg.release_file(&ver)).map_err(|e| match e {511             ForgeError::NotFound { .. } => CliError::Runtime(format!("{ver}: not found")),512             _ => CliError::from(e),513         })?;514     warn(&format!("{ver}.md"), &p.warnings, &p.schema_note);515     if fs.is("json") {516         output::emit_json(517             &ReleaseJson {518                 version: ver.clone(),519                 title: p.value.title,520                 created: p.value.created,521                 body: p.body.clone(),522                 status: build_status(fg, &ver),523             },524             fs.get("jq"),525         );526         return Ok(());527     }528     let mut body = p.body.clone();529     if !body.is_empty() && !body.ends_with('\n') {530         body.push('\n');531     }532     output::out(&body);533     Ok(())534 }535 536 // ---- install (local machine op; never forge state, never CI) ----537 538 #[derive(Debug, Clone, Serialize)]539 struct InstallJson {540     path: String,541     sha256: String,542     #[serde(rename = "ref")]543     ref_: String,544     method: String,545 }546 547 /// Install destination: $CARGO_HOME/bin/<name>, else ~/.cargo/bin/<name>.548 fn cargo_dest_for(name: &str) -> Result<PathBuf, CliError> {549     if let Ok(h) = std::env::var("CARGO_HOME") {550         if !h.is_empty() {551             return Ok(PathBuf::from(h).join("bin").join(name));552         }553     }554     match std::env::var("HOME") {555         Ok(h) if !h.is_empty() => Ok(PathBuf::from(h).join(".cargo").join("bin").join(name)),556         _ => Err(CliError::Runtime("no home".to_string())),557     }558 }559 560 fn tag_exists(repo: &Path, tag: &str) -> bool {561     git(repo, &["rev-parse", "--verify", tag])562 }563 564 fn emit_installed(fs: &args::FlagSet, dest: &Path, sha: &str, rf: &str, method: &str) {565     if fs.is("json") {566         output::emit_json(567             &InstallJson {568                 path: dest.display().to_string(),569                 sha256: sha.to_string(),570                 ref_: rf.to_string(),571                 method: method.to_string(),572             },573             fs.get("jq"),574         );575         return;576     }577     output::out(&format!("installed {} {} {}\n", dest.display(), sha, rf));578 }579 580 fn cargo_install_from(src: &Path) -> Result<(), CliError> {581     let st = Command::new("cargo")582         .arg("install")583         .arg("--locked")584         .arg("--path")585         .arg(src)586         .stdout(std::process::Stdio::null())587         .stderr(std::process::Stdio::null())588         .status();589     match st {590         Ok(s) if s.success() => Ok(()),591         _ => Err(CliError::Runtime("install failed".to_string())),592     }593 }594 595 /// Installer convention (#49): CWD repo-root `install.sh` run argv-only596 /// as `./install.sh <ver>` owns placement for its ecosystem.597 /// Detection + exec are CWD-based; record-sha/tag pin only the598 /// version (and cargo source tree / staging), never the installer.599 /// Run the project installer argv-only, no shell (SEC-04, local posture).600 fn run_install_sh(dir: &Path, ver: &str, env_sha: &str) -> Result<(), CliError> {601     let st = Command::new(dir.join("install.sh"))602         .arg(ver)603         .current_dir(dir)604         .env("F0RGE_RELEASE_SHA", env_sha)605         .status();606     match st {607         Ok(s) if s.success() => Ok(()),608         _ => Err(CliError::Runtime("install failed".to_string())),609     }610 }611 612 fn emit_script(fs: &args::FlagSet, ver: &str) {613     if fs.is("json") {614         output::emit_json(615             &InstallJson {616                 path: "./install.sh".to_string(),617                 sha256: String::new(),618                 ref_: ver.to_string(),619                 method: "install.sh".to_string(),620             },621             fs.get("jq"),622         );623         return;624     }625     output::out(&format!("ran ./install.sh {ver}\n"));626 }627 628 /// Bin name from a Cargo manifest: first [[bin]].name, else629 /// [package].name. Textual scan (no new deps); None when absent.630 fn manifest_bin_name(dir: &Path) -> Option<String> {631     let text = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;632     if text.len() > 65536 {633         return None;634     }635     let mut pkg: Option<String> = None;636     let mut section = String::new();637     for line in text.lines() {638         let t = line.trim();639         if t.starts_with('[') && t.ends_with(']') {640             section = t.to_string();641             continue;642         }643         if section != "[package]" && section != "[[bin]]" {644             continue;645         }646         let rest = match t.split_once('=') {647             Some((k, v)) if k.trim() == "name" => v.trim(),648             _ => continue,649         };650         if !rest.starts_with('"') {651             continue;652         }653         let end = rest[1..].find('"')?;654         let name = rest[1..1 + end].to_string();655         if name.is_empty() {656             continue;657         }658         if section == "[[bin]]" {659             return Some(name);660         }661         if pkg.is_none() {662             pkg = Some(name);663         }664     }665     pkg666 }667 668 /// Path-safe bin name for the cargo fallback; f0rge when the669 /// manifest is missing or the name is not a safe filename (#48).670 fn safe_bin(dir: &Path) -> String {671     match manifest_bin_name(dir) {672         Some(n)673             if !n.is_empty()674                 && n.len() <= 64675                 && n.chars().all(|c| {676                     c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'677                 }) =>678         {679             n680         }681         _ => "f0rge".to_string(),682     }683 }684 685 /// Pinned install (#49): resolve record sha > git tag > current686 /// checkout for pin + stage releases/build/<ver>/, then hand off687 /// CWD-based: CWD ./install.sh argv-only as ./install.sh <ver>688 /// (F0RGE_RELEASE_SHA env carries the pin), else cargo install689 /// from the resolved tree for CWD cargo projects, else one-line690 /// no-installer error. Argv-only.691 fn install_ver(692     fs: &args::FlagSet,693     fg: &Forge,694     repo: &Path,695     ver: &str,696     dry: bool,697 ) -> Result<(), CliError> {698     let sha = record_sha(fg, ver);699     let tag_ok = tag_exists(repo, ver);700     let sha_ok = sha.as_ref().is_some_and(|s| tag_exists(repo, s));701     let (at, label) = if sha_ok {702         let s = sha.clone().unwrap_or_default();703         (Some(s.clone()), format!("{ver} @ {s}"))704     } else if tag_ok {705         (Some(ver.to_string()), ver.to_string())706     } else {707         (None, format!("{ver} (current checkout)"))708     };709     let has_sh = repo.join("install.sh").is_file();710     let has_cargo = repo.join("Cargo.toml").is_file();711     if has_sh {712         if dry {713             output::out(&format!("would run: ./install.sh {ver} (checkout of {label})\n"));714             return Ok(());715         }716         let build_dir = fg.root().join("releases").join("build").join(ver);717         let _ = std::fs::create_dir_all(&build_dir);718         let env_sha = sha.clone().or(at.clone()).unwrap_or_default();719         run_install_sh(repo, ver, &env_sha)?;720         emit_script(fs, ver);721         Ok(())722     } else if has_cargo {723         let dest = cargo_dest_for(&safe_bin(repo))?;724         if dry {725             output::out(&format!(726                 "would run: cargo install --locked --path <checkout of {label}> to {}\n",727                 dest.display()728             ));729             return Ok(());730         }731         let build_dir = fg.root().join("releases").join("build").join(ver);732         let _ = std::fs::create_dir_all(&build_dir);733         match at {734             Some(r) => match try_worktree(repo, &r, ver) {735                 Some(wt) => {736                     let res = cargo_install_from(&wt);737                     let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest);738                     cleanup_worktree(repo, &wt);739                     res?;740                     let sha = sha256_of(&dest).unwrap_or_default();741                     emit_installed(fs, &dest, &sha, ver, "compile");742                     Ok(())743                 }744                 None => {745                     cargo_install_from(repo)?;746                     let sha = sha256_of(&dest).unwrap_or_default();747                     emit_installed(fs, &dest, &sha, ver, "compile");748                     Ok(())749                 }750             },751             None => {752                 cargo_install_from(repo)?;753                 let sha = sha256_of(&dest).unwrap_or_default();754                 emit_installed(fs, &dest, &sha, ver, "compile");755                 Ok(())756             }757         }758     } else {759         Err(CliError::Runtime(760             "no installer for this project (install.sh or Cargo.toml)".to_string(),761         ))762     }763 }764 765 /// Main install: current checkout hands to install.sh or cargo.766 fn install_main(767     fs: &args::FlagSet,768     fg: &Forge,769     repo: &Path,770     dry: bool,771 ) -> Result<(), CliError> {772     let _ = fg;773     if repo.join("install.sh").is_file() {774         if dry {775             output::out("would run: ./install.sh main\n");776             return Ok(());777         }778         run_install_sh(repo, "main", "main")?;779         emit_script(fs, "main");780         return Ok(());781     }782     if !repo.join("Cargo.toml").is_file() {783         return Err(CliError::Runtime(784             "no installer for this project (install.sh or Cargo.toml)".to_string(),785         ));786     }787     let dest = cargo_dest_for(&safe_bin(repo))?;788     if dry {789         output::out(&format!(790             "would run: cargo install --locked --path {} (main) to {}\n",791             repo.display(),792             dest.display()793         ));794         return Ok(());795     }796     cargo_install_from(repo)?;797     let sha = sha256_of(&dest).unwrap_or_default();798     emit_installed(fs, &dest, &sha, "main", "compile");799     Ok(())800 }801 802 /// `install` = latest record; `install <ver>` = pinned record (record803 /// sha > git tag > current checkout); `install main` = cwd.804 fn install(fs: &args::FlagSet, fg: &Forge, raw: Option<String>) -> Result<(), CliError> {805     let repo =806         fg.root().parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));807     let dry = fs.is("dry-run");808     match raw {809         Some(r) if clean(&r) == "main" => install_main(fs, fg, &repo, dry),810         Some(r) => {811             let ver = check_ver(&r)?;812             if fg.load_entity::<ReleaseRec>(&fg.release_file(&ver)).is_err() {813                 return Err(CliError::Usage(format!("no such release: {ver}")));814             }815             install_ver(fs, fg, &repo, &ver, dry)816         }817         None => {818             let rows = load_all(fg);819             let ver = rows820                 .last()821                 .map(|r| r.version.clone())822                 .ok_or_else(|| CliError::Runtime("no releases".to_string()))?;823             install_ver(fs, fg, &repo, &ver, dry)824         }825     }826 }827 828 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {829     if args_in.is_empty() {830         return Err(usage("f0rge release <create|list|view|install> [ver|main] (try: f0rge release -h)"));831     }832     let verb = clean(&args_in[0]);833     if verb.is_empty() || verb.starts_with('-') || !["create", "list", "view", "install"].contains(&verb.as_str()) {834         let shown = if verb.is_empty() { "<none>".to_string() } else { verb };835         return Err(CliError::Usage(format!("unknown verb: {shown} (try: f0rge release -h)")));836     }837     let fs = args::parse_flags(&args_in[1..], &spec()).map_err(|e| with_try(e, &verb))?;838     if fs.is("help") {839         if let Some(h) = verb_help(&verb) {840             output::out(h);841             return Ok(());842         }843     }844     let r: Result<(), CliError> = match verb.as_str() {845         "create" => create(&fs, fg),846         "list" => list(&fs, fg),847         "install" => {848             if fs.positional.len() > 1 {849                 return Err(with_try(usage("f0rge release install [ver|main] [--dry-run]"), &verb));850             }851             install(&fs, fg, fs.positional.first().cloned())852         }853         "view" => {854             if fs.positional.len() != 1 {855                 return Err(with_try(usage("f0rge release view <ver>"), &verb));856             }857             view(&fs, fg, &fs.positional[0].clone())858         }859         _ => Err(usage("f0rge release <create|list|view> [ver] (try: f0rge release -h)")),860     };861     r.map_err(|e| with_try(e, &verb))862 }863 864 #[cfg(test)]865 mod verb_help_tests {866     use super::*;867     fn budget(s: &str) {868         assert!(s.lines().count() <= 8, "lines:\n{s}");869         assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());870     }871     #[test]872     fn per_verb_budgets_and_needles() {873         for (v, needle) in [874             ("create", "--notes"),875             ("list", "release list"),876             ("view", "<ver>"),877             ("install", "--dry-run"),878         ] {879             let h = verb_help(v).expect(v);880             budget(h);881             assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");882         }883         assert!(verb_help("frobnicate").is_none());884     }885 }