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 without `--i-am-removing-modules`, a
758///   consent separate from the production one.
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        // The venture's own URL, which its problem `type` URIs are named
1023        // under (`<public_url>/problems/<slug>` — issue #557). Scaffolded
1024        // for the same reason as `cors_origins` below: without it the
1025        // harness falls back to `about:blank`, and a manifest that builds
1026        // into an error vocabulary with no URI is a worse first step than
1027        // one line the author rarely has to change.
1028        public_url: Some(format!("https://{host}")),
1029        // The venture's own host, which is the origin a browser on its
1030        // site sends. Scaffolded rather than left empty because the
1031        // harness refuses a venture with none — `fz init` writing a
1032        // manifest that `fz build` would reject is a first step that
1033        // fails on the second.
1034        //
1035        // A guess, and the one an author is most likely to want: a front
1036        // end on another domain is a line they edit, which is a better
1037        // failure than a manifest that does not build.
1038        cors_origins: vec![format!("https://{host}")],
1039        modules: Vec::new(),
1040        config: BTreeMap::new(),
1041        seed_sql: None,
1042        // `fz init` scaffolds a venture with no tables of its own; a
1043        // declaration is something an author adds (issue #153).
1044        ..Default::default()
1045    };
1046    manifest
1047        .validate()
1048        .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
1049    write_manifest(manifest_path, &manifest)
1050        .map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
1051    Ok(manifest_path.to_path_buf())
1052}
1053
1054/// `fz verify`: diffs the recorded deployment against the manifest and
1055/// reports every drift as a coded failure in the doctor's shape —
1056/// composition, config, environment, and (when nothing narrower
1057/// explains it) the recorded digest no longer matching a recomputed
1058/// plan.
1059///
1060/// # Panics
1061///
1062/// Never; every problem is a coded failure in the report.
1063#[must_use]
1064pub fn verify(manifest_path: &Path, migrations_dir: &Path) -> VerifyOutcome {
1065    let inputs = match load(manifest_path, migrations_dir) {
1066        Ok(inputs) => inputs,
1067        Err(failures) => {
1068            return VerifyOutcome {
1069                report: DoctorReport { failures },
1070                digest: None,
1071            };
1072        }
1073    };
1074    let (content, _) = compute_plan(
1075        &inputs.manifest,
1076        &inputs.set,
1077        &inputs.lock,
1078        inputs.record.as_ref(),
1079        &inputs.catalog,
1080    );
1081    let mut failures: Vec<DoctorFailure> = Vec::new();
1082    let digest = match plan_digest(&content) {
1083        Ok(digest) => digest,
1084        Err(new_failures) => {
1085            return VerifyOutcome {
1086                report: DoctorReport {
1087                    failures: new_failures,
1088                },
1089                digest: None,
1090            };
1091        }
1092    };
1093
1094    let Some(record) = &inputs.record else {
1095        failures.push(failure(
1096            &CODES.not_deployed,
1097            "no deployment is recorded for this manifest — run `fz plan --json`, approve the \
1098             digest, then `fz deploy --plan <digest>`",
1099        ));
1100        return VerifyOutcome {
1101            report: DoctorReport { failures },
1102            digest: Some(digest),
1103        };
1104    };
1105
1106    if let Some(what) = diff_modules(&content.state.modules, &record.plan.state.modules) {
1107        failures.push(failure(
1108            &CODES.composition_drift,
1109            format!("the recorded deployment's module set drifted from the manifest: {what}"),
1110        ));
1111    }
1112    if let Some(what) = diff_config(&content.state.config, &record.plan.state.config) {
1113        failures.push(failure(
1114            &CODES.config_drift,
1115            format!("the recorded deployment's config drifted from the manifest: {what}"),
1116        ));
1117    }
1118    if content.state.env != record.plan.state.env {
1119        failures.push(failure(
1120            &CODES.env_drift,
1121            format!(
1122                "the deployment environment drifted: recorded {}, manifest resolves to {}",
1123                record.plan.state.env, content.state.env
1124            ),
1125        ));
1126    }
1127    let narrow = failures.len();
1128    if record.digest != digest {
1129        let moved = moved_since(&content.state, &record.plan.state);
1130        let message = if moved.is_empty() {
1131            format!(
1132                "the recorded digest does not match a plan recomputed from the manifest, and \
1133                 no module, config or environment drift explains it — the venture's host, \
1134                 public URL, CORS origins, seed SQL or per-module config changed since the \
1135                 deployment was recorded (recorded {}, current {digest})",
1136                record.digest
1137            )
1138        } else {
1139            format!(
1140                "the recorded digest does not match a plan recomputed from the manifest \
1141                 (recorded {}, current {digest}): {}",
1142                record.digest,
1143                moved.join("; ")
1144            )
1145        };
1146        if failures.len() == narrow {
1147            failures.push(failure(&CODES.manifest_drift, message));
1148        }
1149    }
1150    VerifyOutcome {
1151        report: DoctorReport { failures },
1152        digest: Some(digest),
1153    }
1154}
1155
1156/// One failure as it travels on the wire: the catalogue slug plus the
1157/// message, in the doctor's shape.
1158#[derive(Serialize)]
1159struct FailRef<'a> {
1160    code: &'a str,
1161    message: &'a str,
1162}
1163
1164/// Every workflow failure payload: the doctor's object, nothing more.
1165#[derive(Serialize)]
1166struct BarePayload<'a> {
1167    schema: u32,
1168    ok: bool,
1169    failures: Vec<FailRef<'a>>,
1170}
1171
1172/// The `fz plan --json` object: the verdict plus the plan content and
1173/// its digest.
1174#[derive(Serialize)]
1175struct PlanPayload<'a> {
1176    schema: u32,
1177    ok: bool,
1178    failures: Vec<FailRef<'a>>,
1179    digest: &'a str,
1180    #[serde(flatten)]
1181    content: &'a PlanContent,
1182    baseline: &'a Baseline,
1183}
1184
1185/// The `fz deploy --json` object.
1186#[derive(Serialize)]
1187struct DeployPayload<'a> {
1188    schema: u32,
1189    ok: bool,
1190    failures: Vec<FailRef<'a>>,
1191    changed: bool,
1192    digest: &'a str,
1193    venture: &'a str,
1194    env: &'a str,
1195    modules_added: &'a [String],
1196    modules_removed: &'a [String],
1197    record: String,
1198}
1199
1200/// The `fz add --json` object.
1201#[derive(Serialize)]
1202struct AddPayload<'a> {
1203    schema: u32,
1204    ok: bool,
1205    failures: Vec<FailRef<'a>>,
1206    changed: bool,
1207    module: &'a str,
1208    manifest: String,
1209}
1210
1211/// The `fz init --json` object.
1212#[derive(Serialize)]
1213struct InitPayload<'a> {
1214    schema: u32,
1215    ok: bool,
1216    failures: Vec<FailRef<'a>>,
1217    name: &'a str,
1218    host: &'a str,
1219    manifest: String,
1220}
1221
1222fn refs(failures: &[DoctorFailure]) -> Vec<FailRef<'_>> {
1223    failures
1224        .iter()
1225        .map(|failure| FailRef {
1226            code: failure.code.code,
1227            message: &failure.message,
1228        })
1229        .collect()
1230}
1231
1232/// Prints one JSON object — the payload plus the exit code it implies.
1233/// Serialization cannot fail for these structs; the stderr fallback
1234/// mirrors [`crate::doctor::doctor_json`].
1235fn emit(payload: &impl Serialize, exit: ExitCode) -> ExitCode {
1236    match serde_json::to_string(payload) {
1237        Ok(line) => {
1238            println!("{line}");
1239            exit
1240        }
1241        Err(err) => {
1242            eprintln!("fz: cannot serialize the report: {err}");
1243            ExitCode::FAILURE
1244        }
1245    }
1246}
1247
1248/// Prints a failure list under the active discipline: one JSON object
1249/// under `--json`, the joined prose on stderr otherwise.
1250fn fail(failures: &[DoctorFailure], json: bool) -> ExitCode {
1251    if json {
1252        let payload = BarePayload {
1253            schema: SCHEMA,
1254            ok: false,
1255            failures: refs(failures),
1256        };
1257        return emit(&payload, ExitCode::FAILURE);
1258    }
1259    eprintln!("fz: {}", joined(failures));
1260    ExitCode::FAILURE
1261}
1262
1263/// The `fz plan` CLI entry: both output disciplines.
1264#[must_use = "call process::exit with the returned ExitCode"]
1265pub fn plan_cli(
1266    manifest_path: &Path,
1267    migrations_dir: &Path,
1268    json: bool,
1269    non_interactive: bool,
1270) -> ExitCode {
1271    match plan(manifest_path, migrations_dir) {
1272        Ok(outcome) => {
1273            if json {
1274                let payload = PlanPayload {
1275                    schema: SCHEMA,
1276                    ok: true,
1277                    failures: Vec::new(),
1278                    digest: &outcome.digest,
1279                    content: &outcome.content,
1280                    baseline: &outcome.baseline,
1281                };
1282                return emit(&payload, ExitCode::SUCCESS);
1283            }
1284            println!(
1285                "fz plan: {} (env: {})",
1286                outcome.content.state.venture, outcome.content.state.env
1287            );
1288            println!("  digest:     {}", outcome.digest);
1289            println!(
1290                "  baseline:   {} ({})",
1291                outcome.baseline.source,
1292                or_none(&outcome.baseline.modules)
1293            );
1294            println!("  added:      {}", or_none(&outcome.content.modules_added));
1295            println!(
1296                "  removed:    {}",
1297                or_none(&outcome.content.modules_removed)
1298            );
1299            let mut config = Vec::new();
1300            for key in &outcome.content.config_added {
1301                config.push(format!("+{key}"));
1302            }
1303            for key in &outcome.content.config_removed {
1304                config.push(format!("-{key}"));
1305            }
1306            for key in &outcome.content.config_changed {
1307                config.push(format!("~{key}"));
1308            }
1309            println!(
1310                "  config:     {}",
1311                if config.is_empty() {
1312                    "(unchanged)".to_owned()
1313                } else {
1314                    config.join(" ")
1315                }
1316            );
1317            println!(
1318                "  migrations: not collected for {} / orphaned {}",
1319                or_none(&outcome.content.state.migrations.not_collected),
1320                or_none(&outcome.content.state.migrations.orphaned)
1321            );
1322            for capability in &outcome.content.capabilities {
1323                println!(
1324                    "  needs:      {} ({} module, requires {}, {})",
1325                    capability.slug,
1326                    capability.tier,
1327                    or_none(&capability.requires),
1328                    capability.via
1329                );
1330            }
1331            if !non_interactive {
1332                println!();
1333                println!("next:");
1334                println!(
1335                    "  fz deploy --plan {}   # apply exactly this plan",
1336                    outcome.digest
1337                );
1338            }
1339            ExitCode::SUCCESS
1340        }
1341        Err(failures) => fail(&failures, json),
1342    }
1343}
1344
1345/// `"(none)"` for an empty list, the comma-joined list otherwise.
1346fn or_none(items: &[String]) -> String {
1347    if items.is_empty() {
1348        "(none)".to_owned()
1349    } else {
1350        items.join(", ")
1351    }
1352}
1353
1354/// The `fz deploy` CLI entry: both output disciplines.
1355#[must_use = "call process::exit with the returned ExitCode"]
1356pub fn deploy_cli(
1357    approved: Option<&str>,
1358    manifest_path: &Path,
1359    migrations_dir: &Path,
1360    consent: Consent,
1361    json: bool,
1362    non_interactive: bool,
1363) -> ExitCode {
1364    match deploy(approved, manifest_path, migrations_dir, consent) {
1365        Ok(outcome) => {
1366            if json {
1367                let payload = DeployPayload {
1368                    schema: SCHEMA,
1369                    ok: true,
1370                    failures: Vec::new(),
1371                    changed: outcome.changed,
1372                    digest: &outcome.digest,
1373                    venture: &outcome.content.state.venture,
1374                    env: &outcome.content.state.env,
1375                    modules_added: &outcome.content.modules_added,
1376                    modules_removed: &outcome.content.modules_removed,
1377                    record: outcome.record_path.display().to_string(),
1378                };
1379                return emit(&payload, ExitCode::SUCCESS);
1380            }
1381            if !outcome.changed {
1382                println!(
1383                    "fz deploy: nothing to do — plan {} is already the recorded deployment",
1384                    outcome.digest
1385                );
1386                return ExitCode::SUCCESS;
1387            }
1388            println!(
1389                "fz deploy: {} (env: {})",
1390                outcome.content.state.venture, outcome.content.state.env
1391            );
1392            println!("  digest:     {}", outcome.digest);
1393            println!("  added:      {}", or_none(&outcome.content.modules_added));
1394            println!(
1395                "  removed:    {}",
1396                or_none(&outcome.content.modules_removed)
1397            );
1398            println!("  record:     {}", outcome.record_path.display());
1399            if !non_interactive {
1400                println!();
1401                println!("next (needs a human or credentials fz does not hold):");
1402                println!(
1403                    "  fz migrations collect        # materialise the wrangler migration files"
1404                );
1405                println!("  fz doctor                    # harness validity, production rules");
1406                println!("  worker-build --release       # compile the venture to wasm");
1407                println!("  wrangler deploy              # stand up the Worker + D1");
1408            }
1409            ExitCode::SUCCESS
1410        }
1411        Err(failures) => fail(&failures, json),
1412    }
1413}
1414
1415/// The `fz add` CLI entry: both output disciplines.
1416#[must_use = "call process::exit with the returned ExitCode"]
1417pub fn add_cli(module: &str, manifest_path: &Path, json: bool, non_interactive: bool) -> ExitCode {
1418    match add(module, manifest_path) {
1419        Ok(outcome) => {
1420            if json {
1421                let payload = AddPayload {
1422                    schema: SCHEMA,
1423                    ok: true,
1424                    failures: Vec::new(),
1425                    changed: outcome.changed,
1426                    module: &outcome.module,
1427                    manifest: outcome.manifest.display().to_string(),
1428                };
1429                return emit(&payload, ExitCode::SUCCESS);
1430            }
1431            if outcome.changed {
1432                println!(
1433                    "fz add: added `{}` to {} — the desired composition only; nothing was \
1434                     deployed, provisioned or migrated",
1435                    outcome.module,
1436                    outcome.manifest.display()
1437                );
1438                if !non_interactive {
1439                    println!();
1440                    println!("next:");
1441                    println!("  fz plan --json   # see what this would change and get its digest");
1442                }
1443            } else {
1444                println!(
1445                    "fz add: `{}` is already in {} — nothing changed",
1446                    outcome.module,
1447                    outcome.manifest.display()
1448                );
1449            }
1450            ExitCode::SUCCESS
1451        }
1452        Err(failures) => fail(&failures, json),
1453    }
1454}
1455
1456/// The `fz init` CLI entry: both output disciplines.
1457#[must_use = "call process::exit with the returned ExitCode"]
1458pub fn init_cli(
1459    name: &str,
1460    host: &str,
1461    manifest_path: &Path,
1462    force: bool,
1463    json: bool,
1464    non_interactive: bool,
1465) -> ExitCode {
1466    match init(name, host, manifest_path, force) {
1467        Ok(path) => {
1468            if json {
1469                let payload = InitPayload {
1470                    schema: SCHEMA,
1471                    ok: true,
1472                    failures: Vec::new(),
1473                    name,
1474                    host,
1475                    manifest: path.display().to_string(),
1476                };
1477                return emit(&payload, ExitCode::SUCCESS);
1478            }
1479            println!(
1480                "fz init: wrote {} (venture {name}, host {host}, no modules yet)",
1481                path.display()
1482            );
1483            if !non_interactive {
1484                println!();
1485                println!("next:");
1486                println!(
1487                    "  fz add <module>   # grow the desired composition, one module at a time"
1488                );
1489            }
1490            ExitCode::SUCCESS
1491        }
1492        Err(failures) => fail(&failures, json),
1493    }
1494}
1495
1496/// The `fz verify` CLI entry: both output disciplines. The JSON shape
1497/// is the doctor's, byte for byte.
1498#[must_use = "call process::exit with the returned ExitCode"]
1499pub fn verify_cli(manifest_path: &Path, migrations_dir: &Path, json: bool) -> ExitCode {
1500    let outcome = verify(manifest_path, migrations_dir);
1501    if json {
1502        match outcome.report.render_json() {
1503            Ok(payload) => {
1504                println!("{payload}");
1505                return if outcome.report.ok() {
1506                    ExitCode::SUCCESS
1507                } else {
1508                    ExitCode::FAILURE
1509                };
1510            }
1511            Err(err) => {
1512                eprintln!("fz: cannot serialize the report: {err}");
1513                return ExitCode::FAILURE;
1514            }
1515        }
1516    }
1517    if outcome.report.ok() {
1518        match outcome.digest {
1519            Some(digest) => println!(
1520                "fz verify: the recorded deployment matches the manifest (digest {digest})"
1521            ),
1522            None => println!("fz verify: no problems found"),
1523        }
1524        ExitCode::SUCCESS
1525    } else {
1526        eprintln!("fz: {}", joined(&outcome.report.failures));
1527        ExitCode::FAILURE
1528    }
1529}
1530
1531/// Routes the workflow commands — the ones that need no compiled-in
1532/// harness — or `None` for every other command.
1533pub(crate) fn dispatch(command: &crate::Command) -> Option<ExitCode> {
1534    match command {
1535        crate::Command::Plan {
1536            manifest,
1537            migrations,
1538            json,
1539            non_interactive,
1540        } => Some(plan_cli(manifest, migrations, *json, *non_interactive)),
1541        crate::Command::Deploy {
1542            plan,
1543            manifest,
1544            migrations,
1545            i_am_deploying_to_production,
1546            i_am_removing_modules,
1547            json,
1548            non_interactive,
1549        } => Some(deploy_cli(
1550            plan.as_deref(),
1551            manifest,
1552            migrations,
1553            Consent {
1554                production: *i_am_deploying_to_production,
1555                removal: *i_am_removing_modules,
1556            },
1557            *json,
1558            *non_interactive,
1559        )),
1560        crate::Command::Add {
1561            module,
1562            manifest,
1563            json,
1564            non_interactive,
1565        } => Some(add_cli(module, manifest, *json, *non_interactive)),
1566        crate::Command::Init {
1567            name,
1568            host,
1569            manifest,
1570            force,
1571            json,
1572            non_interactive,
1573        } => Some(init_cli(
1574            name,
1575            host,
1576            manifest,
1577            *force,
1578            *json,
1579            *non_interactive,
1580        )),
1581        crate::Command::Verify {
1582            manifest,
1583            migrations,
1584            json,
1585            ..
1586        } => Some(verify_cli(manifest, migrations, *json)),
1587        _ => None,
1588    }
1589}
1590
1591#[cfg(test)]
1592mod tests {
1593    use super::{compute_plan, init, plan_digest, resolved_env};
1594    use crate::lock::Lock;
1595    use cratefield_core::VentureEnv;
1596    use cratefield_manifest::{ModuleRef, VentureManifest, builtin};
1597    use std::collections::BTreeMap;
1598
1599    fn manifest(modules: &[&str], config: &[(&str, &str)]) -> VentureManifest {
1600        VentureManifest {
1601            name: "acme".to_owned(),
1602            host: "acme.factory0.dev".to_owned(),
1603            public_url: None,
1604            // Required since the manifest began refusing an empty list:
1605            // the harness refuses a venture with no origin, so a manifest
1606            // that allows one generates a venture that panics at boot.
1607            cors_origins: vec!["https://acme.example".to_owned()],
1608            modules: modules
1609                .iter()
1610                .map(|slug| ModuleRef::Slug((*slug).to_owned()))
1611                .collect(),
1612            config: config
1613                .iter()
1614                .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
1615                .collect::<BTreeMap<_, _>>(),
1616            seed_sql: None,
1617            ..Default::default()
1618        }
1619    }
1620
1621    #[test]
1622    fn env_reads_the_manifest_config_key() {
1623        assert_eq!(resolved_env(&manifest(&[], &[])), VentureEnv::Development);
1624        assert_eq!(
1625            resolved_env(&manifest(&[], &[("ENV", "production")])),
1626            VentureEnv::Production
1627        );
1628        assert_eq!(
1629            resolved_env(&manifest(&[], &[("ENV", "nonsense")])),
1630            VentureEnv::Development,
1631            "unrecognised values fall back to development"
1632        );
1633    }
1634
1635    /// The digest is a pure function of the normalised content: the
1636    /// same inputs twice give the same digest, and any input change —
1637    /// here one config key — gives a different one.
1638    #[test]
1639    fn digest_is_stable_and_input_sensitive() {
1640        let catalog = builtin();
1641        let first = manifest(&["waitlist"], &[]);
1642        let set = first.resolve(&catalog).expect("resolves");
1643        let (content, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
1644        let (again, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
1645        let digest = plan_digest(&content).expect("digests");
1646        assert_eq!(digest, plan_digest(&again).expect("digests"));
1647
1648        let changed = manifest(&["waitlist"], &[("ENV", "production")]);
1649        let changed_set = changed.resolve(&catalog).expect("resolves");
1650        let (changed_content, _) =
1651            compute_plan(&changed, &changed_set, &Lock::default(), None, &catalog);
1652        assert_ne!(
1653            digest,
1654            plan_digest(&changed_content).expect("digests"),
1655            "a config change must move the digest"
1656        );
1657        assert_eq!(changed_content.state.env, "production");
1658    }
1659
1660    /// Manifest module order cannot move the digest: the content is
1661    /// normalised before it is hashed.
1662    #[test]
1663    fn digest_is_order_insensitive_over_the_selection() {
1664        let catalog = builtin();
1665        let a = manifest(&["waitlist", "email-signup"], &[]);
1666        let b = manifest(&["email-signup", "waitlist"], &[]);
1667        let set_a = a.resolve(&catalog).expect("resolves");
1668        let set_b = b.resolve(&catalog).expect("resolves");
1669        let (content_a, _) = compute_plan(&a, &set_a, &Lock::default(), None, &catalog);
1670        let (content_b, _) = compute_plan(&b, &set_b, &Lock::default(), None, &catalog);
1671        assert_eq!(
1672            plan_digest(&content_a).expect("digests"),
1673            plan_digest(&content_b).expect("digests")
1674        );
1675    }
1676
1677    /// `fz init` had no test, and its own comment says why one was
1678    /// wanted: "`fz init` writing a manifest that `fz build` would reject
1679    /// is a first step that fails on the second". The scaffolded origin
1680    /// was added for that reason and nothing checked it stayed true.
1681    #[test]
1682    fn init_scaffolds_a_manifest_the_rest_of_the_toolchain_accepts() {
1683        let dir = cratefield_testing::TempDir::new("fz-init");
1684        let path = dir.path().join("venture.json");
1685        init("acme-signups", "acme.factory0.dev", &path, false).expect("init writes one");
1686        let written = std::fs::read_to_string(&path).expect("it is there");
1687        let manifest = VentureManifest::from_json_str(&written).expect("it parses");
1688        manifest.validate().expect("and it is one fz build accepts");
1689        assert_eq!(manifest.cors_origins, ["https://acme.factory0.dev"]);
1690        // The problem base (issue #557): `fz init` scaffolds the public URL
1691        // so the venture's error `type`s are named under its own domain
1692        // rather than falling back to `about:blank`.
1693        assert_eq!(
1694            manifest.public_url.as_deref(),
1695            Some("https://acme.factory0.dev")
1696        );
1697    }
1698
1699    #[test]
1700    fn init_refuses_a_name_that_is_not_one_and_writes_nothing() {
1701        // The refusal has to come before the file: a manifest on disk
1702        // that cannot build is worse than no manifest, because the next
1703        // command reads it rather than the author's intent.
1704        let dir = cratefield_testing::TempDir::new("fz-init");
1705        let path = dir.path().join("venture.json");
1706        let failures = init("My Venture", "acme.factory0.dev", &path, false)
1707            .expect_err("a venture name is a slug");
1708        assert!(
1709            failures
1710                .iter()
1711                .any(|failure| failure.message.contains("name")),
1712            "{failures:?}"
1713        );
1714        assert!(!path.exists(), "a manifest that cannot build was written");
1715    }
1716}