Skip to main content

cratefield_cli/
workflow.rs

1//! The agent-safe workflow commands (harness #140): `fz plan`, `fz
2//! deploy --plan <digest>`, `fz add`, `fz init` and `fz verify`. They
3//! work on the venture manifest — the desired composition — and two
4//! on-disk records: the migration lockfile (`.harness-lock.json`) and
5//! the deploy record (`.harness-deploy.json`) that `fz deploy` writes.
6//! They never run wrangler, never touch a database and never prompt:
7//! turning the recorded composition into a running Worker stays with
8//! `worker-build` and `wrangler deploy`, and applying migrations stays
9//! with `fz migrations collect` / `wrangler d1 migrations apply` — the
10//! same needs-human boundary `fz build` draws.
11//!
12//! Every command speaks the two disciplines `fz doctor --json`
13//! established (harness #140). With `--json` it prints exactly one JSON
14//! object on stdout — `schema` 1, `ok`, the `failures` each carrying a
15//! stable code from [`crate::codes`] — and nothing else. Without it the
16//! output is prose for the operator. `--non-interactive` is accepted on
17//! every command: the workflow never prompts by construction, so the
18//! flag's one effect is suppressing the human `next:` steps from prose
19//! output; anything that would need a human is a coded refusal, never a
20//! blocked process.
21//!
22//! [`PlanContent`] is the whole normalised plan; its sha256 (`fz plan
23//! --json`'s `digest`) is the approval token. `fz deploy` recomputes the
24//! plan from the current inputs and refuses any other digest — a stale
25//! approval cannot deploy — and records the plan it applied. `fz
26//! verify` diffs that record against the manifest and reports the drift
27//! as coded failures in the doctor's JSON shape.
28
29use crate::codes::{CODES, DoctorCodeDef};
30use crate::doctor::DoctorFailure;
31use crate::doctor::DoctorReport;
32use crate::lock::{Lock, read_lock, sha256_hex};
33use cratefield_core::VentureEnv;
34use cratefield_manifest::{Catalog, ModuleSet, VentureManifest, builtin};
35use serde::{Deserialize, Serialize};
36use std::collections::BTreeMap;
37use std::path::{Path, PathBuf};
38use std::process::ExitCode;
39
40/// The `schema` field of every workflow JSON object — the same wire
41/// contract `fz doctor --json` carries ([`crate::doctor::REPORT_SCHEMA`]).
42pub const SCHEMA: u32 = 1;
43
44/// The deploy record `fz deploy` writes next to the manifest: the plan
45/// it applied, bound to the digest that approved it.
46pub const RECORD_FILE: &str = ".harness-deploy.json";
47
48/// A recorded deployment. `plan` is the full [`PlanContent`] that was
49/// applied, so `fz verify` can name precisely which part of the
50/// manifest drifted, and a stale `fz deploy` can name what moved.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct DeployRecord {
53    /// [`SCHEMA`]; consumers branch on it.
54    pub schema: u32,
55    /// The digest that approved the recorded plan.
56    pub digest: String,
57    /// The normalised plan content as it was deployed.
58    pub plan: PlanContent,
59}
60
61/// The destination a plan describes: what the venture will become,
62/// independent of what it is now. The sha256 over this struct's JSON
63/// is the plan digest — the approval token — because an approval is of
64/// the destination, not of the journey: the baseline-dependent deltas
65/// ([`PlanContent`]'s added/removed lists) describe the journey and
66/// change the moment a deployment is recorded, so hashing them would
67/// make every deploy stale by its own success.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PlanState {
70    /// The venture name from the manifest.
71    pub venture: String,
72    /// The primary host from the manifest.
73    pub host: String,
74    /// The canonical public URL, when the manifest sets one.
75    pub public_url: Option<String>,
76    /// Browser origins allowed to call the API, sorted.
77    pub cors_origins: Vec<String>,
78    /// The resolved deployment environment (the manifest config's
79    /// `ENV` key; `development` when unset or unrecognised).
80    pub env: String,
81    /// The resolved module set — dependencies before dependants —
82    /// sorted, so manifest order cannot move the digest.
83    pub modules: Vec<String>,
84    /// The author's selection normalised: slug plus per-module config,
85    /// sorted by slug. Per-module config changes are digest-visible
86    /// here even when the resolved set is unchanged.
87    pub selected: Vec<SelectedModule>,
88    /// The venture-wide config the manifest declares.
89    pub config: BTreeMap<String, String>,
90    /// The seed SQL the manifest declares, if any.
91    pub seed_sql: Option<String>,
92    /// Migration status per resolved module, from the lockfile.
93    pub migrations: MigrationState,
94}
95
96/// The full plan: the destination ([`PlanState`], flattened on the
97/// wire, the only part the digest hashes) plus the deltas against the
98/// current baseline — the deploy record when one exists, else the
99/// modules the migration lockfile knows. Every list is sorted and
100/// every map ordered, so output is a pure function of the inputs.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct PlanContent {
103    /// The hashed destination.
104    #[serde(flatten)]
105    pub state: PlanState,
106    /// Slugs in the resolved set that the baseline does not have,
107    /// sorted.
108    pub modules_added: Vec<String>,
109    /// Baseline slugs the resolved set no longer carries, sorted.
110    /// Removing a module takes its data out of the served venture, so
111    /// a plan with any of these is destructive and `fz deploy` demands
112    /// the second consent for it.
113    pub modules_removed: Vec<String>,
114    /// What each added module needs, from the catalog: its tier, the
115    /// modules it requires, and whether the author selected it or a
116    /// dependency pulled it in. Sorted by slug.
117    pub capabilities: Vec<Capability>,
118    /// Config keys the baseline does not have, sorted.
119    pub config_added: Vec<String>,
120    /// Baseline config keys the manifest drops, sorted.
121    pub config_removed: Vec<String>,
122    /// Baseline config keys whose value changed, sorted.
123    pub config_changed: Vec<String>,
124}
125
126/// One manifest module reference, normalised: the slug and its
127/// per-module config (empty for the bare-slug form).
128#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
129pub struct SelectedModule {
130    /// The module slug.
131    pub slug: String,
132    /// Per-module config keys, ordered.
133    pub config: BTreeMap<String, String>,
134}
135
136/// What one added module needs, as the catalog declares it.
137#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
138pub struct Capability {
139    /// The module slug.
140    pub slug: String,
141    /// `core` or `optional`.
142    pub tier: String,
143    /// The slugs this module requires; the catalog pulls them in, and
144    /// resolution orders them before it.
145    pub requires: Vec<String>,
146    /// `selected` when the author listed it; `dependency` when another
147    /// module's requirement pulled it into the set.
148    pub via: String,
149}
150
151/// Migration status, read from the lockfile. The manifest world cannot
152/// know whether a module ships migrations at all — only what has been
153/// collected — so `not_collected` means "no locked migration exists",
154/// phrased exactly that way in output.
155#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
156pub struct MigrationState {
157    /// Resolved module -> number of locked migration files, ordered.
158    pub collected: BTreeMap<String, usize>,
159    /// Resolved modules with no locked migrations, sorted.
160    pub not_collected: Vec<String>,
161    /// Lockfile entries (`module/id`) whose module the resolved set no
162    /// longer carries, sorted — the migration files a removal orphans.
163    pub orphaned: Vec<String>,
164}
165
166/// What the plan diffed against, and the slugs it saw there.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
168pub struct Baseline {
169    /// `deploy-record`, `lockfile` or `nothing`.
170    pub source: &'static str,
171    /// The baseline module slugs, sorted.
172    pub modules: Vec<String>,
173}
174
175/// The two consents `fz deploy` can require, kept apart on purpose.
176///
177/// One flag for both would mean removing a module from a *development*
178/// venture required asserting "I am deploying to production" — which is
179/// untrue, and which teaches an operator to pass the production flag by
180/// habit. A gate that is passed habitually is not a gate.
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182pub struct Consent {
183    /// `--i-am-deploying-to-production`: this venture resolves to
184    /// production.
185    pub production: bool,
186    /// `--i-am-removing-modules`: the plan drops modules from the served
187    /// composition.
188    pub removal: bool,
189}
190
191/// The outcome of `fz plan`: the normalised content and its digest.
192#[derive(Debug, Clone)]
193pub struct PlanOutcome {
194    /// The normalised plan.
195    pub content: PlanContent,
196    /// sha256 over the JSON of [`PlanContent`] — the approval token
197    /// `fz deploy --plan` demands.
198    pub digest: String,
199    /// What the deltas were computed against.
200    pub baseline: Baseline,
201}
202
203/// The outcome of `fz deploy`.
204#[derive(Debug, Clone)]
205pub struct DeployOutcome {
206    /// The plan that was applied.
207    pub content: PlanContent,
208    /// The digest that approved it.
209    pub digest: String,
210    /// `false` when the exact plan was already recorded — the
211    /// idempotent second run changes nothing and says so.
212    pub changed: bool,
213    /// Where the record lives (or would live).
214    pub record_path: PathBuf,
215}
216
217/// The outcome of `fz add`.
218#[derive(Debug, Clone)]
219pub struct AddOutcome {
220    /// `false` when the module was already in the manifest — the
221    /// no-op second run changes nothing and says so.
222    pub changed: bool,
223    /// The module slug named on the command line.
224    pub module: String,
225    /// The manifest file.
226    pub manifest: PathBuf,
227}
228
229/// The outcome of `fz verify`: the doctor-shaped report plus the
230/// recomputed digest when the manifest resolved far enough to compute
231/// one.
232#[derive(Debug)]
233pub struct VerifyOutcome {
234    /// Coded failures in the doctor's shape.
235    pub report: DoctorReport,
236    /// The recomputed plan digest, when available.
237    pub digest: Option<String>,
238}
239
240/// Builds one coded failure.
241fn failure(code: &'static DoctorCodeDef, message: impl Into<String>) -> DoctorFailure {
242    DoctorFailure {
243        code,
244        message: message.into(),
245    }
246}
247
248/// The prose form of a failure list, byte-compatible with the doctor's.
249fn joined(failures: &[DoctorFailure]) -> String {
250    failures
251        .iter()
252        .map(|f| f.message.as_str())
253        .collect::<Vec<_>>()
254        .join("\n  - ")
255}
256
257/// The deployment environment the manifest declares: its `ENV` config
258/// key, `development` when absent or unrecognised. Unlike the deployed
259/// runtime (issue #143), the manifest world has no compiled environment
260/// to take the stricter of — the manifest is the declaration, and the
261/// deployment's own `ENV` binding is checked where the harness exists,
262/// by `fz doctor`.
263fn resolved_env(manifest: &VentureManifest) -> VentureEnv {
264    manifest
265        .config
266        .get("ENV")
267        .and_then(|value| VentureEnv::parse(value))
268        .unwrap_or_default()
269}
270
271/// Where the deploy record for this manifest lives: beside it.
272fn record_path(manifest_path: &Path) -> PathBuf {
273    let mut path = manifest_path.parent().map_or_else(PathBuf::new, |parent| {
274        if parent.as_os_str().is_empty() {
275            PathBuf::new()
276        } else {
277            parent.to_path_buf()
278        }
279    });
280    path.push(RECORD_FILE);
281    path
282}
283
284/// Reads the deploy record. Absent means never deployed (`None`); a
285/// record that cannot be read or parsed is a real error.
286fn read_record(manifest_path: &Path) -> Result<Option<DeployRecord>, String> {
287    let path = record_path(manifest_path);
288    let raw = match std::fs::read_to_string(&path) {
289        Ok(raw) => raw,
290        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
291        Err(err) => return Err(format!("cannot read {}: {err}", path.display())),
292    };
293    let record: DeployRecord = serde_json::from_str(&raw)
294        .map_err(|err| format!("cannot parse {}: {err}", path.display()))?;
295    if record.schema != SCHEMA {
296        return Err(format!(
297            "{}: unsupported schema {} (this fz speaks {SCHEMA})",
298            path.display(),
299            record.schema
300        ));
301    }
302    Ok(Some(record))
303}
304
305/// Writes the deploy record atomically — write a sibling temp file,
306/// then rename — so an interrupted deploy leaves either the previous
307/// record or the new one, never a torn file, and re-running `fz
308/// deploy` reconciles.
309fn write_record(manifest_path: &Path, record: &DeployRecord) -> Result<PathBuf, String> {
310    let path = record_path(manifest_path);
311    let body = serde_json::to_string_pretty(record)
312        .map_err(|err| format!("cannot serialize the deploy record: {err}"))?;
313    let mut tmp = path.clone();
314    tmp.set_extension("tmp");
315    std::fs::write(&tmp, body + "\n")
316        .map_err(|err| format!("cannot write {}: {err}", tmp.display()))?;
317    std::fs::rename(&tmp, &path).map_err(|err| {
318        format!(
319            "cannot finalize {} (partial file left at {}): {err}",
320            path.display(),
321            tmp.display()
322        )
323    })?;
324    Ok(path)
325}
326
327/// Writes a manifest back in the format its extension names — JSON by
328/// default, TOML for `.toml`. Comments and formatting of the original
329/// file are not preserved; the parsed document is.
330fn write_manifest(path: &Path, manifest: &VentureManifest) -> Result<(), String> {
331    let body = match path.extension().and_then(|ext| ext.to_str()) {
332        Some("toml") => toml::to_string_pretty(manifest)
333            .map_err(|err| format!("cannot serialize TOML manifest: {err}"))?,
334        _ => serde_json::to_string_pretty(manifest)
335            .map_err(|err| format!("cannot serialize manifest: {err}"))?,
336    };
337    std::fs::write(path, body + "\n")
338        .map_err(|err| format!("cannot write {}: {err}", path.display()))
339}
340
341/// Everything the workflow commands read: the manifest (parsed and
342/// resolved), the migration lockfile and the deploy record.
343struct Inputs {
344    manifest: VentureManifest,
345    set: ModuleSet,
346    lock: Lock,
347    record: Option<DeployRecord>,
348    catalog: Catalog,
349}
350
351/// Reads and resolves the inputs. The manifest is upstream of
352/// everything, so it fails fast; lockfile and deploy-record problems
353/// accumulate and surface together.
354fn load(manifest_path: &Path, migrations_dir: &Path) -> Result<Inputs, Vec<DoctorFailure>> {
355    let raw = std::fs::read_to_string(manifest_path).map_err(|err| {
356        vec![failure(
357            &CODES.manifest_unreadable,
358            format!("cannot read {}: {err}", manifest_path.display()),
359        )]
360    })?;
361    let manifest = crate::build::parse(manifest_path, &raw).map_err(|err| {
362        vec![failure(
363            &CODES.manifest_unreadable,
364            format!("{}: {err}", manifest_path.display()),
365        )]
366    })?;
367    let catalog = builtin();
368    let set = manifest
369        .resolve(&catalog)
370        .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
371
372    let mut failures: Vec<DoctorFailure> = Vec::new();
373    let lock = match read_lock(migrations_dir) {
374        Ok(lock) => lock,
375        Err(err) => {
376            failures.push(failure(&CODES.lockfile_unreadable, err));
377            Lock::default()
378        }
379    };
380    let record = match read_record(manifest_path) {
381        Ok(record) => record,
382        Err(err) => {
383            failures.push(failure(&CODES.deploy_record_unreadable, err));
384            None
385        }
386    };
387    if failures.is_empty() {
388        Ok(Inputs {
389            manifest,
390            set,
391            lock,
392            record,
393            catalog,
394        })
395    } else {
396        Err(failures)
397    }
398}
399
400/// The module slugs the lockfile knows, sorted — the collected
401/// composition, as close to "what deployment tooling last saw" as the
402/// manifest world gets without a deploy record.
403fn lockfile_modules(lock: &Lock) -> Vec<String> {
404    let mut modules: Vec<String> = lock
405        .keys()
406        .filter_map(|key| key.split('/').next())
407        .filter(|module| !module.is_empty())
408        .map(str::to_owned)
409        .collect();
410    modules.sort_unstable();
411    modules.dedup();
412    modules
413}
414
415/// What the plan diffs against: the deploy record when one exists,
416/// else the lockfile's modules, else nothing.
417fn baseline_of(lock: &Lock, record: Option<&DeployRecord>) -> Baseline {
418    if let Some(record) = record {
419        let mut modules = record.plan.state.modules.clone();
420        modules.sort_unstable();
421        return Baseline {
422            source: "deploy-record",
423            modules,
424        };
425    }
426    let modules = lockfile_modules(lock);
427    if modules.is_empty() {
428        Baseline {
429            source: "nothing",
430            modules: Vec::new(),
431        }
432    } else {
433        Baseline {
434            source: "lockfile",
435            modules,
436        }
437    }
438}
439
440/// What each added module needs, as the catalog declares it: tier,
441/// required modules, and whether the author selected it or a
442/// dependency pulled it in.
443fn capabilities_of(added: &[String], selected: &[&str], catalog: &Catalog) -> Vec<Capability> {
444    added
445        .iter()
446        .filter_map(|slug| {
447            let module = catalog.modules.iter().find(|m| &m.slug == slug)?;
448            let tier = match module.tier {
449                cratefield_manifest::Tier::Core => "core",
450                cratefield_manifest::Tier::Optional => "optional",
451            };
452            let mut requires = module.depends_on.clone();
453            requires.sort_unstable();
454            Some(Capability {
455                slug: slug.clone(),
456                tier: tier.to_owned(),
457                requires,
458                via: if selected.contains(&slug.as_str()) {
459                    "selected".to_owned()
460                } else {
461                    "dependency".to_owned()
462                },
463            })
464        })
465        .collect()
466}
467
468/// The venture-wide config deltas against the recorded config.
469fn config_deltas(
470    manifest: &VentureManifest,
471    recorded: &BTreeMap<String, String>,
472) -> (Vec<String>, Vec<String>, Vec<String>) {
473    let config_added: Vec<String> = manifest
474        .config
475        .keys()
476        .filter(|key| !recorded.contains_key(*key))
477        .cloned()
478        .collect();
479    let config_removed: Vec<String> = recorded
480        .keys()
481        .filter(|key| !manifest.config.contains_key(*key))
482        .cloned()
483        .collect();
484    let config_changed: Vec<String> = manifest
485        .config
486        .iter()
487        .filter(|(key, value)| {
488            recorded
489                .get(*key)
490                .is_some_and(|recorded| recorded != *value)
491        })
492        .map(|(key, _)| key.clone())
493        .collect();
494    (config_added, config_removed, config_changed)
495}
496
497/// Migration status per resolved module, read from the lockfile.
498fn migration_state(desired: &[String], lock: &Lock) -> MigrationState {
499    let mut state = MigrationState::default();
500    for key in lock.keys() {
501        let Some(module) = key.split('/').next() else {
502            continue;
503        };
504        if desired.iter().any(|slug| slug == module) {
505            *state.collected.entry(module.to_owned()).or_insert(0) += 1;
506        } else {
507            state.orphaned.push(key.clone());
508        }
509    }
510    state.not_collected = desired
511        .iter()
512        .filter(|slug| !state.collected.contains_key(*slug))
513        .cloned()
514        .collect();
515    state.orphaned.sort_unstable();
516    state
517}
518
519/// Computes the normalised plan and its baseline. Pure: the same
520/// inputs always produce the same content, so the same digest.
521#[must_use]
522fn compute_plan(
523    manifest: &VentureManifest,
524    set: &ModuleSet,
525    lock: &Lock,
526    record: Option<&DeployRecord>,
527    catalog: &Catalog,
528) -> (PlanContent, Baseline) {
529    let mut desired = set.slugs().to_vec();
530    desired.sort_unstable();
531    let baseline = baseline_of(lock, record);
532    let modules_added: Vec<String> = desired
533        .iter()
534        .filter(|slug| !baseline.modules.contains(slug))
535        .cloned()
536        .collect();
537    let modules_removed: Vec<String> = baseline
538        .modules
539        .iter()
540        .filter(|slug| !desired.contains(slug))
541        .cloned()
542        .collect();
543
544    let selected_slugs = manifest.module_slugs();
545    let capabilities = capabilities_of(&modules_added, &selected_slugs, catalog);
546    let recorded_config: &BTreeMap<String, String> = match record {
547        Some(record) => &record.plan.state.config,
548        None => &BTreeMap::new(),
549    };
550    let (config_added, config_removed, config_changed) = config_deltas(manifest, recorded_config);
551
552    let mut selected: Vec<SelectedModule> = manifest
553        .modules
554        .iter()
555        .map(|module| SelectedModule {
556            slug: module.slug().to_owned(),
557            config: module.config(),
558        })
559        .collect();
560    selected.sort_unstable();
561
562    let migrations = migration_state(&desired, lock);
563    let mut cors_origins = manifest.cors_origins.clone();
564    cors_origins.sort_unstable();
565
566    let state = PlanState {
567        venture: manifest.name.clone(),
568        host: manifest.host.clone(),
569        public_url: manifest.public_url.clone(),
570        cors_origins,
571        env: resolved_env(manifest).as_str().to_owned(),
572        modules: desired,
573        selected,
574        config: manifest.config.clone(),
575        seed_sql: manifest.seed_sql.clone(),
576        migrations,
577    };
578    let content = PlanContent {
579        state,
580        modules_added,
581        modules_removed,
582        capabilities,
583        config_added,
584        config_removed,
585        config_changed,
586    };
587    (content, baseline)
588}
589
590/// The digest of a plan: sha256 over the normalised destination
591/// state's JSON — deliberately not over the baseline-dependent deltas,
592/// so a deploy does not go stale by its own success.
593///
594/// # Errors
595///
596/// Only if `serde_json` cannot serialize the content — a struct of
597/// strings, maps and lists cannot hit that, but the Result keeps every
598/// caller total instead of panicking.
599fn plan_digest(content: &PlanContent) -> Result<String, Vec<DoctorFailure>> {
600    serde_json::to_vec(&content.state)
601        .map(|bytes| sha256_hex(&bytes))
602        .map_err(|err| {
603            vec![failure(
604                &CODES.manifest_invalid,
605                format!("the plan content does not serialize: {err}"),
606            )]
607        })
608}
609
610/// `fz plan`: describes what the manifest would change, and nothing
611/// else. Read-only by construction — nothing here touches the disk
612/// beyond reading its inputs.
613///
614/// # Errors
615///
616/// Coded failures when the manifest cannot be read or resolved, or the
617/// lockfile or deploy record cannot be parsed.
618pub fn plan(
619    manifest_path: &Path,
620    migrations_dir: &Path,
621) -> Result<PlanOutcome, Vec<DoctorFailure>> {
622    let inputs = load(manifest_path, migrations_dir)?;
623    let (content, baseline) = compute_plan(
624        &inputs.manifest,
625        &inputs.set,
626        &inputs.lock,
627        inputs.record.as_ref(),
628        &inputs.catalog,
629    );
630    let digest = plan_digest(&content)?;
631    Ok(PlanOutcome {
632        content,
633        digest,
634        baseline,
635    })
636}
637
638/// What moved between the recorded state and the one recomputed now —
639/// the message a stale `fz deploy` shows. Every field is named; when
640/// none differs the digests can disagree only by a plan-format
641/// evolution, and the message says that too.
642fn moved_since(current: &PlanState, recorded: &PlanState) -> Vec<String> {
643    let mut moved: Vec<String> = Vec::new();
644    if let Some(what) = diff_modules(&current.modules, &recorded.modules) {
645        moved.push(format!("modules: {what}"));
646    }
647    if let Some(what) = diff_config(&current.config, &recorded.config) {
648        moved.push(format!("config: {what}"));
649    }
650    if current.env != recorded.env {
651        moved.push(format!("environment: {} -> {}", recorded.env, current.env));
652    }
653    if current.host != recorded.host {
654        moved.push(format!("host: {} -> {}", recorded.host, current.host));
655    }
656    if current.public_url != recorded.public_url {
657        moved.push("public URL changed".to_owned());
658    }
659    if current.cors_origins != recorded.cors_origins {
660        moved.push("CORS origins changed".to_owned());
661    }
662    if current.seed_sql != recorded.seed_sql {
663        moved.push("seed SQL changed".to_owned());
664    }
665    if current.selected != recorded.selected {
666        moved.push("the module selection or its per-module config changed".to_owned());
667    }
668    moved
669}
670
671/// One line about a module-set difference, or `None` when equal.
672fn diff_modules(current: &[String], recorded: &[String]) -> Option<String> {
673    let added: Vec<&String> = current
674        .iter()
675        .filter(|slug| !recorded.contains(slug))
676        .collect();
677    let removed: Vec<&String> = recorded
678        .iter()
679        .filter(|slug| !current.contains(slug))
680        .collect();
681    if added.is_empty() && removed.is_empty() {
682        return None;
683    }
684    let mut parts: Vec<String> = Vec::new();
685    if !added.is_empty() {
686        parts.push(format!(
687            "added {}",
688            added
689                .iter()
690                .map(|slug| slug.as_str())
691                .collect::<Vec<_>>()
692                .join(", ")
693        ));
694    }
695    if !removed.is_empty() {
696        parts.push(format!(
697            "removed {}",
698            removed
699                .iter()
700                .map(|slug| slug.as_str())
701                .collect::<Vec<_>>()
702                .join(", ")
703        ));
704    }
705    Some(parts.join("; "))
706}
707
708/// One line about a config difference, or `None` when equal.
709fn diff_config(
710    current: &BTreeMap<String, String>,
711    recorded: &BTreeMap<String, String>,
712) -> Option<String> {
713    let added: Vec<&String> = current
714        .keys()
715        .filter(|key| !recorded.contains_key(*key))
716        .collect();
717    let removed: Vec<&String> = recorded
718        .keys()
719        .filter(|key| !current.contains_key(*key))
720        .collect();
721    let changed: Vec<&String> = current
722        .iter()
723        .filter(|(key, value)| recorded.get(*key).is_some_and(|r| r != *value))
724        .map(|(key, _)| key)
725        .collect();
726    if added.is_empty() && removed.is_empty() && changed.is_empty() {
727        return None;
728    }
729    let mut parts: Vec<String> = Vec::new();
730    let list = |keys: &[&String]| {
731        keys.iter()
732            .map(|key| key.as_str())
733            .collect::<Vec<_>>()
734            .join(", ")
735    };
736    if !added.is_empty() {
737        parts.push(format!("added {}", list(&added)));
738    }
739    if !removed.is_empty() {
740        parts.push(format!("removed {}", list(&removed)));
741    }
742    if !changed.is_empty() {
743        parts.push(format!("changed {}", list(&changed)));
744    }
745    Some(parts.join("; "))
746}
747
748/// `fz deploy --plan <digest>`: applies exactly the approved plan — by
749/// recording it, bound to its digest, in `.harness-deploy.json` beside
750/// the manifest. It refuses:
751///
752/// - without `--plan` at all (approval is the point);
753/// - a digest that does not match a plan recomputed now, naming what
754///   moved since the recorded deployment;
755/// - a production venture without `--i-am-deploying-to-production` —
756///   the second consent, deliberately unlike the first;
757/// - a plan that removes modules (their data leaves the venture)
758///   without that same flag.
759///
760/// Writing the composition itself — compiling, standing up the Worker,
761/// applying migrations — stays with the needs-human steps `fz build`
762/// and the wrangler flow own. Running the same approved deploy twice
763/// is safe: the second run records nothing and reports `changed:
764/// false`.
765///
766/// # Errors
767///
768/// Coded failures for every refusal above, plus unreadable inputs and
769/// a record that cannot be written.
770pub fn deploy(
771    approved: Option<&str>,
772    manifest_path: &Path,
773    migrations_dir: &Path,
774    consent: Consent,
775) -> Result<DeployOutcome, Vec<DoctorFailure>> {
776    let Some(approved) = approved else {
777        return Err(vec![failure(
778            &CODES.deploy_plan_required,
779            "deploy requires an approved plan — run `fz plan --json` and pass its `digest` \
780             as --plan; there is no default",
781        )]);
782    };
783    let inputs = load(manifest_path, migrations_dir)?;
784    let (planned, _) = compute_plan(
785        &inputs.manifest,
786        &inputs.set,
787        &inputs.lock,
788        inputs.record.as_ref(),
789        &inputs.catalog,
790    );
791    let digest = plan_digest(&planned)?;
792
793    let mut failures: Vec<DoctorFailure> = Vec::new();
794    if approved != digest {
795        failures.push(failure(
796            &CODES.stale_plan,
797            stale_plan_message(&planned, inputs.record.as_ref(), approved, &digest),
798        ));
799    }
800    if resolved_env(&inputs.manifest) == VentureEnv::Production && !consent.production {
801        failures.push(failure(
802            &CODES.production_deploy_unauthorized,
803            "this venture resolves to production (manifest config ENV=production) — deploying \
804             needs a second, explicit consent: pass --i-am-deploying-to-production alongside \
805             the approved digest",
806        ));
807    }
808    // Every locked migration still has to be the file that was locked.
809    //
810    // `fz doctor` checks this against a compiled harness; deploy does not
811    // have one, so it checks the lockfile directly — which is the stronger
812    // read for this purpose, because it covers entries whose module the
813    // resolved set no longer carries. Without it an edited migration
814    // deploys silently: the recorded plan claims a schema the files no
815    // longer produce, and the next environment to apply them gets
816    // something different from the one already running.
817    for (key, entry) in &inputs.lock {
818        if let Err(err) = crate::collect::verify_locked(migrations_dir, key, entry) {
819            let code = match &err {
820                crate::collect::LockedMigrationError::Missing { .. } => {
821                    &CODES.locked_migration_missing
822                }
823                crate::collect::LockedMigrationError::Edited { .. } => {
824                    &CODES.locked_migration_edited
825                }
826            };
827            failures.push(failure(code, err.to_string()));
828        }
829    }
830
831    if !planned.modules_removed.is_empty() && !consent.removal {
832        // Deliberately precise about what a removal does and does not do.
833        // `fz deploy` never touches a database, so nothing is deleted: the
834        // modules stop being compiled in and stop serving, and their
835        // tables stay exactly as they are, holding what they held. That
836        // matters in both directions — an operator who reads "its data
837        // leaves the venture" reaches for a backup nobody needs, and one
838        // who *wanted* those rows gone walks away believing they are.
839        let orphans = &planned.state.migrations.orphaned;
840        let leftovers = if orphans.is_empty() {
841            String::new()
842        } else {
843            format!(
844                " The migrations {} stay applied and the tables they created keep their rows; \
845                 dropping them is a separate, manual step (docs/ROLLBACK.md).",
846                orphans.join(", ")
847            )
848        };
849        failures.push(failure(
850            &CODES.destructive_change_unauthorized,
851            format!(
852                "this plan removes {} from the served composition: those modules stop being \
853                 served, and no data is deleted.{leftovers} Pass --i-am-removing-modules to \
854                 accept that",
855                planned.modules_removed.join(", ")
856            ),
857        ));
858    }
859    if !failures.is_empty() {
860        return Err(failures);
861    }
862
863    let record_path = record_path(manifest_path);
864    if inputs
865        .record
866        .as_ref()
867        .is_some_and(|record| record.digest == digest)
868    {
869        return Ok(DeployOutcome {
870            content: planned,
871            digest,
872            changed: false,
873            record_path,
874        });
875    }
876    let record = DeployRecord {
877        schema: SCHEMA,
878        digest: digest.clone(),
879        plan: planned.clone(),
880    };
881    let record_path = write_record(manifest_path, &record)
882        .map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
883    Ok(DeployOutcome {
884        content: planned,
885        digest,
886        changed: true,
887        record_path,
888    })
889}
890
891/// Why the approved digest is stale, naming what moved: every field
892/// that differs from the recorded deployment, or — when none does —
893/// the honest answer that the digest came from a different fz or an
894/// older plan format.
895fn stale_plan_message(
896    planned: &PlanContent,
897    record: Option<&DeployRecord>,
898    approved: &str,
899    digest: &str,
900) -> String {
901    let mut message = format!(
902        "the approved digest {approved} does not match a plan recomputed from the current \
903         inputs (the current digest is {digest})"
904    );
905    let Some(record) = record else {
906        let mut what: Vec<String> = Vec::new();
907        if !planned.modules_added.is_empty() {
908            what.push(format!("add {}", planned.modules_added.join(", ")));
909        }
910        if !planned.modules_removed.is_empty() {
911            what.push(format!("remove {}", planned.modules_removed.join(", ")));
912        }
913        if what.is_empty() {
914            message
915                .push_str("; no deployment is recorded yet, so there is nothing to diff against");
916        } else {
917            message.push_str("; no deployment is recorded yet and the current plan would ");
918            message.push_str(&what.join(" and "));
919        }
920        return message;
921    };
922    let moved = moved_since(&planned.state, &record.plan.state);
923    if moved.is_empty() {
924        message.push_str(
925            "; the recorded deployment matches every plan field, so the approved digest was \
926             computed by a different fz or an older plan format",
927        );
928    } else {
929        message.push_str("; since the recorded deployment: ");
930        message.push_str(&moved.join("; "));
931    }
932    message
933}
934
935/// `fz add <module>`: adds a module to the manifest's desired
936/// composition and nothing else — it never deploys, never provisions
937/// and never touches a database. Adding a module the manifest already
938/// lists is a no-op that succeeds with `changed: false`.
939///
940/// # Errors
941///
942/// Coded failures when the manifest cannot be read or resolved, the
943/// slug is not in the catalog, or the manifest cannot be written back.
944pub fn add(module: &str, manifest_path: &Path) -> Result<AddOutcome, Vec<DoctorFailure>> {
945    let raw = std::fs::read_to_string(manifest_path).map_err(|err| {
946        vec![failure(
947            &CODES.manifest_unreadable,
948            format!("cannot read {}: {err}", manifest_path.display()),
949        )]
950    })?;
951    let mut manifest = crate::build::parse(manifest_path, &raw).map_err(|err| {
952        vec![failure(
953            &CODES.manifest_unreadable,
954            format!("{}: {err}", manifest_path.display()),
955        )]
956    })?;
957    let catalog = builtin();
958    manifest
959        .resolve(&catalog)
960        .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
961
962    if manifest.module_slugs().contains(&module) {
963        return Ok(AddOutcome {
964            changed: false,
965            module: module.to_owned(),
966            manifest: manifest_path.to_path_buf(),
967        });
968    }
969
970    let known: Vec<&str> = catalog.modules.iter().map(|m| m.slug.as_str()).collect();
971    if !known.contains(&module) {
972        return Err(vec![failure(
973            &CODES.module_unknown,
974            format!(
975                "`{module}` is not a module in the catalog; available: {}",
976                known.join(", ")
977            ),
978        )]);
979    }
980    manifest
981        .modules
982        .push(cratefield_manifest::ModuleRef::Slug(module.to_owned()));
983    manifest
984        .resolve(&catalog)
985        .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
986    write_manifest(manifest_path, &manifest)
987        .map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
988    Ok(AddOutcome {
989        changed: true,
990        module: module.to_owned(),
991        manifest: manifest_path.to_path_buf(),
992    })
993}
994
995/// `fz init`: writes a new venture manifest — name and host, no
996/// modules yet. Refuses to overwrite an existing manifest unless
997/// `--force`.
998///
999/// # Errors
1000///
1001/// Coded failures when the file exists without `--force`, the name or
1002/// host is empty, or the manifest cannot be written.
1003pub fn init(
1004    name: &str,
1005    host: &str,
1006    manifest_path: &Path,
1007    force: bool,
1008) -> Result<PathBuf, Vec<DoctorFailure>> {
1009    if manifest_path.exists() && !force {
1010        return Err(vec![failure(
1011            &CODES.manifest_exists,
1012            format!(
1013                "{} already exists — init refuses to overwrite a manifest; pass --force to \
1014                 replace it",
1015                manifest_path.display()
1016            ),
1017        )]);
1018    }
1019    let manifest = VentureManifest {
1020        name: name.to_owned(),
1021        host: host.to_owned(),
1022        public_url: None,
1023        cors_origins: Vec::new(),
1024        modules: Vec::new(),
1025        config: BTreeMap::new(),
1026        seed_sql: None,
1027    };
1028    manifest
1029        .validate()
1030        .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
1031    write_manifest(manifest_path, &manifest)
1032        .map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
1033    Ok(manifest_path.to_path_buf())
1034}
1035
1036/// `fz verify`: diffs the recorded deployment against the manifest and
1037/// reports every drift as a coded failure in the doctor's shape —
1038/// composition, config, environment, and (when nothing narrower
1039/// explains it) the recorded digest no longer matching a recomputed
1040/// plan.
1041///
1042/// # Panics
1043///
1044/// Never; every problem is a coded failure in the report.
1045#[must_use]
1046pub fn verify(manifest_path: &Path, migrations_dir: &Path) -> VerifyOutcome {
1047    let inputs = match load(manifest_path, migrations_dir) {
1048        Ok(inputs) => inputs,
1049        Err(failures) => {
1050            return VerifyOutcome {
1051                report: DoctorReport { failures },
1052                digest: None,
1053            };
1054        }
1055    };
1056    let (content, _) = compute_plan(
1057        &inputs.manifest,
1058        &inputs.set,
1059        &inputs.lock,
1060        inputs.record.as_ref(),
1061        &inputs.catalog,
1062    );
1063    let mut failures: Vec<DoctorFailure> = Vec::new();
1064    let digest = match plan_digest(&content) {
1065        Ok(digest) => digest,
1066        Err(new_failures) => {
1067            return VerifyOutcome {
1068                report: DoctorReport {
1069                    failures: new_failures,
1070                },
1071                digest: None,
1072            };
1073        }
1074    };
1075
1076    let Some(record) = &inputs.record else {
1077        failures.push(failure(
1078            &CODES.not_deployed,
1079            "no deployment is recorded for this manifest — run `fz plan --json`, approve the \
1080             digest, then `fz deploy --plan <digest>`",
1081        ));
1082        return VerifyOutcome {
1083            report: DoctorReport { failures },
1084            digest: Some(digest),
1085        };
1086    };
1087
1088    if let Some(what) = diff_modules(&content.state.modules, &record.plan.state.modules) {
1089        failures.push(failure(
1090            &CODES.composition_drift,
1091            format!("the recorded deployment's module set drifted from the manifest: {what}"),
1092        ));
1093    }
1094    if let Some(what) = diff_config(&content.state.config, &record.plan.state.config) {
1095        failures.push(failure(
1096            &CODES.config_drift,
1097            format!("the recorded deployment's config drifted from the manifest: {what}"),
1098        ));
1099    }
1100    if content.state.env != record.plan.state.env {
1101        failures.push(failure(
1102            &CODES.env_drift,
1103            format!(
1104                "the deployment environment drifted: recorded {}, manifest resolves to {}",
1105                record.plan.state.env, content.state.env
1106            ),
1107        ));
1108    }
1109    let narrow = failures.len();
1110    if record.digest != digest {
1111        let moved = moved_since(&content.state, &record.plan.state);
1112        let message = if moved.is_empty() {
1113            format!(
1114                "the recorded digest does not match a plan recomputed from the manifest, and \
1115                 no module, config or environment drift explains it — the venture's host, \
1116                 public URL, CORS origins, seed SQL or per-module config changed since the \
1117                 deployment was recorded (recorded {}, current {digest})",
1118                record.digest
1119            )
1120        } else {
1121            format!(
1122                "the recorded digest does not match a plan recomputed from the manifest \
1123                 (recorded {}, current {digest}): {}",
1124                record.digest,
1125                moved.join("; ")
1126            )
1127        };
1128        if failures.len() == narrow {
1129            failures.push(failure(&CODES.manifest_drift, message));
1130        }
1131    }
1132    VerifyOutcome {
1133        report: DoctorReport { failures },
1134        digest: Some(digest),
1135    }
1136}
1137
1138/// One failure as it travels on the wire: the catalogue slug plus the
1139/// message, in the doctor's shape.
1140#[derive(Serialize)]
1141struct FailRef<'a> {
1142    code: &'a str,
1143    message: &'a str,
1144}
1145
1146/// Every workflow failure payload: the doctor's object, nothing more.
1147#[derive(Serialize)]
1148struct BarePayload<'a> {
1149    schema: u32,
1150    ok: bool,
1151    failures: Vec<FailRef<'a>>,
1152}
1153
1154/// The `fz plan --json` object: the verdict plus the plan content and
1155/// its digest.
1156#[derive(Serialize)]
1157struct PlanPayload<'a> {
1158    schema: u32,
1159    ok: bool,
1160    failures: Vec<FailRef<'a>>,
1161    digest: &'a str,
1162    #[serde(flatten)]
1163    content: &'a PlanContent,
1164    baseline: &'a Baseline,
1165}
1166
1167/// The `fz deploy --json` object.
1168#[derive(Serialize)]
1169struct DeployPayload<'a> {
1170    schema: u32,
1171    ok: bool,
1172    failures: Vec<FailRef<'a>>,
1173    changed: bool,
1174    digest: &'a str,
1175    venture: &'a str,
1176    env: &'a str,
1177    modules_added: &'a [String],
1178    modules_removed: &'a [String],
1179    record: String,
1180}
1181
1182/// The `fz add --json` object.
1183#[derive(Serialize)]
1184struct AddPayload<'a> {
1185    schema: u32,
1186    ok: bool,
1187    failures: Vec<FailRef<'a>>,
1188    changed: bool,
1189    module: &'a str,
1190    manifest: String,
1191}
1192
1193/// The `fz init --json` object.
1194#[derive(Serialize)]
1195struct InitPayload<'a> {
1196    schema: u32,
1197    ok: bool,
1198    failures: Vec<FailRef<'a>>,
1199    name: &'a str,
1200    host: &'a str,
1201    manifest: String,
1202}
1203
1204fn refs(failures: &[DoctorFailure]) -> Vec<FailRef<'_>> {
1205    failures
1206        .iter()
1207        .map(|failure| FailRef {
1208            code: failure.code.code,
1209            message: &failure.message,
1210        })
1211        .collect()
1212}
1213
1214/// Prints one JSON object — the payload plus the exit code it implies.
1215/// Serialization cannot fail for these structs; the stderr fallback
1216/// mirrors [`crate::doctor::doctor_json`].
1217fn emit(payload: &impl Serialize, exit: ExitCode) -> ExitCode {
1218    match serde_json::to_string(payload) {
1219        Ok(line) => {
1220            println!("{line}");
1221            exit
1222        }
1223        Err(err) => {
1224            eprintln!("fz: cannot serialize the report: {err}");
1225            ExitCode::FAILURE
1226        }
1227    }
1228}
1229
1230/// Prints a failure list under the active discipline: one JSON object
1231/// under `--json`, the joined prose on stderr otherwise.
1232fn fail(failures: &[DoctorFailure], json: bool) -> ExitCode {
1233    if json {
1234        let payload = BarePayload {
1235            schema: SCHEMA,
1236            ok: false,
1237            failures: refs(failures),
1238        };
1239        return emit(&payload, ExitCode::FAILURE);
1240    }
1241    eprintln!("fz: {}", joined(failures));
1242    ExitCode::FAILURE
1243}
1244
1245/// The `fz plan` CLI entry: both output disciplines.
1246#[must_use = "call process::exit with the returned ExitCode"]
1247pub fn plan_cli(
1248    manifest_path: &Path,
1249    migrations_dir: &Path,
1250    json: bool,
1251    non_interactive: bool,
1252) -> ExitCode {
1253    match plan(manifest_path, migrations_dir) {
1254        Ok(outcome) => {
1255            if json {
1256                let payload = PlanPayload {
1257                    schema: SCHEMA,
1258                    ok: true,
1259                    failures: Vec::new(),
1260                    digest: &outcome.digest,
1261                    content: &outcome.content,
1262                    baseline: &outcome.baseline,
1263                };
1264                return emit(&payload, ExitCode::SUCCESS);
1265            }
1266            println!(
1267                "fz plan: {} (env: {})",
1268                outcome.content.state.venture, outcome.content.state.env
1269            );
1270            println!("  digest:     {}", outcome.digest);
1271            println!(
1272                "  baseline:   {} ({})",
1273                outcome.baseline.source,
1274                or_none(&outcome.baseline.modules)
1275            );
1276            println!("  added:      {}", or_none(&outcome.content.modules_added));
1277            println!(
1278                "  removed:    {}",
1279                or_none(&outcome.content.modules_removed)
1280            );
1281            let mut config = Vec::new();
1282            for key in &outcome.content.config_added {
1283                config.push(format!("+{key}"));
1284            }
1285            for key in &outcome.content.config_removed {
1286                config.push(format!("-{key}"));
1287            }
1288            for key in &outcome.content.config_changed {
1289                config.push(format!("~{key}"));
1290            }
1291            println!(
1292                "  config:     {}",
1293                if config.is_empty() {
1294                    "(unchanged)".to_owned()
1295                } else {
1296                    config.join(" ")
1297                }
1298            );
1299            println!(
1300                "  migrations: not collected for {} / orphaned {}",
1301                or_none(&outcome.content.state.migrations.not_collected),
1302                or_none(&outcome.content.state.migrations.orphaned)
1303            );
1304            for capability in &outcome.content.capabilities {
1305                println!(
1306                    "  needs:      {} ({} module, requires {}, {})",
1307                    capability.slug,
1308                    capability.tier,
1309                    or_none(&capability.requires),
1310                    capability.via
1311                );
1312            }
1313            if !non_interactive {
1314                println!();
1315                println!("next:");
1316                println!(
1317                    "  fz deploy --plan {}   # apply exactly this plan",
1318                    outcome.digest
1319                );
1320            }
1321            ExitCode::SUCCESS
1322        }
1323        Err(failures) => fail(&failures, json),
1324    }
1325}
1326
1327/// `"(none)"` for an empty list, the comma-joined list otherwise.
1328fn or_none(items: &[String]) -> String {
1329    if items.is_empty() {
1330        "(none)".to_owned()
1331    } else {
1332        items.join(", ")
1333    }
1334}
1335
1336/// The `fz deploy` CLI entry: both output disciplines.
1337#[must_use = "call process::exit with the returned ExitCode"]
1338pub fn deploy_cli(
1339    approved: Option<&str>,
1340    manifest_path: &Path,
1341    migrations_dir: &Path,
1342    consent: Consent,
1343    json: bool,
1344    non_interactive: bool,
1345) -> ExitCode {
1346    match deploy(approved, manifest_path, migrations_dir, consent) {
1347        Ok(outcome) => {
1348            if json {
1349                let payload = DeployPayload {
1350                    schema: SCHEMA,
1351                    ok: true,
1352                    failures: Vec::new(),
1353                    changed: outcome.changed,
1354                    digest: &outcome.digest,
1355                    venture: &outcome.content.state.venture,
1356                    env: &outcome.content.state.env,
1357                    modules_added: &outcome.content.modules_added,
1358                    modules_removed: &outcome.content.modules_removed,
1359                    record: outcome.record_path.display().to_string(),
1360                };
1361                return emit(&payload, ExitCode::SUCCESS);
1362            }
1363            if !outcome.changed {
1364                println!(
1365                    "fz deploy: nothing to do — plan {} is already the recorded deployment",
1366                    outcome.digest
1367                );
1368                return ExitCode::SUCCESS;
1369            }
1370            println!(
1371                "fz deploy: {} (env: {})",
1372                outcome.content.state.venture, outcome.content.state.env
1373            );
1374            println!("  digest:     {}", outcome.digest);
1375            println!("  added:      {}", or_none(&outcome.content.modules_added));
1376            println!(
1377                "  removed:    {}",
1378                or_none(&outcome.content.modules_removed)
1379            );
1380            println!("  record:     {}", outcome.record_path.display());
1381            if !non_interactive {
1382                println!();
1383                println!("next (needs a human or credentials fz does not hold):");
1384                println!(
1385                    "  fz migrations collect        # materialise the wrangler migration files"
1386                );
1387                println!("  fz doctor                    # harness validity, production rules");
1388                println!("  worker-build --release       # compile the venture to wasm");
1389                println!("  wrangler deploy              # stand up the Worker + D1");
1390            }
1391            ExitCode::SUCCESS
1392        }
1393        Err(failures) => fail(&failures, json),
1394    }
1395}
1396
1397/// The `fz add` CLI entry: both output disciplines.
1398#[must_use = "call process::exit with the returned ExitCode"]
1399pub fn add_cli(module: &str, manifest_path: &Path, json: bool, non_interactive: bool) -> ExitCode {
1400    match add(module, manifest_path) {
1401        Ok(outcome) => {
1402            if json {
1403                let payload = AddPayload {
1404                    schema: SCHEMA,
1405                    ok: true,
1406                    failures: Vec::new(),
1407                    changed: outcome.changed,
1408                    module: &outcome.module,
1409                    manifest: outcome.manifest.display().to_string(),
1410                };
1411                return emit(&payload, ExitCode::SUCCESS);
1412            }
1413            if outcome.changed {
1414                println!(
1415                    "fz add: added `{}` to {} — the desired composition only; nothing was \
1416                     deployed, provisioned or migrated",
1417                    outcome.module,
1418                    outcome.manifest.display()
1419                );
1420                if !non_interactive {
1421                    println!();
1422                    println!("next:");
1423                    println!("  fz plan --json   # see what this would change and get its digest");
1424                }
1425            } else {
1426                println!(
1427                    "fz add: `{}` is already in {} — nothing changed",
1428                    outcome.module,
1429                    outcome.manifest.display()
1430                );
1431            }
1432            ExitCode::SUCCESS
1433        }
1434        Err(failures) => fail(&failures, json),
1435    }
1436}
1437
1438/// The `fz init` CLI entry: both output disciplines.
1439#[must_use = "call process::exit with the returned ExitCode"]
1440pub fn init_cli(
1441    name: &str,
1442    host: &str,
1443    manifest_path: &Path,
1444    force: bool,
1445    json: bool,
1446    non_interactive: bool,
1447) -> ExitCode {
1448    match init(name, host, manifest_path, force) {
1449        Ok(path) => {
1450            if json {
1451                let payload = InitPayload {
1452                    schema: SCHEMA,
1453                    ok: true,
1454                    failures: Vec::new(),
1455                    name,
1456                    host,
1457                    manifest: path.display().to_string(),
1458                };
1459                return emit(&payload, ExitCode::SUCCESS);
1460            }
1461            println!(
1462                "fz init: wrote {} (venture {name}, host {host}, no modules yet)",
1463                path.display()
1464            );
1465            if !non_interactive {
1466                println!();
1467                println!("next:");
1468                println!(
1469                    "  fz add <module>   # grow the desired composition, one module at a time"
1470                );
1471            }
1472            ExitCode::SUCCESS
1473        }
1474        Err(failures) => fail(&failures, json),
1475    }
1476}
1477
1478/// The `fz verify` CLI entry: both output disciplines. The JSON shape
1479/// is the doctor's, byte for byte.
1480#[must_use = "call process::exit with the returned ExitCode"]
1481pub fn verify_cli(manifest_path: &Path, migrations_dir: &Path, json: bool) -> ExitCode {
1482    let outcome = verify(manifest_path, migrations_dir);
1483    if json {
1484        match outcome.report.render_json() {
1485            Ok(payload) => {
1486                println!("{payload}");
1487                return if outcome.report.ok() {
1488                    ExitCode::SUCCESS
1489                } else {
1490                    ExitCode::FAILURE
1491                };
1492            }
1493            Err(err) => {
1494                eprintln!("fz: cannot serialize the report: {err}");
1495                return ExitCode::FAILURE;
1496            }
1497        }
1498    }
1499    if outcome.report.ok() {
1500        match outcome.digest {
1501            Some(digest) => println!(
1502                "fz verify: the recorded deployment matches the manifest (digest {digest})"
1503            ),
1504            None => println!("fz verify: no problems found"),
1505        }
1506        ExitCode::SUCCESS
1507    } else {
1508        eprintln!("fz: {}", joined(&outcome.report.failures));
1509        ExitCode::FAILURE
1510    }
1511}
1512
1513/// Routes the workflow commands — the ones that need no compiled-in
1514/// harness — or `None` for every other command.
1515pub(crate) fn dispatch(command: &crate::Command) -> Option<ExitCode> {
1516    match command {
1517        crate::Command::Plan {
1518            manifest,
1519            migrations,
1520            json,
1521            non_interactive,
1522        } => Some(plan_cli(manifest, migrations, *json, *non_interactive)),
1523        crate::Command::Deploy {
1524            plan,
1525            manifest,
1526            migrations,
1527            i_am_deploying_to_production,
1528            i_am_removing_modules,
1529            json,
1530            non_interactive,
1531        } => Some(deploy_cli(
1532            plan.as_deref(),
1533            manifest,
1534            migrations,
1535            Consent {
1536                production: *i_am_deploying_to_production,
1537                removal: *i_am_removing_modules,
1538            },
1539            *json,
1540            *non_interactive,
1541        )),
1542        crate::Command::Add {
1543            module,
1544            manifest,
1545            json,
1546            non_interactive,
1547        } => Some(add_cli(module, manifest, *json, *non_interactive)),
1548        crate::Command::Init {
1549            name,
1550            host,
1551            manifest,
1552            force,
1553            json,
1554            non_interactive,
1555        } => Some(init_cli(
1556            name,
1557            host,
1558            manifest,
1559            *force,
1560            *json,
1561            *non_interactive,
1562        )),
1563        crate::Command::Verify {
1564            manifest,
1565            migrations,
1566            json,
1567            ..
1568        } => Some(verify_cli(manifest, migrations, *json)),
1569        _ => None,
1570    }
1571}
1572
1573#[cfg(test)]
1574mod tests {
1575    use super::{compute_plan, plan_digest, resolved_env};
1576    use crate::lock::Lock;
1577    use cratefield_core::VentureEnv;
1578    use cratefield_manifest::{ModuleRef, VentureManifest, builtin};
1579    use std::collections::BTreeMap;
1580
1581    fn manifest(modules: &[&str], config: &[(&str, &str)]) -> VentureManifest {
1582        VentureManifest {
1583            name: "acme".to_owned(),
1584            host: "acme.factory0.dev".to_owned(),
1585            public_url: None,
1586            cors_origins: Vec::new(),
1587            modules: modules
1588                .iter()
1589                .map(|slug| ModuleRef::Slug((*slug).to_owned()))
1590                .collect(),
1591            config: config
1592                .iter()
1593                .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
1594                .collect::<BTreeMap<_, _>>(),
1595            seed_sql: None,
1596        }
1597    }
1598
1599    #[test]
1600    fn env_reads_the_manifest_config_key() {
1601        assert_eq!(resolved_env(&manifest(&[], &[])), VentureEnv::Development);
1602        assert_eq!(
1603            resolved_env(&manifest(&[], &[("ENV", "production")])),
1604            VentureEnv::Production
1605        );
1606        assert_eq!(
1607            resolved_env(&manifest(&[], &[("ENV", "nonsense")])),
1608            VentureEnv::Development,
1609            "unrecognised values fall back to development"
1610        );
1611    }
1612
1613    /// The digest is a pure function of the normalised content: the
1614    /// same inputs twice give the same digest, and any input change —
1615    /// here one config key — gives a different one.
1616    #[test]
1617    fn digest_is_stable_and_input_sensitive() {
1618        let catalog = builtin();
1619        let first = manifest(&["waitlist"], &[]);
1620        let set = first.resolve(&catalog).expect("resolves");
1621        let (content, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
1622        let (again, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
1623        let digest = plan_digest(&content).expect("digests");
1624        assert_eq!(digest, plan_digest(&again).expect("digests"));
1625
1626        let changed = manifest(&["waitlist"], &[("ENV", "production")]);
1627        let changed_set = changed.resolve(&catalog).expect("resolves");
1628        let (changed_content, _) =
1629            compute_plan(&changed, &changed_set, &Lock::default(), None, &catalog);
1630        assert_ne!(
1631            digest,
1632            plan_digest(&changed_content).expect("digests"),
1633            "a config change must move the digest"
1634        );
1635        assert_eq!(changed_content.state.env, "production");
1636    }
1637
1638    /// Manifest module order cannot move the digest: the content is
1639    /// normalised before it is hashed.
1640    #[test]
1641    fn digest_is_order_insensitive_over_the_selection() {
1642        let catalog = builtin();
1643        let a = manifest(&["waitlist", "email-signup"], &[]);
1644        let b = manifest(&["email-signup", "waitlist"], &[]);
1645        let set_a = a.resolve(&catalog).expect("resolves");
1646        let set_b = b.resolve(&catalog).expect("resolves");
1647        let (content_a, _) = compute_plan(&a, &set_a, &Lock::default(), None, &catalog);
1648        let (content_b, _) = compute_plan(&b, &set_b, &Lock::default(), None, &catalog);
1649        assert_eq!(
1650            plan_digest(&content_a).expect("digests"),
1651            plan_digest(&content_b).expect("digests")
1652        );
1653    }
1654}