1use 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
40pub const SCHEMA: u32 = 1;
43
44pub const RECORD_FILE: &str = ".harness-deploy.json";
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct DeployRecord {
53 pub schema: u32,
55 pub digest: String,
57 pub plan: PlanContent,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PlanState {
70 pub venture: String,
72 pub host: String,
74 pub public_url: Option<String>,
76 pub cors_origins: Vec<String>,
78 pub env: String,
81 pub modules: Vec<String>,
84 pub selected: Vec<SelectedModule>,
88 pub config: BTreeMap<String, String>,
90 pub seed_sql: Option<String>,
92 pub migrations: MigrationState,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct PlanContent {
103 #[serde(flatten)]
105 pub state: PlanState,
106 pub modules_added: Vec<String>,
109 pub modules_removed: Vec<String>,
114 pub capabilities: Vec<Capability>,
118 pub config_added: Vec<String>,
120 pub config_removed: Vec<String>,
122 pub config_changed: Vec<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
129pub struct SelectedModule {
130 pub slug: String,
132 pub config: BTreeMap<String, String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
138pub struct Capability {
139 pub slug: String,
141 pub tier: String,
143 pub requires: Vec<String>,
146 pub via: String,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
156pub struct MigrationState {
157 pub collected: BTreeMap<String, usize>,
159 pub not_collected: Vec<String>,
161 pub orphaned: Vec<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
168pub struct Baseline {
169 pub source: &'static str,
171 pub modules: Vec<String>,
173}
174
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182pub struct Consent {
183 pub production: bool,
186 pub removal: bool,
189}
190
191#[derive(Debug, Clone)]
193pub struct PlanOutcome {
194 pub content: PlanContent,
196 pub digest: String,
199 pub baseline: Baseline,
201}
202
203#[derive(Debug, Clone)]
205pub struct DeployOutcome {
206 pub content: PlanContent,
208 pub digest: String,
210 pub changed: bool,
213 pub record_path: PathBuf,
215}
216
217#[derive(Debug, Clone)]
219pub struct AddOutcome {
220 pub changed: bool,
223 pub module: String,
225 pub manifest: PathBuf,
227}
228
229#[derive(Debug)]
233pub struct VerifyOutcome {
234 pub report: DoctorReport,
236 pub digest: Option<String>,
238}
239
240fn failure(code: &'static DoctorCodeDef, message: impl Into<String>) -> DoctorFailure {
242 DoctorFailure {
243 code,
244 message: message.into(),
245 }
246}
247
248fn joined(failures: &[DoctorFailure]) -> String {
250 failures
251 .iter()
252 .map(|f| f.message.as_str())
253 .collect::<Vec<_>>()
254 .join("\n - ")
255}
256
257fn 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
271fn 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
284fn 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
305fn 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
327fn 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
341struct Inputs {
344 manifest: VentureManifest,
345 set: ModuleSet,
346 lock: Lock,
347 record: Option<DeployRecord>,
348 catalog: Catalog,
349}
350
351fn 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
400fn 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
415fn 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
440fn 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
468fn 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
497fn 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#[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
590fn 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
610pub 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
638fn moved_since(current: &PlanState, recorded: &PlanState) -> Vec<String> {
643 let mut moved: Vec<String> = Vec::new();
644 if let Some(what) = diff_modules(¤t.modules, &recorded.modules) {
645 moved.push(format!("modules: {what}"));
646 }
647 if let Some(what) = diff_config(¤t.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
671fn 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
708fn 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
748pub 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 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 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
891fn 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
935pub 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
995pub 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: Some(format!("https://{host}")),
1029 cors_origins: vec![format!("https://{host}")],
1039 modules: Vec::new(),
1040 config: BTreeMap::new(),
1041 seed_sql: None,
1042 ..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#[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#[derive(Serialize)]
1159struct FailRef<'a> {
1160 code: &'a str,
1161 message: &'a str,
1162}
1163
1164#[derive(Serialize)]
1166struct BarePayload<'a> {
1167 schema: u32,
1168 ok: bool,
1169 failures: Vec<FailRef<'a>>,
1170}
1171
1172#[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#[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#[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#[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
1232fn 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
1248fn 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#[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
1345fn or_none(items: &[String]) -> String {
1347 if items.is_empty() {
1348 "(none)".to_owned()
1349 } else {
1350 items.join(", ")
1351 }
1352}
1353
1354#[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#[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#[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#[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
1531pub(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 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 #[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 #[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 #[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 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 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}