|
| 1 | +//! `gloam regen` — regenerate existing output trees in place. |
| 2 | +//! |
| 3 | +//! Every generated tree records the exact command line that produced it in |
| 4 | +//! `<tree>/.gloam/manifest.json`, so a tree is self-describing: regeneration |
| 5 | +//! replays that command with the current gloam. The recorded `--out-path` |
| 6 | +//! is a historical record of the original invocation — it was relative to |
| 7 | +//! whatever directory the user happened to generate from, which is |
| 8 | +//! unknowable later — so replay derives the effective output path from the |
| 9 | +//! manifest's own location instead, and re-records the original command line |
| 10 | +//! verbatim. Trees therefore regenerate correctly from any working |
| 11 | +//! directory, and regeneration never rewrites the recorded command. |
| 12 | +//! |
| 13 | +//! By default each replay is pinned to the tree's recorded provenance |
| 14 | +//! (`--lock` semantics): with an unchanged gloam the output is |
| 15 | +//! byte-identical, and after a gloam change `git diff` shows exactly what |
| 16 | +//! the change did. `--fresh` re-resolves sources instead (bundled, or |
| 17 | +//! upstream if the recorded command used `--fetch`), advancing the tree — |
| 18 | +//! the normal update workflow. |
| 19 | +
|
| 20 | +use std::path::{Path, PathBuf}; |
| 21 | + |
| 22 | +use anyhow::{Context, Result, bail}; |
| 23 | +use clap::Parser; |
| 24 | + |
| 25 | +use crate::cli::{Cli, Generator}; |
| 26 | +use crate::diag::Diag; |
| 27 | +use crate::provenance::manifest::{Manifest, SCHEMA_VERSION}; |
| 28 | + |
| 29 | +pub fn run(cli: Cli) -> Result<()> { |
| 30 | + let Generator::Regen(ref args) = cli.generator else { |
| 31 | + unreachable!("regen::run dispatched for a non-regen command"); |
| 32 | + }; |
| 33 | + let diag = Diag::new(cli.quiet); |
| 34 | + |
| 35 | + // (manifest path, explicitly named). Problems in an explicitly named |
| 36 | + // manifest are hard errors; discovered candidates that turn out not to |
| 37 | + // be gloam manifests are skipped silently, because any project may |
| 38 | + // contain unrelated files named manifest.json. |
| 39 | + let mut manifests: Vec<(PathBuf, bool)> = Vec::new(); |
| 40 | + for path in &args.paths { |
| 41 | + if path.is_file() { |
| 42 | + manifests.push((path.clone(), true)); |
| 43 | + } else if path.is_dir() { |
| 44 | + let mut found = Vec::new(); |
| 45 | + find_manifests(path, &mut found); |
| 46 | + found.sort(); |
| 47 | + manifests.extend(found.into_iter().map(|p| (p, false))); |
| 48 | + } else { |
| 49 | + bail!("regen path {} is not a file or directory", path.display()); |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + let mut ran = 0usize; |
| 54 | + for (path, explicit) in &manifests { |
| 55 | + if replay(path, *explicit, args.fresh, cli.quiet, diag)? { |
| 56 | + ran += 1; |
| 57 | + } |
| 58 | + } |
| 59 | + if ran == 0 { |
| 60 | + bail!("no gloam manifests found under the given path(s)"); |
| 61 | + } |
| 62 | + diag.info(format!("regenerated {ran} tree(s)")); |
| 63 | + // The suggested filter hides pure gloam-stamp churn: the C and Rust |
| 64 | + // preamble stamp lines, the manifest's gloam meta block (4-space |
| 65 | + // indent), and the output BOM's file hashes (6-space "blob"), which |
| 66 | + // track stamp changes byte-for-byte. Ignoring 6-space "blob" lines is |
| 67 | + // safe even though provenance pins share the indent: a pin's blob never |
| 68 | + // changes without its sibling "commit" line (6-space, not filtered) |
| 69 | + // changing too, so a real source change always stays visible. |
| 70 | + diag.info( |
| 71 | + "review with: git diff -I'^ \\* @generated by gloam ' \ |
| 72 | + -I'^// @generated by gloam ' -I'^ \"(version|describe|commit)\": ' \ |
| 73 | + -I'^ \"blob\": '", |
| 74 | + ); |
| 75 | + Ok(()) |
| 76 | +} |
| 77 | + |
| 78 | +/// Recursively collect `manifest.json` files under `dir`. `.git` is never |
| 79 | +/// descended into; neither is `target`, because Cargo build directories can |
| 80 | +/// hold packaged copies of real trees (`target/package/...`) that must not |
| 81 | +/// be regenerated in place. A tree that genuinely lives under a directory |
| 82 | +/// named `target` can still be regenerated by naming it explicitly. |
| 83 | +fn find_manifests(dir: &Path, out: &mut Vec<PathBuf>) { |
| 84 | + let Ok(entries) = std::fs::read_dir(dir) else { |
| 85 | + return; |
| 86 | + }; |
| 87 | + for entry in entries.flatten() { |
| 88 | + let path = entry.path(); |
| 89 | + if path.is_dir() { |
| 90 | + if path |
| 91 | + .file_name() |
| 92 | + .is_some_and(|n| n == ".git" || n == "target") |
| 93 | + { |
| 94 | + continue; |
| 95 | + } |
| 96 | + find_manifests(&path, out); |
| 97 | + } else if path.file_name().is_some_and(|n| n == "manifest.json") { |
| 98 | + out.push(path); |
| 99 | + } |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +/// Replay the command recorded in one manifest. Returns `Ok(true)` if a |
| 104 | +/// tree was regenerated and `Ok(false)` if the candidate was skipped (only |
| 105 | +/// possible for discovered, non-explicit candidates). |
| 106 | +fn replay( |
| 107 | + manifest_path: &Path, |
| 108 | + explicit: bool, |
| 109 | + fresh: bool, |
| 110 | + quiet: bool, |
| 111 | + diag: Diag, |
| 112 | +) -> Result<bool> { |
| 113 | + let text = match std::fs::read_to_string(manifest_path) { |
| 114 | + Ok(text) => text, |
| 115 | + Err(e) if explicit => { |
| 116 | + return Err(e).with_context(|| format!("reading {}", manifest_path.display())); |
| 117 | + } |
| 118 | + Err(_) => return Ok(false), |
| 119 | + }; |
| 120 | + let manifest = match Manifest::from_json(&text) { |
| 121 | + Ok(m) => m, |
| 122 | + Err(e) if explicit => { |
| 123 | + return Err(e) |
| 124 | + .with_context(|| format!("{} is not a gloam manifest", manifest_path.display())); |
| 125 | + } |
| 126 | + Err(_) => return Ok(false), |
| 127 | + }; |
| 128 | + if manifest.schema_version != SCHEMA_VERSION { |
| 129 | + if explicit { |
| 130 | + bail!( |
| 131 | + "{} has schema_version {}, but this gloam understands {}", |
| 132 | + manifest_path.display(), |
| 133 | + manifest.schema_version, |
| 134 | + SCHEMA_VERSION |
| 135 | + ); |
| 136 | + } |
| 137 | + diag.warn(format!( |
| 138 | + "{}: skipping (schema_version {} != {})", |
| 139 | + manifest_path.display(), |
| 140 | + manifest.schema_version, |
| 141 | + SCHEMA_VERSION |
| 142 | + )); |
| 143 | + return Ok(false); |
| 144 | + } |
| 145 | + |
| 146 | + let recorded = manifest.gloam.command_line; |
| 147 | + let tokens: Vec<&str> = recorded.split_whitespace().collect(); |
| 148 | + if tokens.first().copied() != Some("gloam") { |
| 149 | + if explicit { |
| 150 | + bail!( |
| 151 | + "{} does not record a gloam command line (got '{recorded}')", |
| 152 | + manifest_path.display() |
| 153 | + ); |
| 154 | + } |
| 155 | + return Ok(false); |
| 156 | + } |
| 157 | + let mut replay_cli = Cli::try_parse_from(&tokens).with_context(|| { |
| 158 | + format!( |
| 159 | + "re-parsing the command recorded in {}: '{recorded}'", |
| 160 | + manifest_path.display() |
| 161 | + ) |
| 162 | + })?; |
| 163 | + |
| 164 | + // Verbosity follows the regen invocation, not the recorded command. |
| 165 | + replay_cli.quiet = quiet; |
| 166 | + if !fresh { |
| 167 | + replay_cli.lock = Some(manifest_path.to_path_buf()); |
| 168 | + } |
| 169 | + |
| 170 | + let target = match &mut replay_cli.generator { |
| 171 | + Generator::C(_) | Generator::Rust(_) => { |
| 172 | + // A generation manifest lives at <tree>/.gloam/manifest.json, so |
| 173 | + // its own location is the authoritative output path. The |
| 174 | + // recorded --out-path (relative to the original invocation's |
| 175 | + // cwd) is preserved in the re-recorded command line but ignored |
| 176 | + // for placement. |
| 177 | + let tree = manifest_path |
| 178 | + .parent() |
| 179 | + .filter(|p| p.file_name().is_some_and(|n| n == ".gloam")) |
| 180 | + .and_then(Path::parent); |
| 181 | + let Some(tree) = tree else { |
| 182 | + if explicit { |
| 183 | + bail!( |
| 184 | + "{} records a generation command but does not live at \ |
| 185 | + <tree>/.gloam/manifest.json", |
| 186 | + manifest_path.display() |
| 187 | + ); |
| 188 | + } |
| 189 | + return Ok(false); |
| 190 | + }; |
| 191 | + // parent() of a bare relative ".gloam/manifest.json" is "" — |
| 192 | + // that tree root is the current directory. |
| 193 | + let tree = if tree.as_os_str().is_empty() { |
| 194 | + Path::new(".") |
| 195 | + } else { |
| 196 | + tree |
| 197 | + }; |
| 198 | + replay_cli.out_path = tree.display().to_string(); |
| 199 | + replay_cli.out_path.clone() |
| 200 | + } |
| 201 | + Generator::Lock(lock_args) => { |
| 202 | + // A lock snapshot is the manifest file itself; rewrite in place. |
| 203 | + lock_args.out = manifest_path.display().to_string(); |
| 204 | + lock_args.out.clone() |
| 205 | + } |
| 206 | + Generator::Regen(_) => { |
| 207 | + bail!( |
| 208 | + "{} records a regen command — refusing to recurse", |
| 209 | + manifest_path.display() |
| 210 | + ); |
| 211 | + } |
| 212 | + }; |
| 213 | + |
| 214 | + diag.info(format!("· {target} $ {recorded}")); |
| 215 | + crate::execute(replay_cli, &recorded).with_context(|| format!("regenerating {target}"))?; |
| 216 | + Ok(true) |
| 217 | +} |
0 commit comments