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 uncovered_counts: bool,
116}
117
118impl BindingResolution {
119 fn verdict(&self) -> RollupVerdict {
120 if self.onboarding {
121 RollupVerdict::Onboarding
122 } else if self.has_action {
123 RollupVerdict::ActionNeeded
124 } else {
125 RollupVerdict::Clean
126 }
127 }
128}
129
130#[cfg(test)]
134thread_local! {
135 pub(crate) static BINDING_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
136}
137
138fn resolve_binding_status(
139 engine: &Engine,
140 workspace_root: &Path,
141 binding: &crate::binding::Binding,
142 resolved: &ResolvedIngest,
143) -> BindingResolution {
144 #[cfg(test)]
145 BINDING_SCANS.with(|c| c.set(c.get() + 1));
146 if mem_predates_binding(engine, resolved) {
147 return BindingResolution {
148 onboarding: true,
149 source_moved: false,
150 findings: FindingCounts::default(),
151 has_action: false,
152 uncovered_counts: false,
153 };
154 }
155 let source_moved = source_moved(engine, resolved, workspace_root);
156 let mut findings = FindingCounts::default();
157 if let Ok((_key, list)) = current_findings(engine, workspace_root, binding, resolved) {
158 for f in &list {
159 match f.class {
160 FindingClass::UnresolvableAnchor => findings.unresolvable += 1,
161 FindingClass::Drifted | FindingClass::Wrong => findings.drifted += 1,
162 FindingClass::Uncovered => findings.uncovered += 1,
163 FindingClass::QueuedForAdjudication => findings.queued += 1,
164 }
165 }
166 }
167 let uncovered_counts = findings.uncovered > 0
168 && matches!(
169 crate::binding::effective_coverage_semantics(binding).value,
170 CoverageSemantics::Exhaustive
171 );
172 let has_action = source_moved
173 || findings.unresolvable > 0
174 || findings.drifted > 0
175 || uncovered_counts
176 || findings.queued > 0;
177 BindingResolution {
178 onboarding: false,
179 source_moved,
180 findings,
181 has_action,
182 uncovered_counts,
183 }
184}
185
186fn signal_of(strategy: ChangeStrategy) -> &'static str {
189 match strategy {
190 ChangeStrategy::None => "none",
191 ChangeStrategy::Git => "git",
192 ChangeStrategy::Mtime => "mtime",
193 ChangeStrategy::Graph => "graph",
194 }
195}
196
197pub fn projection_status(engine: &Engine, workspace_root: &Path) -> Vec<ProjectionStatus> {
207 projection_overview(engine, workspace_root).bindings
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
217pub struct ProjectionOverview {
218 pub bindings: Vec<ProjectionStatus>,
220 pub rollup: Rollup,
222}
223
224pub fn projection_overview(engine: &Engine, workspace_root: &Path) -> ProjectionOverview {
226 let Ok(configs) = load_pipeline_configs(workspace_root) else {
227 return ProjectionOverview {
228 bindings: Vec::new(),
229 rollup: Rollup::default(),
230 };
231 };
232
233 let mut out = Vec::with_capacity(configs.bindings.len());
234 let mut scans: Vec<(String, Option<BindingResolution>)> =
235 Vec::with_capacity(configs.bindings.len());
236 for record in &configs.bindings {
237 let binding_id = format!("{}/{}", record.mem, record.name);
238 let binding = &record.config;
239
240 let mut operations = Vec::new();
241 if binding.operations.build.is_some() {
242 operations.push("build".to_string());
243 }
244 if binding.operations.sync.is_some() {
245 operations.push("sync".to_string());
246 }
247 if binding.operations.verify.is_some() {
248 operations.push("verify".to_string());
249 }
250
251 let sync_state = engine
253 .mem_config_for(&binding.destination_mem)
254 .map(|c| c.sync_state.clone())
255 .unwrap_or_default();
256
257 let mut state = BTreeMap::new();
260 let mut resolution: Option<BindingResolution> = None;
261 if let Ok(resolved) = resolve_binding_run(&binding_id, binding) {
262 resolution = Some(resolve_binding_status(
263 engine,
264 workspace_root,
265 binding,
266 &resolved,
267 ));
268 for source in &resolved.sources {
269 let (facet, signal) = match source {
270 ResolvedSource::Primary(p) => (
271 p.name.clone(),
272 signal_of(resolve_change_strategy(p, workspace_root)).to_string(),
273 ),
274 ResolvedSource::Reference { mem } => (mem.clone(), "graph".to_string()),
277 };
278 let synced = sync_state
279 .get(&format!("{binding_id}/{facet}#synced"))
280 .cloned();
281 let verified = sync_state
282 .get(&format!("{binding_id}/{facet}#verified"))
283 .cloned();
284 state.insert(
285 facet,
286 FacetState {
287 synced,
288 verified,
289 signal,
290 },
291 );
292 }
293 }
294
295 let advance = match read_advance_store(workspace_root, &record.mem, &record.name) {
297 Ok(Some(s)) => AdvanceCounts {
298 pending: s.pending(),
299 disposed: s.disposed(),
300 },
301 _ => AdvanceCounts {
302 pending: 0,
303 disposed: 0,
304 },
305 };
306
307 let (verdict, source_moved, findings) = match &resolution {
308 Some(r) => (r.verdict(), r.source_moved, r.findings),
309 None => (RollupVerdict::Clean, false, FindingCounts::default()),
310 };
311 out.push(ProjectionStatus {
312 binding: binding_id.clone(),
313 destination_mem: binding.destination_mem.clone(),
314 operations,
315 state,
316 advance,
317 verdict,
318 source_moved,
319 findings,
320 });
321 scans.push((binding_id, resolution));
322 }
323 let rollup = rollup_from_scans(configs.bindings.len(), &scans);
324 ProjectionOverview {
325 bindings: out,
326 rollup,
327 }
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
338#[serde(rename_all = "kebab-case")]
339pub enum RollupVerdict {
340 Clean,
343 NothingDeclared,
350 Onboarding,
354 ActionNeeded,
358}
359
360impl RollupVerdict {
361 pub fn as_wire(&self) -> &'static str {
363 match self {
364 RollupVerdict::Clean => "clean",
365 RollupVerdict::NothingDeclared => "nothing-declared",
366 RollupVerdict::Onboarding => "onboarding",
367 RollupVerdict::ActionNeeded => "action-needed",
368 }
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
377pub struct Rollup {
378 pub verdict: RollupVerdict,
380 pub subject: String,
385 pub headline: String,
387 pub actions: Vec<String>,
390}
391
392impl Default for Rollup {
393 fn default() -> Self {
394 Rollup {
395 verdict: RollupVerdict::NothingDeclared,
396 subject: "no projection bindings".to_string(),
397 headline: "No projection bindings are declared, so this says nothing about the \
398 workspace beyond that."
399 .to_string(),
400 actions: Vec::new(),
401 }
402 }
403}
404
405struct Candidate {
408 severity: u8,
409 text: String,
410}
411
412pub fn projection_rollup(engine: &Engine, workspace_root: &Path) -> Rollup {
427 projection_overview(engine, workspace_root).rollup
428}
429
430fn rollup_from_scans(total: usize, scans: &[(String, Option<BindingResolution>)]) -> Rollup {
435 if total == 0 {
436 return Rollup::default();
437 }
438
439 let mut candidates: Vec<Candidate> = Vec::new();
440 let mut action_bindings = 0usize;
441 let mut onboarding_bindings = 0usize;
442
443 for (binding_id, resolution) in scans {
444 let Some(resolution) = resolution else {
445 continue;
446 };
447
448 if resolution.onboarding {
452 onboarding_bindings += 1;
453 candidates.push(Candidate {
454 severity: 1,
455 text: format!(
456 "`{binding_id}` predates its binding — 0% anchored is expected; run \
457 `memstead projection brief {binding_id} --sync` for a first-sync backfill"
458 ),
459 });
460 continue;
461 }
462
463 if resolution.source_moved {
465 candidates.push(Candidate {
466 severity: 4,
467 text: format!(
468 "`{binding_id}` source moved since the last sync — run `memstead projection \
469 brief {binding_id} --sync`"
470 ),
471 });
472 }
473
474 let FindingCounts {
475 unresolvable,
476 drifted,
477 uncovered,
478 queued,
479 } = resolution.findings;
480 if unresolvable > 0 {
481 candidates.push(Candidate {
482 severity: 6,
483 text: format!(
484 "{unresolvable} entit{} in `{binding_id}` describe source that no longer \
485 exists — run `memstead projection brief {binding_id} --sync`",
486 if unresolvable == 1 { "y" } else { "ies" }
487 ),
488 });
489 }
490 if drifted > 0 {
491 candidates.push(Candidate {
492 severity: 5,
493 text: format!(
494 "{drifted} anchor(s) in `{binding_id}` drifted from their source — run \
495 `memstead projection brief {binding_id} --sync`"
496 ),
497 });
498 }
499 if uncovered > 0 && resolution.uncovered_counts {
504 candidates.push(Candidate {
505 severity: 3,
506 text: format!(
507 "{uncovered} in-scope source artifact(s) in `{binding_id}` carry no entity \
508 — run `memstead projection verify {binding_id}`, then sync"
509 ),
510 });
511 }
512 if queued > 0 {
513 candidates.push(Candidate {
514 severity: 2,
515 text: format!(
516 "{queued} finding(s) in `{binding_id}` queued for adjudication — run \
517 `memstead projection verify {binding_id}`"
518 ),
519 });
520 }
521
522 if resolution.has_action {
523 action_bindings += 1;
524 }
525 }
526
527 candidates.sort_by_key(|c| std::cmp::Reverse(c.severity));
530 let actions: Vec<String> = candidates.into_iter().take(3).map(|c| c.text).collect();
531
532 let verdict = if action_bindings > 0 {
533 RollupVerdict::ActionNeeded
534 } else if onboarding_bindings > 0 {
535 RollupVerdict::Onboarding
536 } else {
537 RollupVerdict::Clean
538 };
539
540 let headline = match verdict {
541 RollupVerdict::ActionNeeded => format!(
542 "Action needed — {action_bindings} of {total} projection(s) have open findings or a \
543 moved source."
544 ),
545 RollupVerdict::Onboarding => format!(
546 "Onboarding — {onboarding_bindings} of {total} projection(s) predate their binding; a \
547 first-sync backfill is expected, not a defect."
548 ),
549 RollupVerdict::Clean => {
550 format!("All {total} projection(s) are in sync — no open findings, no moved sources.")
551 }
552 RollupVerdict::NothingDeclared => "No projection bindings were examined.".to_string(),
555 };
556
557 Rollup {
558 verdict,
559 subject: format!("{total} projection binding(s)"),
560 headline,
561 actions,
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568 use crate::binding::{
569 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, SyncOperation,
570 };
571 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
572 use crate::pipeline_store::write_binding;
573 use crate::storage::FilesystemMemWriter;
574 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
575 use tempfile::TempDir;
576
577 #[test]
582 fn projection_status_reports_operations_signal_and_baseline() {
583 let tmp = TempDir::new().unwrap();
584 let root = tmp.path();
585 std::fs::create_dir_all(root.join(".memstead")).unwrap();
587 std::fs::write(
588 root.join(".memstead").join("config.json"),
589 br#"{"format":1,"schema":"default@1.0.0"}"#,
590 )
591 .unwrap();
592 std::fs::write(
593 root.join(".memstead").join("workspace.toml"),
594 "[workspace]\n",
595 )
596 .unwrap();
597 let out = std::process::Command::new("git")
599 .args(["init", "-q"])
600 .current_dir(root)
601 .output()
602 .unwrap();
603 assert!(out.status.success());
604
605 write_binding(
607 root,
608 "engine",
609 "graph",
610 &Binding {
611 version: BINDING_VERSION,
612 intent: None,
613 sources: vec![crate::pipeline::Source {
614 name: "graph".to_string(),
615 medium_type: MediumType::Codebase,
616 pointer: String::new(),
617 change_detection: Some("git".to_string()),
618 scope: vec![PatternEntry {
619 path: "**/*.rs".to_string(),
620 mode: PatternMode::Allow,
621 }],
622 engagement: None,
623 preparation: None,
624 }],
625 reference_mems: Vec::new(),
626 destination_mem: "engine".to_string(),
627 deny_paths: Vec::new(),
628 coverage_semantics: None,
629 rules: None,
630 prune: None,
631 operations: Operations {
632 build: Some(BuildOperation {
633 mode: BuildMode::Discovery,
634 trigger: IngestTrigger::Loop,
635 batch_size: 20,
636 post_actions: None,
637 }),
638 sync: Some(SyncOperation {
639 trigger: IngestTrigger::Manual,
640 batch_size: 20,
641 }),
642 verify: None,
643 },
644 },
645 )
646 .unwrap();
647
648 let mount = Mount {
649 mem: "engine".to_string(),
650 schema: Some("default@1.0.0".parse().unwrap()),
651 storage: MountStorage::Folder {
652 path: root.to_path_buf(),
653 },
654 capability: MountCapability::Write,
655 lifecycle: MountLifecycle::Eager,
656 cross_linkable: false,
657 migration_target: None,
658 };
659 let mut engine = Engine::from_mounts(vec![(
660 mount,
661 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
662 as Box<dyn crate::backend::MemBackend>,
663 )])
664 .unwrap();
665 engine
666 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
667 .unwrap();
668
669 let ps = projection_status(&engine, root);
670 assert_eq!(ps.len(), 1);
671 let p = &ps[0];
672 assert_eq!(p.binding, "engine/graph");
673 assert_eq!(p.destination_mem, "engine");
674 assert_eq!(p.operations, vec!["build".to_string(), "sync".to_string()]);
675 let facet = p.state.get("graph").expect("the source facet's state");
676 assert_eq!(facet.signal, "git");
677 assert_eq!(facet.synced.as_deref(), Some("deadbeef"));
678 assert_eq!(facet.verified, None);
679 assert_eq!(
680 p.advance,
681 AdvanceCounts {
682 pending: 0,
683 disposed: 0
684 }
685 );
686 }
687
688 #[test]
691 fn projection_status_empty_without_bindings() {
692 let tmp = TempDir::new().unwrap();
693 let root = tmp.path();
694 std::fs::create_dir_all(root.join(".memstead")).unwrap();
695 std::fs::write(
696 root.join(".memstead").join("config.json"),
697 br#"{"format":1,"schema":"default@1.0.0"}"#,
698 )
699 .unwrap();
700 let mount = Mount {
701 mem: "engine".to_string(),
702 schema: Some("default@1.0.0".parse().unwrap()),
703 storage: MountStorage::Folder {
704 path: root.to_path_buf(),
705 },
706 capability: MountCapability::Write,
707 lifecycle: MountLifecycle::Eager,
708 cross_linkable: false,
709 migration_target: None,
710 };
711 let engine = Engine::from_mounts(vec![(
712 mount,
713 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
714 as Box<dyn crate::backend::MemBackend>,
715 )])
716 .unwrap();
717 assert!(projection_status(&engine, root).is_empty());
718 }
719
720 fn one_binding_workspace(tmp: &TempDir) -> Engine {
727 let root = tmp.path();
728 std::fs::create_dir_all(root.join(".memstead")).unwrap();
729 std::fs::write(
730 root.join(".memstead").join("config.json"),
731 br#"{"format":1,"schema":"default@1.0.0"}"#,
732 )
733 .unwrap();
734 std::fs::write(
735 root.join(".memstead").join("workspace.toml"),
736 "[workspace]\n",
737 )
738 .unwrap();
739 let out = std::process::Command::new("git")
740 .args(["init", "-q"])
741 .current_dir(root)
742 .output()
743 .unwrap();
744 assert!(out.status.success());
745
746 write_binding(
747 root,
748 "engine",
749 "graph",
750 &Binding {
751 version: BINDING_VERSION,
752 intent: None,
753 sources: vec![crate::pipeline::Source {
754 name: "graph".to_string(),
755 medium_type: MediumType::Codebase,
756 pointer: String::new(),
757 change_detection: Some("git".to_string()),
758 scope: vec![PatternEntry {
759 path: "**/*.rs".to_string(),
760 mode: PatternMode::Allow,
761 }],
762 engagement: None,
763 preparation: None,
764 }],
765 reference_mems: Vec::new(),
766 destination_mem: "engine".to_string(),
767 deny_paths: Vec::new(),
768 coverage_semantics: None,
769 rules: None,
770 prune: None,
771 operations: Operations {
772 build: Some(BuildOperation {
773 mode: BuildMode::Discovery,
774 trigger: IngestTrigger::Loop,
775 batch_size: 20,
776 post_actions: None,
777 }),
778 sync: Some(SyncOperation {
779 trigger: IngestTrigger::Manual,
780 batch_size: 20,
781 }),
782 verify: None,
783 },
784 },
785 )
786 .unwrap();
787
788 let mount = Mount {
789 mem: "engine".to_string(),
790 schema: Some("default@1.0.0".parse().unwrap()),
791 storage: MountStorage::Folder {
792 path: root.to_path_buf(),
793 },
794 capability: MountCapability::Write,
795 lifecycle: MountLifecycle::Eager,
796 cross_linkable: false,
797 migration_target: None,
798 };
799 Engine::from_mounts(vec![(
800 mount,
801 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
802 as Box<dyn crate::backend::MemBackend>,
803 )])
804 .unwrap()
805 }
806
807 #[test]
816 fn projection_status_carries_the_per_binding_verdict() {
817 let tmp = TempDir::new().unwrap();
818 let engine = one_binding_workspace(&tmp);
819 let statuses = projection_status(&engine, tmp.path());
820 assert_eq!(statuses.len(), 1);
821 let s = &statuses[0];
822 assert_eq!(s.verdict, RollupVerdict::Onboarding);
823 assert!(!s.source_moved, "onboarding skips the freshness scan");
824 assert_eq!(s.findings, FindingCounts::default());
825 let json = serde_json::to_value(s).unwrap();
827 assert_eq!(json["verdict"], "onboarding");
828 assert_eq!(json["source_moved"], false);
829 assert_eq!(json["findings"]["unresolvable"], 0);
830 let rollup = projection_rollup(&engine, tmp.path());
832 assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
833 }
834
835 #[test]
836 fn rollup_adopt_binding_is_onboarding_not_action_needed() {
837 let tmp = TempDir::new().unwrap();
838 let engine = one_binding_workspace(&tmp);
839 let rollup = projection_rollup(&engine, tmp.path());
840 assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
841 assert_ne!(
842 rollup.verdict,
843 RollupVerdict::ActionNeeded,
844 "pre-binding history alone must never be a red verdict"
845 );
846 assert!(
847 rollup
848 .actions
849 .iter()
850 .any(|a| a.contains("predates its binding")),
851 "the onboarding action is surfaced: {:?}",
852 rollup.actions
853 );
854 assert!(rollup.headline.contains("Onboarding"));
855 }
856
857 #[test]
864 fn rollup_without_bindings_asserts_nothing_rather_than_clean() {
865 let tmp = TempDir::new().unwrap();
866 let root = tmp.path();
867 std::fs::create_dir_all(root.join(".memstead")).unwrap();
868 std::fs::write(
869 root.join(".memstead").join("config.json"),
870 br#"{"format":1,"schema":"default@1.0.0"}"#,
871 )
872 .unwrap();
873 let mount = Mount {
874 mem: "engine".to_string(),
875 schema: Some("default@1.0.0".parse().unwrap()),
876 storage: MountStorage::Folder {
877 path: root.to_path_buf(),
878 },
879 capability: MountCapability::Write,
880 lifecycle: MountLifecycle::Eager,
881 cross_linkable: false,
882 migration_target: None,
883 };
884 let engine = Engine::from_mounts(vec![(
885 mount,
886 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
887 as Box<dyn crate::backend::MemBackend>,
888 )])
889 .unwrap();
890 let rollup = projection_rollup(&engine, root);
891 assert_eq!(rollup.verdict, RollupVerdict::NothingDeclared);
892 assert_eq!(rollup.subject, "no projection bindings");
893 assert!(
894 rollup.headline.contains("says nothing about the workspace"),
895 "the headline must not read as an all-clear: {}",
896 rollup.headline
897 );
898 assert!(rollup.actions.is_empty());
899 assert!(rollup.headline.contains("No projection bindings"));
900 }
901
902 #[test]
906 fn overview_scans_each_binding_once_for_status_and_rollup() {
907 let tmp = TempDir::new().unwrap();
908 let engine = one_binding_workspace(&tmp);
909 let root = tmp.path();
910 let before = BINDING_SCANS.with(std::cell::Cell::get);
911 let overview = projection_overview(&engine, root);
912 let after = BINDING_SCANS.with(std::cell::Cell::get);
913 assert_eq!(overview.bindings.len(), 1);
914 assert_eq!(after - before, 1, "one scan for status AND rollup");
915 assert_eq!(
917 serde_json::to_value(projection_status(&engine, root)).unwrap(),
918 serde_json::to_value(&overview.bindings).unwrap()
919 );
920 assert_eq!(projection_rollup(&engine, root), overview.rollup);
921 }
922}