1use clap::{Args as ClapArgs, Subcommand, ValueEnum};
26use serde_json::json;
27
28use memstead_base::binding::{
29 BINDING_VERSION, BindingV1, BuildMode, BuildOperation, CapabilityError, CoverageSemantics,
30 DEFAULT_ADJUDICATION_CAP, DEFAULT_FULL_RESYNC_EVERY, Operations, PruneConfig, ResolvedBinding,
31 SyncOperation, VerifyOperation, prune_guarantee_for_medium, validate_binding,
32};
33use memstead_base::binding_migrate::{
34 BindingMigrateError, migrate_gen2_bindings, resolve_migrated_binding,
35};
36use memstead_base::ingest::advance::{
37 AdvanceError, DispositionInput, ExcludeError, advance_baseline, record_exclusions,
38};
39use memstead_base::ingest::findings::{FullResyncDecision, verify_binding};
40use memstead_base::ingest::report::{
41 DEFAULT_REPORT_BUDGET, compute_fidelity_report, render_fidelity_report,
42};
43use memstead_base::ingest::resolve::{
44 ResolveError, ResolvedPrimarySource, ResolvedSource, resolve_binding, resolve_binding_run,
45};
46use memstead_base::ingest::{
47 RenderBriefError, render_ingest_brief, render_sync_brief_for, render_verify_brief_for,
48 select_next_due,
49};
50use memstead_base::pipeline::{
51 Facet, IngestTrigger, Medium, MediumType, PatternEntry, PatternMode,
52};
53use memstead_base::pipeline_store::{
54 delete_ingest, load_legacy_pipeline_configs, load_pipeline_configs, read_binding,
55 write_binding, write_facet, write_medium,
56};
57use memstead_base::workspace_store::StoreError;
58use memstead_base::{migrate_legacy_pipeline, read_legacy_pipeline_configs};
59
60use crate::CliError;
61use crate::output::{ExitKind, print_json, print_markdown};
62use crate::setup::{CliContext, CliEngine, workspace_not_initialised_error};
63
64#[derive(ClapArgs, Debug)]
65pub struct Args {
66 #[command(subcommand)]
67 pub command: ProjectionCommand,
68}
69
70#[derive(Subcommand, Debug)]
71pub enum ProjectionCommand {
72 Brief(BriefArgs),
89 Init(InitArgs),
99 Migrate(MigrateArgs),
110 Enable(EnableArgs),
120 Advance(AdvanceArgs),
133 Exclude(ExcludeArgs),
145 Verify(VerifyArgs),
161}
162
163#[derive(Clone, Copy, Debug, ValueEnum)]
167pub enum MediumTypeArg {
168 Codebase,
170 Filesystem,
172 Git,
174 Graph,
176 Web,
178}
179
180impl MediumTypeArg {
181 fn to_medium_type(self) -> MediumType {
182 match self {
183 MediumTypeArg::Codebase => MediumType::Codebase,
184 MediumTypeArg::Filesystem => MediumType::Filesystem,
185 MediumTypeArg::Git => MediumType::Git,
186 MediumTypeArg::Graph => MediumType::Graph,
187 MediumTypeArg::Web => MediumType::Web,
188 }
189 }
190}
191
192#[derive(ClapArgs, Debug)]
193pub struct BriefArgs {
194 pub binding: Option<String>,
199 #[arg(long)]
203 pub all: bool,
204 #[arg(long, conflicts_with = "sync")]
209 pub verify: bool,
210 #[arg(long, conflicts_with = "verify")]
216 pub sync: bool,
217}
218
219#[derive(ClapArgs, Debug)]
220pub struct InitArgs {
221 #[arg(long)]
224 pub mem: String,
225 #[arg(long)]
228 pub source: String,
229 #[arg(long = "medium-type", value_enum)]
232 pub medium_type: MediumTypeArg,
233 #[arg(long)]
235 pub intent: Option<String>,
236 #[arg(long)]
240 pub name: Option<String>,
241}
242
243#[derive(ClapArgs, Debug)]
244pub struct MigrateArgs {
245 #[arg(long)]
248 pub dry_run: bool,
249}
250
251#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
256pub enum EnableOperationArg {
257 Build,
259 Sync,
261 Verify,
263}
264
265impl EnableOperationArg {
266 fn name(self) -> &'static str {
267 match self {
268 EnableOperationArg::Build => "build",
269 EnableOperationArg::Sync => "sync",
270 EnableOperationArg::Verify => "verify",
271 }
272 }
273}
274
275#[derive(ClapArgs, Debug)]
276pub struct EnableArgs {
277 #[arg(value_enum)]
279 pub operation: EnableOperationArg,
280 pub binding: String,
282}
283
284#[derive(ClapArgs, Debug)]
285pub struct AdvanceArgs {
286 pub binding: String,
288 #[arg(long)]
298 pub dispositions: String,
299}
300
301#[derive(ClapArgs, Debug)]
302pub struct ExcludeArgs {
303 pub binding: String,
305 #[arg(long)]
311 pub exclusions: String,
312}
313
314#[derive(ClapArgs, Debug)]
315pub struct VerifyArgs {
316 pub binding: String,
318 #[arg(long)]
323 pub budget: Option<usize>,
324 #[arg(long = "include")]
327 pub include: Vec<String>,
328}
329
330pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
331 match args.command {
332 ProjectionCommand::Brief(a) => brief(ctx, a),
333 ProjectionCommand::Init(a) => init(ctx, a),
334 ProjectionCommand::Migrate(a) => migrate(ctx, a),
335 ProjectionCommand::Enable(a) => enable(ctx, a),
336 ProjectionCommand::Advance(a) => advance(ctx, a),
337 ProjectionCommand::Exclude(a) => exclude(ctx, a),
338 ProjectionCommand::Verify(a) => verify(ctx, a),
339 }
340}
341
342fn map_brief_err(binding_id: &str, err: RenderBriefError) -> CliError {
348 let message = err.to_string();
349 let mapped = match &err {
350 RenderBriefError::ConfigLoad(_) => {
351 CliError::new(ExitKind::Generic, "PROJECTION_LOAD_FAILED", message)
352 }
353 RenderBriefError::BuildOperationAbsent { .. } => CliError::new(
356 ExitKind::Validation,
357 "PROJECTION_BUILD_NOT_ENABLED",
358 message,
359 ),
360 RenderBriefError::FindingsRead { .. } => CliError::new(
362 ExitKind::Generic,
363 "PROJECTION_FINDINGS_READ_FAILED",
364 message,
365 ),
366 RenderBriefError::Resolve(inner) => match inner {
367 ResolveError::BindingNotFound { .. } => {
368 CliError::new(ExitKind::NotFound, "PROJECTION_NOT_FOUND", message)
369 }
370 ResolveError::FacetNotFound { .. } => {
371 CliError::new(ExitKind::NotFound, "PROJECTION_FACET_NOT_FOUND", message)
372 }
373 ResolveError::MediumNotFound { .. } => {
374 CliError::new(ExitKind::NotFound, "PROJECTION_MEDIUM_NOT_FOUND", message)
375 }
376 ResolveError::MalformedProjectionRef { .. } => {
377 CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
378 }
379 },
380 };
381 mapped.with_details(json!({ "binding": binding_id }))
382}
383
384fn brief(ctx: &CliContext, args: BriefArgs) -> anyhow::Result<()> {
385 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
386 workspace_not_initialised_error(
387 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
388 )
389 })?;
390
391 let cli_engine = ctx.cli_engine_at(&root)?;
392 let engine = match &cli_engine {
393 #[cfg(feature = "mem-repo")]
394 CliEngine::MemRepo(e) => e,
395 CliEngine::Filesystem(e) => e,
396 };
397
398 if args.verify || args.sync {
402 let binding_id = args.binding.ok_or_else(|| {
403 CliError::new(
404 ExitKind::Validation,
405 "PROJECTION_BRIEF_BINDING_REQUIRED",
406 format!(
407 "`projection brief --{}` needs a binding id `<mem>/<stem>` — it renders one \
408 binding's brief, not an `--all` rotation",
409 if args.verify { "verify" } else { "sync" }
410 ),
411 )
412 })?;
413 let rendered = if args.verify {
414 render_verify_brief_for(engine, &root, &binding_id)
415 } else {
416 render_sync_brief_for(engine, &root, &binding_id)
417 }
418 .map_err(|e| map_brief_err(&binding_id, e))?;
419
420 if ctx.json {
421 print_json(&json!({ "brief": rendered }))?;
422 } else {
423 print!("{rendered}");
424 }
425 return Ok(());
426 }
427
428 let selected = match args.binding {
432 Some(binding) if !args.all => Some(binding),
433 _ => {
434 let configs = load_pipeline_configs(&root).map_err(|e| {
435 CliError::new(
436 ExitKind::Generic,
437 "PROJECTION_LOAD_FAILED",
438 format!("could not load binding store: {e}"),
439 )
440 .with_details(json!({ "error": e.to_string() }))
441 })?;
442 if configs.bindings.is_empty() {
449 if ctx.json {
450 print_json(&json!({ "no_bindings": true }))?;
451 } else {
452 println!("> **[projection] No bindings configured in this workspace yet.**");
453 }
454 return Ok(());
455 }
456 select_next_due(engine, &root, &configs)
457 }
458 };
459
460 let Some(binding_id) = selected else {
461 if ctx.json {
463 print_json(&json!({ "skipped": true }))?;
464 } else {
465 println!(
466 "> **[projection] Skipped — every eligible binding is backing off this pass.**"
467 );
468 }
469 return Ok(());
470 };
471
472 let rendered = render_ingest_brief(engine, &root, &binding_id)
473 .map_err(|e| map_brief_err(&binding_id, e))?;
474
475 if ctx.json {
476 print_json(&json!({ "brief": rendered }))?;
477 } else {
478 print!("{rendered}");
481 }
482 Ok(())
483}
484
485fn is_single_component(value: &str) -> bool {
490 !value.is_empty()
491 && value != "."
492 && value != ".."
493 && !value.contains('/')
494 && !value.contains('\\')
495 && !value.contains(':')
496 && !value.contains('\0')
497}
498
499fn derive_stem(source: &str) -> String {
503 source
504 .trim_end_matches('/')
505 .rsplit('/')
506 .next()
507 .unwrap_or(source)
508 .to_string()
509}
510
511fn init_write_error(binding_id: &str, err: StoreError) -> CliError {
513 CliError::new(
514 ExitKind::Generic,
515 "PROJECTION_INIT_FAILED",
516 format!("could not scaffold binding `{binding_id}`: {err}"),
517 )
518 .with_details(json!({ "binding": binding_id, "error": err.to_string() }))
519}
520
521fn init(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
522 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
523 workspace_not_initialised_error(
524 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
525 )
526 })?;
527
528 let mem = args.mem;
529 let stem = args
530 .name
531 .clone()
532 .unwrap_or_else(|| derive_stem(&args.source));
533
534 for (kind, value) in [("mem", mem.as_str()), ("name", stem.as_str())] {
537 if !is_single_component(value) {
538 return Err(CliError::new(
539 ExitKind::Validation,
540 "PROJECTION_INVALID_NAME",
541 format!(
542 "invalid {kind} '{}': must be a single path component (no separators, \
543 traversal segments, ':' or NUL) — pass an explicit --name",
544 value.escape_default()
545 ),
546 )
547 .with_details(json!({ "kind": kind, "value": value }))
548 .into());
549 }
550 }
551
552 let binding_id = format!("{mem}/{stem}");
553 let medium_type = args.medium_type.to_medium_type();
554
555 let binding_path = root
559 .join(".memstead")
560 .join("projections")
561 .join(&mem)
562 .join(format!("{stem}.json"));
563 if binding_path.exists() {
564 return Err(CliError::new(
565 ExitKind::Validation,
566 "PROJECTION_EXISTS",
567 format!(
568 "a binding `{binding_id}` already exists at \
569 .memstead/projections/{mem}/{stem}.json — `projection init` never overwrites; \
570 choose a different --name or edit the existing binding"
571 ),
572 )
573 .with_details(json!({ "binding": binding_id }))
574 .into());
575 }
576
577 let medium = Medium {
582 name: stem.clone(),
583 medium_type,
584 pointer: args.source.clone(),
585 change_detection: None,
586 };
587 let scope = vec![PatternEntry {
588 path: "**/*".to_string(),
589 mode: PatternMode::Allow,
590 }];
591 let facet = Facet {
592 name: stem.clone(),
593 medium: stem.clone(),
594 scope: scope.clone(),
595 engagement: None,
596 preparation: None,
597 };
598
599 let mut binding = BindingV1 {
605 version: BINDING_VERSION,
606 intent: args.intent.clone(),
607 source_facets: vec![stem.clone()],
608 reference_mems: Vec::new(),
609 destination_mem: mem.clone(),
610 deny_paths: Vec::new(),
611 coverage_semantics: CoverageSemantics::Exhaustive,
612 rules: None,
613 prune: None,
614 operations: Operations {
615 build: Some(BuildOperation {
616 mode: BuildMode::Discovery,
617 trigger: IngestTrigger::Loop,
618 batch_size: 20,
619 post_actions: None,
620 }),
621 sync: Some(SyncOperation {
622 trigger: IngestTrigger::Manual,
623 batch_size: 20,
624 }),
625 verify: Some(VerifyOperation {
626 trigger: IngestTrigger::Manual,
627 batch_size: 20,
628 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
629 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
630 }),
631 },
632 };
633
634 let resolved = ResolvedBinding {
635 binding: binding.clone(),
636 primary_sources: vec![ResolvedPrimarySource {
637 facet_ref: stem.clone(),
638 medium: stem.clone(),
639 medium_type,
640 medium_pointer: args.source.clone(),
641 declared_change_detection: None,
642 scope,
643 preparation: None,
644 }],
645 };
646
647 let mut warnings: Vec<String> = Vec::new();
648 if let Err(refusals) = validate_binding(&resolved) {
649 for r in &refusals {
650 if let CapabilityError::OperationOutOfScope { operation, .. } = r {
651 match *operation {
652 "sync" => binding.operations.sync = None,
653 "verify" => binding.operations.verify = None,
654 _ => {}
655 }
656 }
657 warnings.push(r.to_string());
658 }
659 }
660
661 if binding.operations.sync.is_some() {
667 binding.prune = Some(PruneConfig {
668 guarantee: prune_guarantee_for_medium(medium_type),
669 });
670 }
671
672 let mut operations: Vec<&str> = vec!["build"];
673 if binding.operations.sync.is_some() {
674 operations.push("sync");
675 }
676 if binding.operations.verify.is_some() {
677 operations.push("verify");
678 }
679
680 write_medium(&root, &mem, &stem, &medium).map_err(|e| init_write_error(&binding_id, e))?;
684 write_facet(&root, &mem, &stem, &facet).map_err(|e| init_write_error(&binding_id, e))?;
685 write_binding(&root, &mem, &stem, &binding).map_err(|e| init_write_error(&binding_id, e))?;
686
687 let created = vec![
688 format!(".memstead/mediums/{mem}/{stem}.json"),
689 format!(".memstead/facets/{mem}/{stem}.json"),
690 format!(".memstead/projections/{mem}/{stem}.json"),
691 ];
692
693 if ctx.json {
694 print_json(&json!({
696 "binding": binding_id,
697 "created": created,
698 "operations": operations,
699 "warnings": warnings,
700 }))?;
701 } else {
702 let mut out = format!("# Projection init\n\nScaffolded binding `{binding_id}`:\n");
703 for c in &created {
704 out.push_str(&format!("- `{c}`\n"));
705 }
706 out.push_str(&format!("\nOperations: {}\n", operations.join(", ")));
707 if !warnings.is_empty() {
708 out.push_str("\n## Warnings\n\n");
709 for w in &warnings {
710 out.push_str(&format!("- {w}\n"));
711 }
712 }
713 print_markdown(&out);
714 }
715 Ok(())
716}
717
718fn map_migrate_err(err: BindingMigrateError) -> CliError {
719 let message = err.to_string();
723 match &err {
724 BindingMigrateError::RefinementModeDeleted { .. } => CliError::new(
725 ExitKind::Validation,
726 "PROJECTION_MIGRATE_REFINEMENT",
727 message,
728 ),
729 BindingMigrateError::MalformedProjectionRef { .. } => CliError::new(
730 ExitKind::Validation,
731 "PROJECTION_MIGRATE_MALFORMED_REF",
732 message,
733 ),
734 BindingMigrateError::DanglingProjectionRef { .. } => CliError::new(
735 ExitKind::Validation,
736 "PROJECTION_MIGRATE_DANGLING_REF",
737 message,
738 ),
739 }
740}
741
742fn has_legacy_root_layout(root: &std::path::Path) -> bool {
748 ["scopes", "projections", "ingests"]
749 .iter()
750 .any(|d| root.join(d).is_dir())
751}
752
753fn migrate_load_err(err: StoreError) -> CliError {
755 CliError::new(
756 ExitKind::Generic,
757 "PROJECTION_MIGRATE_FAILED",
758 format!("could not load pipeline config: {err}"),
759 )
760 .with_details(json!({ "error": err.to_string() }))
761}
762
763fn pointer_resolves_to(root: &std::path::Path, medium_pointer: &str, abs_path: &str) -> bool {
768 let resolved = if medium_pointer.is_empty() {
769 root.to_path_buf()
770 } else {
771 root.join(medium_pointer)
772 };
773 match (
774 std::fs::canonicalize(&resolved),
775 std::fs::canonicalize(abs_path),
776 ) {
777 (Ok(a), Ok(b)) => a == b,
778 _ => resolved == std::path::Path::new(abs_path),
779 }
780}
781
782fn propose_workspace_toml(root: &std::path::Path) -> Option<String> {
787 let path = root.join(".memstead").join("workspace.toml");
788 let content = std::fs::read_to_string(path).ok()?;
789 let hits: Vec<(usize, &str)> = content
790 .lines()
791 .enumerate()
792 .filter(|(_, l)| {
793 let low = l.to_lowercase();
794 low.contains("reconcile-cursors") || low.contains("ingests/") || low.contains("ingest ")
795 })
796 .collect();
797 if hits.is_empty() {
798 return None;
799 }
800 let mut block = String::from(
801 "## Proposal: workspace.toml (NOT applied)\n\n`projection migrate` never edits \
802 `workspace.toml`. It found references to retired pipeline vocabulary — review and \
803 update these lines by hand, then commit:\n\n",
804 );
805 for (i, line) in hits {
806 block.push_str(&format!("- L{}: `{}`\n", i + 1, line.trim()));
807 }
808 Some(block)
809}
810
811fn consume_reconcile_cursors(
818 ctx: &CliContext,
819 root: &std::path::Path,
820) -> anyhow::Result<Vec<String>> {
821 let cursor_path = root.join(".memstead").join("reconcile-cursors.json");
822 if !cursor_path.exists() {
823 return Ok(Vec::new());
824 }
825 let cursors: std::collections::BTreeMap<String, String> = std::fs::read(&cursor_path)
826 .ok()
827 .and_then(|b| serde_json::from_slice(&b).ok())
828 .unwrap_or_default();
829
830 let mut seeded: Vec<String> = Vec::new();
831 if !cursors.is_empty() {
832 let configs = load_pipeline_configs(root).map_err(migrate_load_err)?;
833 let mut cli_engine = ctx.cli_engine_at(root)?;
834 let engine = match &mut cli_engine {
835 #[cfg(feature = "mem-repo")]
836 CliEngine::MemRepo(e) => e,
837 CliEngine::Filesystem(e) => e,
838 };
839 for (cursor_key, sha) in &cursors {
840 let Some((_cursor_mem, abs_path)) = cursor_key.split_once(':') else {
842 continue;
843 };
844 for record in &configs.bindings {
845 let binding_id = format!("{}/{}", record.mem, record.name);
846 let Ok(resolved) = resolve_binding_run(&configs, &binding_id, &record.config)
847 else {
848 continue;
849 };
850 for source in &resolved.sources {
851 if let ResolvedSource::Primary(p) = source
852 && pointer_resolves_to(root, &p.medium_pointer, abs_path)
853 {
854 let key = format!("{binding_id}/{}#synced", p.facet_ref);
855 if engine
856 .set_mem_sync_state(
857 &resolved.destination_mem,
858 &key,
859 sha,
860 Some("projection migrate: seeded from reconcile-cursors.json"),
861 )
862 .is_ok()
863 {
864 seeded.push(key);
865 }
866 }
867 }
868 }
869 }
870 }
871 let _ = std::fs::remove_file(&cursor_path);
873 Ok(seeded)
874}
875
876fn migrate(ctx: &CliContext, args: MigrateArgs) -> anyhow::Result<()> {
877 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
878 workspace_not_initialised_error(
879 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
880 )
881 })?;
882
883 let gen1 = has_legacy_root_layout(&root);
890 if gen1 && !args.dry_run {
891 migrate_legacy_pipeline(&root).map_err(|e| {
892 CliError::new(
893 ExitKind::Generic,
894 "PROJECTION_MIGRATE_FAILED",
895 format!("could not convert root-folder (gen-1) pipeline layout: {e}"),
896 )
897 .with_details(json!({ "error": e.to_string() }))
898 })?;
899 }
900
901 let configs = if gen1 && args.dry_run {
902 read_legacy_pipeline_configs(&root).map_err(migrate_load_err)?
903 } else {
904 load_legacy_pipeline_configs(&root).map_err(migrate_load_err)?
905 };
906
907 let migrated = migrate_gen2_bindings(&configs).map_err(map_migrate_err)?;
910
911 let mut warnings: Vec<serde_json::Value> = Vec::new();
916 for m in &migrated {
917 match resolve_migrated_binding(&configs, &m.id, m.binding.clone()) {
918 Ok(resolved) => {
919 if let Err(refusals) = validate_binding(&resolved) {
920 for r in refusals {
921 warnings.push(json!({
922 "binding": m.id,
923 "kind": "capability",
924 "message": r.to_string(),
925 }));
926 }
927 }
928 }
929 Err(e) => warnings.push(json!({
930 "binding": m.id,
931 "kind": "resolve",
932 "message": e.to_string(),
933 })),
934 }
935 for note in &m.notes {
936 warnings.push(json!({
937 "binding": m.id,
938 "kind": "note",
939 "message": note,
940 }));
941 }
942 }
943
944 if !args.dry_run {
947 for m in &migrated {
948 write_binding(&root, &m.mem, &m.name, &m.binding).map_err(|e| {
949 CliError::new(
950 ExitKind::Generic,
951 "PROJECTION_MIGRATE_FAILED",
952 format!("could not write binding `{}`: {e}", m.id),
953 )
954 .with_details(json!({ "binding": m.id, "error": e.to_string() }))
955 })?;
956 delete_ingest(&root, &m.ingest_name).map_err(|e| {
957 CliError::new(
958 ExitKind::Generic,
959 "PROJECTION_MIGRATE_FAILED",
960 format!("could not remove merged ingest `{}`: {e}", m.ingest_name),
961 )
962 .with_details(json!({ "ingest": m.ingest_name, "error": e.to_string() }))
963 })?;
964 }
965 }
966
967 let (seeded, proposal) = if args.dry_run {
971 (Vec::new(), None)
972 } else {
973 (
974 consume_reconcile_cursors(ctx, &root)?,
975 propose_workspace_toml(&root),
976 )
977 };
978
979 let bindings: Vec<&str> = migrated.iter().map(|m| m.id.as_str()).collect();
980 if ctx.json {
981 print_json(&json!({
982 "ok": true,
983 "dry_run": args.dry_run,
984 "migrated": migrated.len(),
985 "bindings": bindings,
986 "warnings": warnings,
987 "cursors_seeded": seeded,
988 "workspace_toml_proposal": proposal,
989 }))?;
990 } else {
991 let verb = if args.dry_run {
992 "Would migrate"
993 } else {
994 "Migrated"
995 };
996 let mut out = format!(
997 "# Projection migration\n\n{verb} {} binding(s) to v1:\n",
998 migrated.len()
999 );
1000 for id in &bindings {
1001 out.push_str(&format!("- `{id}`\n"));
1002 }
1003 if !warnings.is_empty() {
1004 out.push_str("\n## Warnings\n\n");
1005 for w in &warnings {
1006 out.push_str(&format!(
1007 "- [{}] `{}`: {}\n",
1008 w["kind"].as_str().unwrap_or(""),
1009 w["binding"].as_str().unwrap_or(""),
1010 w["message"].as_str().unwrap_or(""),
1011 ));
1012 }
1013 }
1014 if !seeded.is_empty() {
1015 out.push_str("\n## Baselines seeded from reconcile-cursors.json\n\n");
1016 for key in &seeded {
1017 out.push_str(&format!("- `{key}`\n"));
1018 }
1019 }
1020 if let Some(block) = &proposal {
1021 out.push('\n');
1022 out.push_str(block);
1023 }
1024 if !args.dry_run {
1025 out.push_str(
1026 "\nEach projection file was promoted to a v1 binding in place and its merged \
1027 ingest removed.\n",
1028 );
1029 }
1030 print_markdown(&out);
1031 }
1032 Ok(())
1033}
1034
1035fn invalid_binding_id(binding_id: &str) -> CliError {
1039 CliError::new(
1040 ExitKind::Validation,
1041 "PROJECTION_INVALID_NAME",
1042 format!(
1043 "invalid binding id '{}': expected `<mem>/<stem>` with each half a single path \
1044 component (no extra separators, traversal segments, ':' or NUL)",
1045 binding_id.escape_default()
1046 ),
1047 )
1048 .with_details(json!({ "binding": binding_id }))
1049}
1050
1051fn enable_failed(binding_id: &str, err: StoreError) -> CliError {
1056 CliError::new(
1057 ExitKind::Generic,
1058 "PROJECTION_ENABLE_FAILED",
1059 format!("could not enable operation on binding `{binding_id}`: {err}"),
1060 )
1061 .with_details(json!({ "binding": binding_id, "error": err.to_string() }))
1062}
1063
1064fn enable(ctx: &CliContext, args: EnableArgs) -> anyhow::Result<()> {
1065 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1066 workspace_not_initialised_error(
1067 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1068 )
1069 })?;
1070
1071 let binding_id = args.binding;
1072 let op = args.operation;
1073
1074 let (mem, stem) = binding_id
1078 .split_once('/')
1079 .filter(|(m, n)| !m.is_empty() && !n.is_empty())
1080 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
1081 .ok_or_else(|| invalid_binding_id(&binding_id))?;
1082 let mem = mem.to_string();
1083 let stem = stem.to_string();
1084
1085 let binding_path = root
1089 .join(".memstead")
1090 .join("projections")
1091 .join(&mem)
1092 .join(format!("{stem}.json"));
1093 if !binding_path.exists() {
1094 return Err(CliError::new(
1095 ExitKind::NotFound,
1096 "PROJECTION_NOT_FOUND",
1097 format!(
1098 "no binding `{binding_id}` at .memstead/projections/{mem}/{stem}.json — \
1099 scaffold one with `projection init` or migrate a legacy workspace with \
1100 `projection migrate`"
1101 ),
1102 )
1103 .with_details(json!({ "binding": binding_id }))
1104 .into());
1105 }
1106 let mut binding =
1107 read_binding(&root, &mem, &stem).map_err(|e| enable_failed(&binding_id, e))?;
1108
1109 let already = match op {
1113 EnableOperationArg::Build => binding.operations.build.is_some(),
1114 EnableOperationArg::Sync => binding.operations.sync.is_some(),
1115 EnableOperationArg::Verify => binding.operations.verify.is_some(),
1116 };
1117 if already {
1118 return Err(CliError::new(
1119 ExitKind::Validation,
1120 "PROJECTION_OP_ALREADY_ENABLED",
1121 format!(
1122 "operation `{}` is already enabled on binding `{binding_id}` — nothing to do",
1123 op.name()
1124 ),
1125 )
1126 .with_details(json!({ "binding": binding_id, "operation": op.name() }))
1127 .into());
1128 }
1129
1130 let batch_size = binding
1134 .operations
1135 .build
1136 .as_ref()
1137 .map_or(20, |b| b.batch_size);
1138 match op {
1139 EnableOperationArg::Build => {
1140 binding.operations.build = Some(BuildOperation {
1141 mode: BuildMode::Discovery,
1142 trigger: IngestTrigger::Loop,
1143 batch_size,
1144 post_actions: None,
1145 });
1146 }
1147 EnableOperationArg::Sync => {
1148 binding.operations.sync = Some(SyncOperation {
1149 trigger: IngestTrigger::Manual,
1150 batch_size,
1151 });
1152 }
1153 EnableOperationArg::Verify => {
1154 binding.operations.verify = Some(VerifyOperation {
1155 trigger: IngestTrigger::Manual,
1156 batch_size,
1157 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1158 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1159 });
1160 }
1161 }
1162
1163 let configs = load_legacy_pipeline_configs(&root).map_err(|e| enable_failed(&binding_id, e))?;
1170 let resolved = resolve_binding(&configs, &binding_id, &binding).map_err(|e| {
1171 CliError::new(
1172 ExitKind::Generic,
1173 "PROJECTION_ENABLE_FAILED",
1174 format!("could not resolve binding `{binding_id}` for validation: {e}"),
1175 )
1176 .with_details(json!({ "binding": binding_id, "error": e.to_string() }))
1177 })?;
1178 if let Err(refusals) = validate_binding(&resolved)
1179 && let Some(err) = refusals.iter().find(|r| {
1180 matches!(
1181 r,
1182 CapabilityError::OperationOutOfScope { operation, .. } if *operation == op.name()
1183 )
1184 })
1185 {
1186 return Err(CliError::new(
1187 ExitKind::Validation,
1188 "PROJECTION_CAPABILITY_UNSUPPORTED",
1189 err.to_string(),
1190 )
1191 .with_details(json!({ "binding": binding_id, "operation": op.name() }))
1192 .into());
1193 }
1194
1195 write_binding(&root, &mem, &stem, &binding).map_err(|e| enable_failed(&binding_id, e))?;
1196
1197 let mut operations: Vec<&str> = Vec::new();
1198 if binding.operations.build.is_some() {
1199 operations.push("build");
1200 }
1201 if binding.operations.sync.is_some() {
1202 operations.push("sync");
1203 }
1204 if binding.operations.verify.is_some() {
1205 operations.push("verify");
1206 }
1207
1208 if ctx.json {
1209 print_json(&json!({
1210 "binding": binding_id,
1211 "enabled": op.name(),
1212 "operations": operations,
1213 }))?;
1214 } else {
1215 print_markdown(&format!(
1216 "# Projection enable\n\nEnabled `{}` on binding `{binding_id}`.\n\nOperations: {}\n",
1217 op.name(),
1218 operations.join(", ")
1219 ));
1220 }
1221 Ok(())
1222}
1223
1224fn map_resolve_err(binding_id: &str, err: ResolveError) -> CliError {
1229 let message = err.to_string();
1230 let mapped = match err {
1231 ResolveError::FacetNotFound { .. } => {
1232 CliError::new(ExitKind::NotFound, "PROJECTION_FACET_NOT_FOUND", message)
1233 }
1234 ResolveError::MediumNotFound { .. } => {
1235 CliError::new(ExitKind::NotFound, "PROJECTION_MEDIUM_NOT_FOUND", message)
1236 }
1237 ResolveError::MalformedProjectionRef { .. } => {
1238 CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1239 }
1240 _ => CliError::new(ExitKind::Generic, "PROJECTION_ADVANCE_FAILED", message),
1241 };
1242 mapped.with_details(json!({ "binding": binding_id }))
1243}
1244
1245fn map_advance_err(binding_id: &str, err: AdvanceError) -> CliError {
1250 let message = err.to_string();
1251 match &err {
1252 AdvanceError::MalformedId(_) => {
1253 CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1254 .with_details(json!({ "binding": binding_id }))
1255 }
1256 AdvanceError::UnknownArtifact { artifacts, .. } => CliError::new(
1257 ExitKind::Validation,
1258 "PROJECTION_ADVANCE_UNKNOWN_ARTIFACT",
1259 message,
1260 )
1261 .with_details(json!({ "binding": binding_id, "unknown_artifacts": artifacts })),
1262 AdvanceError::Store(_) | AdvanceError::Engine(_) => {
1263 CliError::new(ExitKind::Generic, "PROJECTION_ADVANCE_FAILED", message)
1264 .with_details(json!({ "binding": binding_id }))
1265 }
1266 }
1267}
1268
1269fn advance(ctx: &CliContext, args: AdvanceArgs) -> anyhow::Result<()> {
1270 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1271 workspace_not_initialised_error(
1272 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1273 )
1274 })?;
1275
1276 let binding_id = args.binding;
1277
1278 let dispositions: std::collections::BTreeMap<String, DispositionInput> =
1281 serde_json::from_str(&args.dispositions).map_err(|e| {
1282 CliError::new(
1283 ExitKind::Validation,
1284 "PROJECTION_INVALID_DISPOSITIONS",
1285 format!(
1286 "--dispositions must be a JSON object mapping artifact id → either a \
1287 disposition string (e.g. \"worked\") or an object \
1288 {{\"disposition\": \"excluded\", \"rationale\": \"...\"}}: {e}"
1289 ),
1290 )
1291 .with_details(json!({ "error": e.to_string() }))
1292 })?;
1293
1294 let configs = load_pipeline_configs(&root).map_err(|e| {
1296 CliError::new(
1297 ExitKind::Generic,
1298 "PROJECTION_ADVANCE_FAILED",
1299 format!("could not load pipeline config: {e}"),
1300 )
1301 .with_details(json!({ "error": e.to_string() }))
1302 })?;
1303 let record = configs
1304 .bindings
1305 .iter()
1306 .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1307 .ok_or_else(|| {
1308 CliError::new(
1309 ExitKind::NotFound,
1310 "PROJECTION_NOT_FOUND",
1311 format!(
1312 "no binding `{binding_id}` in this workspace — scaffold one with \
1313 `projection init` or migrate a legacy workspace with `projection migrate`"
1314 ),
1315 )
1316 .with_details(json!({ "binding": binding_id }))
1317 })?;
1318
1319 if record.config.operations.sync.is_none() {
1323 return Err(CliError::new(
1324 ExitKind::Validation,
1325 "PROJECTION_SYNC_NOT_ENABLED",
1326 format!(
1327 "binding `{binding_id}` has no sync operation — enable it with \
1328 `memstead projection enable sync {binding_id}`"
1329 ),
1330 )
1331 .with_details(json!({ "binding": binding_id }))
1332 .into());
1333 }
1334
1335 let resolved = resolve_binding_run(&configs, &binding_id, &record.config)
1336 .map_err(|e| map_resolve_err(&binding_id, e))?;
1337
1338 let mut cli_engine = ctx.cli_engine_at(&root)?;
1341 let engine = match &mut cli_engine {
1342 #[cfg(feature = "mem-repo")]
1343 CliEngine::MemRepo(e) => e,
1344 CliEngine::Filesystem(e) => e,
1345 };
1346
1347 let outcome = advance_baseline(engine, &root, &resolved, &dispositions)
1348 .map_err(|e| map_advance_err(&binding_id, e))?;
1349
1350 if ctx.json {
1351 print_json(&json!({
1352 "binding": outcome.binding,
1353 "completed": outcome.completed,
1354 "disposed": outcome.disposed,
1355 "pending": outcome.pending,
1356 "remainder": outcome.remainder,
1357 "tokens_written": outcome.tokens_written,
1358 "warnings": outcome.warnings,
1359 }))?;
1360 } else {
1361 let mut out = format!(
1362 "# Projection advance\n\nBinding `{}`: {} artifact(s) disposed, {} remaining.\n",
1363 outcome.binding, outcome.disposed, outcome.pending
1364 );
1365 if outcome.completed {
1366 out.push_str("\nEvery presented artifact is disposed — the sync baseline advanced.\n");
1367 if !outcome.tokens_written.is_empty() {
1368 out.push_str("\nBaseline tokens written:\n");
1369 for key in &outcome.tokens_written {
1370 out.push_str(&format!("- `{key}`\n"));
1371 }
1372 }
1373 } else {
1374 out.push_str(
1375 "\nRemainder still pending — re-run `projection advance` after judging the rest \
1376 (a brief re-render shows what is left).\n",
1377 );
1378 }
1379 if !outcome.warnings.is_empty() {
1380 out.push_str("\n## Warnings\n\n");
1381 for w in &outcome.warnings {
1382 out.push_str(&format!("- {w}\n"));
1383 }
1384 }
1385 print_markdown(&out);
1386 }
1387 Ok(())
1388}
1389
1390fn map_exclude_err(binding_id: &str, err: ExcludeError) -> CliError {
1395 let message = err.to_string();
1396 match &err {
1397 ExcludeError::MalformedId(_) => {
1398 CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1399 .with_details(json!({ "binding": binding_id }))
1400 }
1401 ExcludeError::NotSourceMember { artifacts, .. } => CliError::new(
1402 ExitKind::Validation,
1403 "PROJECTION_EXCLUDE_NOT_SOURCE_MEMBER",
1404 message,
1405 )
1406 .with_details(json!({ "binding": binding_id, "not_source_members": artifacts })),
1407 ExcludeError::Store(_) => {
1408 CliError::new(ExitKind::Generic, "PROJECTION_EXCLUDE_FAILED", message)
1409 .with_details(json!({ "binding": binding_id }))
1410 }
1411 }
1412}
1413
1414fn exclude(ctx: &CliContext, args: ExcludeArgs) -> anyhow::Result<()> {
1415 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1416 workspace_not_initialised_error(
1417 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1418 )
1419 })?;
1420
1421 let binding_id = args.binding;
1422
1423 let exclusions: std::collections::BTreeMap<String, String> =
1426 serde_json::from_str(&args.exclusions).map_err(|e| {
1427 CliError::new(
1428 ExitKind::Validation,
1429 "PROJECTION_INVALID_EXCLUSIONS",
1430 format!(
1431 "--exclusions must be a JSON object mapping in-scope artifact id → \
1432 rationale string: {e}"
1433 ),
1434 )
1435 .with_details(json!({ "error": e.to_string() }))
1436 })?;
1437
1438 let configs = load_pipeline_configs(&root).map_err(|e| {
1440 CliError::new(
1441 ExitKind::Generic,
1442 "PROJECTION_EXCLUDE_FAILED",
1443 format!("could not load pipeline config: {e}"),
1444 )
1445 .with_details(json!({ "error": e.to_string() }))
1446 })?;
1447 let record = configs
1448 .bindings
1449 .iter()
1450 .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1451 .ok_or_else(|| {
1452 CliError::new(
1453 ExitKind::NotFound,
1454 "PROJECTION_NOT_FOUND",
1455 format!(
1456 "no binding `{binding_id}` in this workspace — scaffold one with \
1457 `projection init` or migrate a legacy workspace with `projection migrate`"
1458 ),
1459 )
1460 .with_details(json!({ "binding": binding_id }))
1461 })?;
1462
1463 let resolved = resolve_binding_run(&configs, &binding_id, &record.config)
1464 .map_err(|e| map_resolve_err(&binding_id, e))?;
1465
1466 let outcome = record_exclusions(&root, &resolved, &exclusions)
1467 .map_err(|e| map_exclude_err(&binding_id, e))?;
1468
1469 if ctx.json {
1470 print_json(&json!({
1471 "binding": outcome.binding,
1472 "excluded": outcome.excluded,
1473 "added": outcome.added,
1474 }))?;
1475 } else {
1476 print_markdown(&format!(
1477 "# Projection exclude\n\nBinding `{}`: {} artifact(s) newly excluded, \
1478 {} in the ledger.\n",
1479 outcome.binding, outcome.added, outcome.excluded
1480 ));
1481 }
1482 Ok(())
1483}
1484
1485fn render_full_resync_note(decision: &FullResyncDecision) -> String {
1491 match decision {
1492 FullResyncDecision::Disabled => String::new(),
1493 FullResyncDecision::NotDue { .. } => String::new(),
1494 FullResyncDecision::Due {
1495 walked_facets,
1496 refused,
1497 ..
1498 } => {
1499 let mut s = String::from("> **Scheduled full resync (D3)** — ");
1500 if walked_facets.is_empty() {
1501 s.push_str("no enumerable facet to walk this run.");
1502 } else {
1503 s.push_str(&format!(
1504 "full-enumeration coverage walk fired for: {}.",
1505 walked_facets.join(", ")
1506 ));
1507 }
1508 for r in refused {
1509 s.push_str(&format!(
1510 "\n> **Refused (non-enumerable):** `{}` ({}) — {}",
1511 r.facet, r.medium_type, r.reason
1512 ));
1513 }
1514 s.push_str("\n\n");
1515 s
1516 }
1517 }
1518}
1519
1520fn verify(ctx: &CliContext, args: VerifyArgs) -> anyhow::Result<()> {
1523 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1524 workspace_not_initialised_error(
1525 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1526 )
1527 })?;
1528
1529 let binding_id = args.binding;
1530
1531 let configs = load_pipeline_configs(&root).map_err(|e| {
1532 CliError::new(
1533 ExitKind::Generic,
1534 "PROJECTION_VERIFY_FAILED",
1535 format!("could not load pipeline config: {e}"),
1536 )
1537 .with_details(json!({ "error": e.to_string() }))
1538 })?;
1539 let record = configs
1540 .bindings
1541 .iter()
1542 .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1543 .ok_or_else(|| {
1544 CliError::new(
1545 ExitKind::NotFound,
1546 "PROJECTION_NOT_FOUND",
1547 format!(
1548 "no binding `{binding_id}` in this workspace — scaffold one with \
1549 `projection init` or migrate a legacy workspace with `projection migrate`"
1550 ),
1551 )
1552 .with_details(json!({ "binding": binding_id }))
1553 })?;
1554
1555 let resolved = resolve_binding_run(&configs, &binding_id, &record.config)
1556 .map_err(|e| map_resolve_err(&binding_id, e))?;
1557
1558 let cli_engine = ctx.cli_engine_at(&root)?;
1561 let engine = match &cli_engine {
1562 #[cfg(feature = "mem-repo")]
1563 CliEngine::MemRepo(e) => e,
1564 CliEngine::Filesystem(e) => e,
1565 };
1566
1567 let outcome = verify_binding(engine, &root, &record.config, &resolved).map_err(|e| {
1568 CliError::new(
1569 ExitKind::Generic,
1570 "PROJECTION_VERIFY_FAILED",
1571 format!("verify failed for `{binding_id}`: {e}"),
1572 )
1573 .with_details(json!({ "binding": binding_id, "error": e.to_string() }))
1574 })?;
1575
1576 let budget = args.budget.unwrap_or(DEFAULT_REPORT_BUDGET);
1579 let report = compute_fidelity_report(engine, &root, &record.config, &resolved, &outcome.key);
1580 let rendered = render_fidelity_report(&report, budget, &args.include);
1581
1582 if ctx.json {
1583 print_json(&json!({
1584 "binding": outcome.binding,
1585 "key": {
1586 "binding_hash": outcome.key.binding_hash,
1587 "source_head": outcome.key.source_head,
1588 },
1589 "recorded": outcome.recorded,
1590 "superseded": outcome.superseded,
1591 "backlog": outcome.backlog,
1592 "full_resync": outcome.full_resync,
1596 "report": report,
1597 "report_mode": rendered.mode,
1598 "report_markdown": rendered.markdown,
1599 }))?;
1600 } else {
1601 print_markdown(&format!(
1605 "{}{}",
1606 render_full_resync_note(&outcome.full_resync),
1607 rendered.markdown
1608 ));
1609 }
1610 Ok(())
1611}