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 Onboarding,
302 ActionNeeded,
306}
307
308impl RollupVerdict {
309 pub fn as_wire(&self) -> &'static str {
311 match self {
312 RollupVerdict::Clean => "clean",
313 RollupVerdict::Onboarding => "onboarding",
314 RollupVerdict::ActionNeeded => "action-needed",
315 }
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
324pub struct Rollup {
325 pub verdict: RollupVerdict,
327 pub headline: String,
329 pub actions: Vec<String>,
332}
333
334impl Default for Rollup {
335 fn default() -> Self {
336 Rollup {
337 verdict: RollupVerdict::Clean,
338 headline: "No projection bindings declared.".to_string(),
339 actions: Vec::new(),
340 }
341 }
342}
343
344struct Candidate {
347 severity: u8,
348 text: String,
349}
350
351pub fn projection_rollup(engine: &Engine, workspace_root: &Path) -> Rollup {
366 let Ok(configs) = load_pipeline_configs(workspace_root) else {
367 return Rollup::default();
368 };
369 if configs.bindings.is_empty() {
370 return Rollup::default();
371 }
372 let total = configs.bindings.len();
373
374 let mut candidates: Vec<Candidate> = Vec::new();
375 let mut action_bindings = 0usize;
376 let mut onboarding_bindings = 0usize;
377
378 for record in &configs.bindings {
379 let binding_id = format!("{}/{}", record.mem, record.name);
380 let binding = &record.config;
381 let Ok(resolved) = resolve_binding_run(&binding_id, binding) else {
382 continue;
383 };
384
385 let resolution = resolve_binding_status(engine, workspace_root, binding, &resolved);
387
388 if resolution.onboarding {
392 onboarding_bindings += 1;
393 candidates.push(Candidate {
394 severity: 1,
395 text: format!(
396 "`{binding_id}` predates its binding — 0% anchored is expected; run \
397 `memstead projection sync {binding_id}` for a first-sync backfill"
398 ),
399 });
400 continue;
401 }
402
403 if resolution.source_moved {
405 candidates.push(Candidate {
406 severity: 4,
407 text: format!(
408 "`{binding_id}` source moved since the last sync — run `memstead projection \
409 sync {binding_id}`"
410 ),
411 });
412 }
413
414 let FindingCounts {
415 unresolvable,
416 drifted,
417 uncovered,
418 queued,
419 } = resolution.findings;
420 if unresolvable > 0 {
421 candidates.push(Candidate {
422 severity: 6,
423 text: format!(
424 "{unresolvable} entit{} in `{binding_id}` describe source that no longer \
425 exists — run `memstead projection sync {binding_id}`",
426 if unresolvable == 1 { "y" } else { "ies" }
427 ),
428 });
429 }
430 if drifted > 0 {
431 candidates.push(Candidate {
432 severity: 5,
433 text: format!(
434 "{drifted} anchor(s) in `{binding_id}` drifted from their source — run \
435 `memstead projection sync {binding_id}`"
436 ),
437 });
438 }
439 if uncovered > 0
444 && matches!(
445 crate::binding::effective_coverage_semantics(binding).value,
446 CoverageSemantics::Exhaustive
447 )
448 {
449 candidates.push(Candidate {
450 severity: 3,
451 text: format!(
452 "{uncovered} in-scope source artifact(s) in `{binding_id}` carry no entity \
453 — run `memstead projection verify {binding_id}`, then sync"
454 ),
455 });
456 }
457 if queued > 0 {
458 candidates.push(Candidate {
459 severity: 2,
460 text: format!(
461 "{queued} finding(s) in `{binding_id}` queued for adjudication — run \
462 `memstead projection verify {binding_id}`"
463 ),
464 });
465 }
466
467 if resolution.has_action {
468 action_bindings += 1;
469 }
470 }
471
472 candidates.sort_by_key(|c| std::cmp::Reverse(c.severity));
475 let actions: Vec<String> = candidates.into_iter().take(3).map(|c| c.text).collect();
476
477 let verdict = if action_bindings > 0 {
478 RollupVerdict::ActionNeeded
479 } else if onboarding_bindings > 0 {
480 RollupVerdict::Onboarding
481 } else {
482 RollupVerdict::Clean
483 };
484
485 let headline = match verdict {
486 RollupVerdict::ActionNeeded => format!(
487 "Action needed — {action_bindings} of {total} projection(s) have open findings or a \
488 moved source."
489 ),
490 RollupVerdict::Onboarding => format!(
491 "Onboarding — {onboarding_bindings} of {total} projection(s) predate their binding; a \
492 first-sync backfill is expected, not a defect."
493 ),
494 RollupVerdict::Clean => {
495 format!("All {total} projection(s) are in sync — no open findings, no moved sources.")
496 }
497 };
498
499 Rollup {
500 verdict,
501 headline,
502 actions,
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::binding::{
510 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, SyncOperation,
511 };
512 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
513 use crate::pipeline_store::write_binding;
514 use crate::storage::FilesystemMemWriter;
515 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
516 use tempfile::TempDir;
517
518 #[test]
523 fn projection_status_reports_operations_signal_and_baseline() {
524 let tmp = TempDir::new().unwrap();
525 let root = tmp.path();
526 std::fs::create_dir_all(root.join(".memstead")).unwrap();
528 std::fs::write(
529 root.join(".memstead").join("config.json"),
530 br#"{"format":1,"schema":"default@1.0.0"}"#,
531 )
532 .unwrap();
533 std::fs::write(
534 root.join(".memstead").join("workspace.toml"),
535 "[workspace]\n",
536 )
537 .unwrap();
538 let out = std::process::Command::new("git")
540 .args(["init", "-q"])
541 .current_dir(root)
542 .output()
543 .unwrap();
544 assert!(out.status.success());
545
546 write_binding(
548 root,
549 "engine",
550 "graph",
551 &Binding {
552 version: BINDING_VERSION,
553 intent: None,
554 sources: vec![crate::pipeline::Source {
555 name: "graph".to_string(),
556 medium_type: MediumType::Codebase,
557 pointer: String::new(),
558 change_detection: Some("git".to_string()),
559 scope: vec![PatternEntry {
560 path: "**/*.rs".to_string(),
561 mode: PatternMode::Allow,
562 }],
563 engagement: None,
564 preparation: None,
565 }],
566 reference_mems: Vec::new(),
567 destination_mem: "engine".to_string(),
568 deny_paths: Vec::new(),
569 coverage_semantics: None,
570 rules: None,
571 prune: None,
572 operations: Operations {
573 build: Some(BuildOperation {
574 mode: BuildMode::Discovery,
575 trigger: IngestTrigger::Loop,
576 batch_size: 20,
577 post_actions: None,
578 }),
579 sync: Some(SyncOperation {
580 trigger: IngestTrigger::Manual,
581 batch_size: 20,
582 }),
583 verify: None,
584 },
585 },
586 )
587 .unwrap();
588
589 let mount = Mount {
590 mem: "engine".to_string(),
591 schema: Some("default@1.0.0".parse().unwrap()),
592 storage: MountStorage::Folder {
593 path: root.to_path_buf(),
594 },
595 capability: MountCapability::Write,
596 lifecycle: MountLifecycle::Eager,
597 cross_linkable: false,
598 migration_target: None,
599 };
600 let mut engine = Engine::from_mounts(vec![(
601 mount,
602 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
603 as Box<dyn crate::backend::MemBackend>,
604 )])
605 .unwrap();
606 engine
607 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
608 .unwrap();
609
610 let ps = projection_status(&engine, root);
611 assert_eq!(ps.len(), 1);
612 let p = &ps[0];
613 assert_eq!(p.binding, "engine/graph");
614 assert_eq!(p.destination_mem, "engine");
615 assert_eq!(p.operations, vec!["build".to_string(), "sync".to_string()]);
616 let facet = p.state.get("graph").expect("the source facet's state");
617 assert_eq!(facet.signal, "git");
618 assert_eq!(facet.synced.as_deref(), Some("deadbeef"));
619 assert_eq!(facet.verified, None);
620 assert_eq!(
621 p.advance,
622 AdvanceCounts {
623 pending: 0,
624 disposed: 0
625 }
626 );
627 }
628
629 #[test]
632 fn projection_status_empty_without_bindings() {
633 let tmp = TempDir::new().unwrap();
634 let root = tmp.path();
635 std::fs::create_dir_all(root.join(".memstead")).unwrap();
636 std::fs::write(
637 root.join(".memstead").join("config.json"),
638 br#"{"format":1,"schema":"default@1.0.0"}"#,
639 )
640 .unwrap();
641 let mount = Mount {
642 mem: "engine".to_string(),
643 schema: Some("default@1.0.0".parse().unwrap()),
644 storage: MountStorage::Folder {
645 path: root.to_path_buf(),
646 },
647 capability: MountCapability::Write,
648 lifecycle: MountLifecycle::Eager,
649 cross_linkable: false,
650 migration_target: None,
651 };
652 let engine = Engine::from_mounts(vec![(
653 mount,
654 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
655 as Box<dyn crate::backend::MemBackend>,
656 )])
657 .unwrap();
658 assert!(projection_status(&engine, root).is_empty());
659 }
660
661 fn one_binding_workspace(tmp: &TempDir) -> Engine {
668 let root = tmp.path();
669 std::fs::create_dir_all(root.join(".memstead")).unwrap();
670 std::fs::write(
671 root.join(".memstead").join("config.json"),
672 br#"{"format":1,"schema":"default@1.0.0"}"#,
673 )
674 .unwrap();
675 std::fs::write(
676 root.join(".memstead").join("workspace.toml"),
677 "[workspace]\n",
678 )
679 .unwrap();
680 let out = std::process::Command::new("git")
681 .args(["init", "-q"])
682 .current_dir(root)
683 .output()
684 .unwrap();
685 assert!(out.status.success());
686
687 write_binding(
688 root,
689 "engine",
690 "graph",
691 &Binding {
692 version: BINDING_VERSION,
693 intent: None,
694 sources: vec![crate::pipeline::Source {
695 name: "graph".to_string(),
696 medium_type: MediumType::Codebase,
697 pointer: String::new(),
698 change_detection: Some("git".to_string()),
699 scope: vec![PatternEntry {
700 path: "**/*.rs".to_string(),
701 mode: PatternMode::Allow,
702 }],
703 engagement: None,
704 preparation: None,
705 }],
706 reference_mems: Vec::new(),
707 destination_mem: "engine".to_string(),
708 deny_paths: Vec::new(),
709 coverage_semantics: None,
710 rules: None,
711 prune: None,
712 operations: Operations {
713 build: Some(BuildOperation {
714 mode: BuildMode::Discovery,
715 trigger: IngestTrigger::Loop,
716 batch_size: 20,
717 post_actions: None,
718 }),
719 sync: Some(SyncOperation {
720 trigger: IngestTrigger::Manual,
721 batch_size: 20,
722 }),
723 verify: None,
724 },
725 },
726 )
727 .unwrap();
728
729 let mount = Mount {
730 mem: "engine".to_string(),
731 schema: Some("default@1.0.0".parse().unwrap()),
732 storage: MountStorage::Folder {
733 path: root.to_path_buf(),
734 },
735 capability: MountCapability::Write,
736 lifecycle: MountLifecycle::Eager,
737 cross_linkable: false,
738 migration_target: None,
739 };
740 Engine::from_mounts(vec![(
741 mount,
742 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
743 as Box<dyn crate::backend::MemBackend>,
744 )])
745 .unwrap()
746 }
747
748 #[test]
757 fn projection_status_carries_the_per_binding_verdict() {
758 let tmp = TempDir::new().unwrap();
759 let engine = one_binding_workspace(&tmp);
760 let statuses = projection_status(&engine, tmp.path());
761 assert_eq!(statuses.len(), 1);
762 let s = &statuses[0];
763 assert_eq!(s.verdict, RollupVerdict::Onboarding);
764 assert!(!s.source_moved, "onboarding skips the freshness scan");
765 assert_eq!(s.findings, FindingCounts::default());
766 let json = serde_json::to_value(s).unwrap();
768 assert_eq!(json["verdict"], "onboarding");
769 assert_eq!(json["source_moved"], false);
770 assert_eq!(json["findings"]["unresolvable"], 0);
771 let rollup = projection_rollup(&engine, tmp.path());
773 assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
774 }
775
776 #[test]
777 fn rollup_adopt_binding_is_onboarding_not_action_needed() {
778 let tmp = TempDir::new().unwrap();
779 let engine = one_binding_workspace(&tmp);
780 let rollup = projection_rollup(&engine, tmp.path());
781 assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
782 assert_ne!(
783 rollup.verdict,
784 RollupVerdict::ActionNeeded,
785 "pre-binding history alone must never be a red verdict"
786 );
787 assert!(
788 rollup
789 .actions
790 .iter()
791 .any(|a| a.contains("predates its binding")),
792 "the onboarding action is surfaced: {:?}",
793 rollup.actions
794 );
795 assert!(rollup.headline.contains("Onboarding"));
796 }
797
798 #[test]
801 fn rollup_empty_without_bindings_is_clean() {
802 let tmp = TempDir::new().unwrap();
803 let root = tmp.path();
804 std::fs::create_dir_all(root.join(".memstead")).unwrap();
805 std::fs::write(
806 root.join(".memstead").join("config.json"),
807 br#"{"format":1,"schema":"default@1.0.0"}"#,
808 )
809 .unwrap();
810 let mount = Mount {
811 mem: "engine".to_string(),
812 schema: Some("default@1.0.0".parse().unwrap()),
813 storage: MountStorage::Folder {
814 path: root.to_path_buf(),
815 },
816 capability: MountCapability::Write,
817 lifecycle: MountLifecycle::Eager,
818 cross_linkable: false,
819 migration_target: None,
820 };
821 let engine = Engine::from_mounts(vec![(
822 mount,
823 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
824 as Box<dyn crate::backend::MemBackend>,
825 )])
826 .unwrap();
827 let rollup = projection_rollup(&engine, root);
828 assert_eq!(rollup.verdict, RollupVerdict::Clean);
829 assert!(rollup.actions.is_empty());
830 assert!(rollup.headline.contains("No projection bindings"));
831 }
832}