1use std::collections::BTreeMap;
17use std::path::Path;
18
19use serde::Serialize;
20
21use crate::Engine;
22use crate::binding::CoverageSemantics;
23use crate::ingest::advance::read_advance_store;
24use crate::ingest::cursor::source_moved;
25use crate::ingest::findings::{FindingClass, current_findings};
26use crate::ingest::render::mem_predates_binding;
27use crate::ingest::resolve::{
28 ChangeStrategy, ResolvedIngest, ResolvedSource, resolve_binding_run, resolve_change_strategy,
29};
30use crate::pipeline_store::load_pipeline_configs;
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct FacetState {
37 pub synced: Option<String>,
40 pub verified: Option<String>,
42 pub signal: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51pub struct AdvanceCounts {
52 pub pending: usize,
54 pub disposed: usize,
56}
57
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
63pub struct FindingCounts {
64 pub unresolvable: usize,
66 pub drifted: usize,
68 pub uncovered: usize,
70 pub queued: usize,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct ProjectionStatus {
80 pub binding: String,
82 pub destination_mem: String,
84 pub operations: Vec<String>,
87 pub state: BTreeMap<String, FacetState>,
89 pub advance: AdvanceCounts,
91 pub verdict: RollupVerdict,
96 pub source_moved: bool,
99 pub findings: FindingCounts,
101}
102
103struct BindingResolution {
106 onboarding: bool,
107 source_moved: bool,
108 findings: FindingCounts,
109 has_action: bool,
112}
113
114impl BindingResolution {
115 fn verdict(&self) -> RollupVerdict {
116 if self.onboarding {
117 RollupVerdict::Onboarding
118 } else if self.has_action {
119 RollupVerdict::ActionNeeded
120 } else {
121 RollupVerdict::Clean
122 }
123 }
124}
125
126fn resolve_binding_status(
127 engine: &Engine,
128 workspace_root: &Path,
129 binding: &crate::binding::Binding,
130 resolved: &ResolvedIngest,
131) -> BindingResolution {
132 if mem_predates_binding(engine, resolved) {
133 return BindingResolution {
134 onboarding: true,
135 source_moved: false,
136 findings: FindingCounts::default(),
137 has_action: false,
138 };
139 }
140 let source_moved = source_moved(engine, resolved, workspace_root);
141 let mut findings = FindingCounts::default();
142 if let Ok((_key, list)) = current_findings(engine, workspace_root, binding, resolved) {
143 for f in &list {
144 match f.class {
145 FindingClass::UnresolvableAnchor => findings.unresolvable += 1,
146 FindingClass::Drifted | FindingClass::Wrong => findings.drifted += 1,
147 FindingClass::Uncovered => findings.uncovered += 1,
148 FindingClass::QueuedForAdjudication => findings.queued += 1,
149 }
150 }
151 }
152 let uncovered_counts = findings.uncovered > 0
153 && matches!(
154 crate::binding::effective_coverage_semantics(binding).value,
155 CoverageSemantics::Exhaustive
156 );
157 let has_action = source_moved
158 || findings.unresolvable > 0
159 || findings.drifted > 0
160 || uncovered_counts
161 || findings.queued > 0;
162 BindingResolution {
163 onboarding: false,
164 source_moved,
165 findings,
166 has_action,
167 }
168}
169
170fn signal_of(strategy: ChangeStrategy) -> &'static str {
173 match strategy {
174 ChangeStrategy::None => "none",
175 ChangeStrategy::Git => "git",
176 ChangeStrategy::Mtime => "mtime",
177 ChangeStrategy::Graph => "graph",
178 }
179}
180
181pub fn projection_status(engine: &Engine, workspace_root: &Path) -> Vec<ProjectionStatus> {
191 let Ok(configs) = load_pipeline_configs(workspace_root) else {
192 return Vec::new();
193 };
194
195 let mut out = Vec::with_capacity(configs.bindings.len());
196 for record in &configs.bindings {
197 let binding_id = format!("{}/{}", record.mem, record.name);
198 let binding = &record.config;
199
200 let mut operations = Vec::new();
201 if binding.operations.build.is_some() {
202 operations.push("build".to_string());
203 }
204 if binding.operations.sync.is_some() {
205 operations.push("sync".to_string());
206 }
207 if binding.operations.verify.is_some() {
208 operations.push("verify".to_string());
209 }
210
211 let sync_state = engine
213 .mem_config_for(&binding.destination_mem)
214 .map(|c| c.sync_state.clone())
215 .unwrap_or_default();
216
217 let mut state = BTreeMap::new();
220 let mut resolution: Option<BindingResolution> = None;
221 if let Ok(resolved) = resolve_binding_run(&binding_id, binding) {
222 resolution = Some(resolve_binding_status(
223 engine,
224 workspace_root,
225 binding,
226 &resolved,
227 ));
228 for source in &resolved.sources {
229 let (facet, signal) = match source {
230 ResolvedSource::Primary(p) => (
231 p.name.clone(),
232 signal_of(resolve_change_strategy(p, workspace_root)).to_string(),
233 ),
234 ResolvedSource::Reference { mem } => (mem.clone(), "graph".to_string()),
237 };
238 let synced = sync_state
239 .get(&format!("{binding_id}/{facet}#synced"))
240 .cloned();
241 let verified = sync_state
242 .get(&format!("{binding_id}/{facet}#verified"))
243 .cloned();
244 state.insert(
245 facet,
246 FacetState {
247 synced,
248 verified,
249 signal,
250 },
251 );
252 }
253 }
254
255 let advance = match read_advance_store(workspace_root, &record.mem, &record.name) {
257 Ok(Some(s)) => AdvanceCounts {
258 pending: s.pending(),
259 disposed: s.disposed(),
260 },
261 _ => AdvanceCounts {
262 pending: 0,
263 disposed: 0,
264 },
265 };
266
267 let (verdict, source_moved, findings) = match &resolution {
268 Some(r) => (r.verdict(), r.source_moved, r.findings),
269 None => (RollupVerdict::Clean, false, FindingCounts::default()),
270 };
271 out.push(ProjectionStatus {
272 binding: binding_id,
273 destination_mem: binding.destination_mem.clone(),
274 operations,
275 state,
276 advance,
277 verdict,
278 source_moved,
279 findings,
280 });
281 }
282 out
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
293#[serde(rename_all = "kebab-case")]
294pub enum RollupVerdict {
295 Clean,
298 NothingDeclared,
305 Onboarding,
309 ActionNeeded,
313}
314
315impl RollupVerdict {
316 pub fn as_wire(&self) -> &'static str {
318 match self {
319 RollupVerdict::Clean => "clean",
320 RollupVerdict::NothingDeclared => "nothing-declared",
321 RollupVerdict::Onboarding => "onboarding",
322 RollupVerdict::ActionNeeded => "action-needed",
323 }
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
332pub struct Rollup {
333 pub verdict: RollupVerdict,
335 pub subject: String,
340 pub headline: String,
342 pub actions: Vec<String>,
345}
346
347impl Default for Rollup {
348 fn default() -> Self {
349 Rollup {
350 verdict: RollupVerdict::NothingDeclared,
351 subject: "no projection bindings".to_string(),
352 headline: "No projection bindings are declared, so this says nothing about the \
353 workspace beyond that."
354 .to_string(),
355 actions: Vec::new(),
356 }
357 }
358}
359
360struct Candidate {
363 severity: u8,
364 text: String,
365}
366
367pub fn projection_rollup(engine: &Engine, workspace_root: &Path) -> Rollup {
382 let Ok(configs) = load_pipeline_configs(workspace_root) else {
383 return Rollup::default();
384 };
385 if configs.bindings.is_empty() {
386 return Rollup::default();
387 }
388 let total = configs.bindings.len();
389
390 let mut candidates: Vec<Candidate> = Vec::new();
391 let mut action_bindings = 0usize;
392 let mut onboarding_bindings = 0usize;
393
394 for record in &configs.bindings {
395 let binding_id = format!("{}/{}", record.mem, record.name);
396 let binding = &record.config;
397 let Ok(resolved) = resolve_binding_run(&binding_id, binding) else {
398 continue;
399 };
400
401 let resolution = resolve_binding_status(engine, workspace_root, binding, &resolved);
403
404 if resolution.onboarding {
408 onboarding_bindings += 1;
409 candidates.push(Candidate {
410 severity: 1,
411 text: format!(
412 "`{binding_id}` predates its binding — 0% anchored is expected; run \
413 `memstead projection brief {binding_id} --sync` for a first-sync backfill"
414 ),
415 });
416 continue;
417 }
418
419 if resolution.source_moved {
421 candidates.push(Candidate {
422 severity: 4,
423 text: format!(
424 "`{binding_id}` source moved since the last sync — run `memstead projection \
425 sync {binding_id}`"
426 ),
427 });
428 }
429
430 let FindingCounts {
431 unresolvable,
432 drifted,
433 uncovered,
434 queued,
435 } = resolution.findings;
436 if unresolvable > 0 {
437 candidates.push(Candidate {
438 severity: 6,
439 text: format!(
440 "{unresolvable} entit{} in `{binding_id}` describe source that no longer \
441 exists — run `memstead projection brief {binding_id} --sync`",
442 if unresolvable == 1 { "y" } else { "ies" }
443 ),
444 });
445 }
446 if drifted > 0 {
447 candidates.push(Candidate {
448 severity: 5,
449 text: format!(
450 "{drifted} anchor(s) in `{binding_id}` drifted from their source — run \
451 `memstead projection brief {binding_id} --sync`"
452 ),
453 });
454 }
455 if uncovered > 0
460 && matches!(
461 crate::binding::effective_coverage_semantics(binding).value,
462 CoverageSemantics::Exhaustive
463 )
464 {
465 candidates.push(Candidate {
466 severity: 3,
467 text: format!(
468 "{uncovered} in-scope source artifact(s) in `{binding_id}` carry no entity \
469 — run `memstead projection verify {binding_id}`, then sync"
470 ),
471 });
472 }
473 if queued > 0 {
474 candidates.push(Candidate {
475 severity: 2,
476 text: format!(
477 "{queued} finding(s) in `{binding_id}` queued for adjudication — run \
478 `memstead projection verify {binding_id}`"
479 ),
480 });
481 }
482
483 if resolution.has_action {
484 action_bindings += 1;
485 }
486 }
487
488 candidates.sort_by_key(|c| std::cmp::Reverse(c.severity));
491 let actions: Vec<String> = candidates.into_iter().take(3).map(|c| c.text).collect();
492
493 let verdict = if action_bindings > 0 {
494 RollupVerdict::ActionNeeded
495 } else if onboarding_bindings > 0 {
496 RollupVerdict::Onboarding
497 } else {
498 RollupVerdict::Clean
499 };
500
501 let headline = match verdict {
502 RollupVerdict::ActionNeeded => format!(
503 "Action needed — {action_bindings} of {total} projection(s) have open findings or a \
504 moved source."
505 ),
506 RollupVerdict::Onboarding => format!(
507 "Onboarding — {onboarding_bindings} of {total} projection(s) predate their binding; a \
508 first-sync backfill is expected, not a defect."
509 ),
510 RollupVerdict::Clean => {
511 format!("All {total} projection(s) are in sync — no open findings, no moved sources.")
512 }
513 RollupVerdict::NothingDeclared => "No projection bindings were examined.".to_string(),
516 };
517
518 Rollup {
519 verdict,
520 subject: format!("{total} projection binding(s)"),
521 headline,
522 actions,
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use crate::binding::{
530 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, SyncOperation,
531 };
532 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
533 use crate::pipeline_store::write_binding;
534 use crate::storage::FilesystemMemWriter;
535 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
536 use tempfile::TempDir;
537
538 #[test]
543 fn projection_status_reports_operations_signal_and_baseline() {
544 let tmp = TempDir::new().unwrap();
545 let root = tmp.path();
546 std::fs::create_dir_all(root.join(".memstead")).unwrap();
548 std::fs::write(
549 root.join(".memstead").join("config.json"),
550 br#"{"format":1,"schema":"default@1.0.0"}"#,
551 )
552 .unwrap();
553 std::fs::write(
554 root.join(".memstead").join("workspace.toml"),
555 "[workspace]\n",
556 )
557 .unwrap();
558 let out = std::process::Command::new("git")
560 .args(["init", "-q"])
561 .current_dir(root)
562 .output()
563 .unwrap();
564 assert!(out.status.success());
565
566 write_binding(
568 root,
569 "engine",
570 "graph",
571 &Binding {
572 version: BINDING_VERSION,
573 intent: None,
574 sources: vec![crate::pipeline::Source {
575 name: "graph".to_string(),
576 medium_type: MediumType::Codebase,
577 pointer: String::new(),
578 change_detection: Some("git".to_string()),
579 scope: vec![PatternEntry {
580 path: "**/*.rs".to_string(),
581 mode: PatternMode::Allow,
582 }],
583 engagement: None,
584 preparation: None,
585 }],
586 reference_mems: Vec::new(),
587 destination_mem: "engine".to_string(),
588 deny_paths: Vec::new(),
589 coverage_semantics: None,
590 rules: None,
591 prune: None,
592 operations: Operations {
593 build: Some(BuildOperation {
594 mode: BuildMode::Discovery,
595 trigger: IngestTrigger::Loop,
596 batch_size: 20,
597 post_actions: None,
598 }),
599 sync: Some(SyncOperation {
600 trigger: IngestTrigger::Manual,
601 batch_size: 20,
602 }),
603 verify: None,
604 },
605 },
606 )
607 .unwrap();
608
609 let mount = Mount {
610 mem: "engine".to_string(),
611 schema: Some("default@1.0.0".parse().unwrap()),
612 storage: MountStorage::Folder {
613 path: root.to_path_buf(),
614 },
615 capability: MountCapability::Write,
616 lifecycle: MountLifecycle::Eager,
617 cross_linkable: false,
618 migration_target: None,
619 };
620 let mut engine = Engine::from_mounts(vec![(
621 mount,
622 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
623 as Box<dyn crate::backend::MemBackend>,
624 )])
625 .unwrap();
626 engine
627 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
628 .unwrap();
629
630 let ps = projection_status(&engine, root);
631 assert_eq!(ps.len(), 1);
632 let p = &ps[0];
633 assert_eq!(p.binding, "engine/graph");
634 assert_eq!(p.destination_mem, "engine");
635 assert_eq!(p.operations, vec!["build".to_string(), "sync".to_string()]);
636 let facet = p.state.get("graph").expect("the source facet's state");
637 assert_eq!(facet.signal, "git");
638 assert_eq!(facet.synced.as_deref(), Some("deadbeef"));
639 assert_eq!(facet.verified, None);
640 assert_eq!(
641 p.advance,
642 AdvanceCounts {
643 pending: 0,
644 disposed: 0
645 }
646 );
647 }
648
649 #[test]
652 fn projection_status_empty_without_bindings() {
653 let tmp = TempDir::new().unwrap();
654 let root = tmp.path();
655 std::fs::create_dir_all(root.join(".memstead")).unwrap();
656 std::fs::write(
657 root.join(".memstead").join("config.json"),
658 br#"{"format":1,"schema":"default@1.0.0"}"#,
659 )
660 .unwrap();
661 let mount = Mount {
662 mem: "engine".to_string(),
663 schema: Some("default@1.0.0".parse().unwrap()),
664 storage: MountStorage::Folder {
665 path: root.to_path_buf(),
666 },
667 capability: MountCapability::Write,
668 lifecycle: MountLifecycle::Eager,
669 cross_linkable: false,
670 migration_target: None,
671 };
672 let engine = Engine::from_mounts(vec![(
673 mount,
674 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
675 as Box<dyn crate::backend::MemBackend>,
676 )])
677 .unwrap();
678 assert!(projection_status(&engine, root).is_empty());
679 }
680
681 fn one_binding_workspace(tmp: &TempDir) -> Engine {
688 let root = tmp.path();
689 std::fs::create_dir_all(root.join(".memstead")).unwrap();
690 std::fs::write(
691 root.join(".memstead").join("config.json"),
692 br#"{"format":1,"schema":"default@1.0.0"}"#,
693 )
694 .unwrap();
695 std::fs::write(
696 root.join(".memstead").join("workspace.toml"),
697 "[workspace]\n",
698 )
699 .unwrap();
700 let out = std::process::Command::new("git")
701 .args(["init", "-q"])
702 .current_dir(root)
703 .output()
704 .unwrap();
705 assert!(out.status.success());
706
707 write_binding(
708 root,
709 "engine",
710 "graph",
711 &Binding {
712 version: BINDING_VERSION,
713 intent: None,
714 sources: vec![crate::pipeline::Source {
715 name: "graph".to_string(),
716 medium_type: MediumType::Codebase,
717 pointer: String::new(),
718 change_detection: Some("git".to_string()),
719 scope: vec![PatternEntry {
720 path: "**/*.rs".to_string(),
721 mode: PatternMode::Allow,
722 }],
723 engagement: None,
724 preparation: None,
725 }],
726 reference_mems: Vec::new(),
727 destination_mem: "engine".to_string(),
728 deny_paths: Vec::new(),
729 coverage_semantics: None,
730 rules: None,
731 prune: None,
732 operations: Operations {
733 build: Some(BuildOperation {
734 mode: BuildMode::Discovery,
735 trigger: IngestTrigger::Loop,
736 batch_size: 20,
737 post_actions: None,
738 }),
739 sync: Some(SyncOperation {
740 trigger: IngestTrigger::Manual,
741 batch_size: 20,
742 }),
743 verify: None,
744 },
745 },
746 )
747 .unwrap();
748
749 let mount = Mount {
750 mem: "engine".to_string(),
751 schema: Some("default@1.0.0".parse().unwrap()),
752 storage: MountStorage::Folder {
753 path: root.to_path_buf(),
754 },
755 capability: MountCapability::Write,
756 lifecycle: MountLifecycle::Eager,
757 cross_linkable: false,
758 migration_target: None,
759 };
760 Engine::from_mounts(vec![(
761 mount,
762 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
763 as Box<dyn crate::backend::MemBackend>,
764 )])
765 .unwrap()
766 }
767
768 #[test]
777 fn projection_status_carries_the_per_binding_verdict() {
778 let tmp = TempDir::new().unwrap();
779 let engine = one_binding_workspace(&tmp);
780 let statuses = projection_status(&engine, tmp.path());
781 assert_eq!(statuses.len(), 1);
782 let s = &statuses[0];
783 assert_eq!(s.verdict, RollupVerdict::Onboarding);
784 assert!(!s.source_moved, "onboarding skips the freshness scan");
785 assert_eq!(s.findings, FindingCounts::default());
786 let json = serde_json::to_value(s).unwrap();
788 assert_eq!(json["verdict"], "onboarding");
789 assert_eq!(json["source_moved"], false);
790 assert_eq!(json["findings"]["unresolvable"], 0);
791 let rollup = projection_rollup(&engine, tmp.path());
793 assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
794 }
795
796 #[test]
797 fn rollup_adopt_binding_is_onboarding_not_action_needed() {
798 let tmp = TempDir::new().unwrap();
799 let engine = one_binding_workspace(&tmp);
800 let rollup = projection_rollup(&engine, tmp.path());
801 assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
802 assert_ne!(
803 rollup.verdict,
804 RollupVerdict::ActionNeeded,
805 "pre-binding history alone must never be a red verdict"
806 );
807 assert!(
808 rollup
809 .actions
810 .iter()
811 .any(|a| a.contains("predates its binding")),
812 "the onboarding action is surfaced: {:?}",
813 rollup.actions
814 );
815 assert!(rollup.headline.contains("Onboarding"));
816 }
817
818 #[test]
825 fn rollup_without_bindings_asserts_nothing_rather_than_clean() {
826 let tmp = TempDir::new().unwrap();
827 let root = tmp.path();
828 std::fs::create_dir_all(root.join(".memstead")).unwrap();
829 std::fs::write(
830 root.join(".memstead").join("config.json"),
831 br#"{"format":1,"schema":"default@1.0.0"}"#,
832 )
833 .unwrap();
834 let mount = Mount {
835 mem: "engine".to_string(),
836 schema: Some("default@1.0.0".parse().unwrap()),
837 storage: MountStorage::Folder {
838 path: root.to_path_buf(),
839 },
840 capability: MountCapability::Write,
841 lifecycle: MountLifecycle::Eager,
842 cross_linkable: false,
843 migration_target: None,
844 };
845 let engine = Engine::from_mounts(vec![(
846 mount,
847 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
848 as Box<dyn crate::backend::MemBackend>,
849 )])
850 .unwrap();
851 let rollup = projection_rollup(&engine, root);
852 assert_eq!(rollup.verdict, RollupVerdict::NothingDeclared);
853 assert_eq!(rollup.subject, "no projection bindings");
854 assert!(
855 rollup.headline.contains("says nothing about the workspace"),
856 "the headline must not read as an all-clear: {}",
857 rollup.headline
858 );
859 assert!(rollup.actions.is_empty());
860 assert!(rollup.headline.contains("No projection bindings"));
861 }
862}