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, 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, PartialEq, Eq, Serialize)]
62pub struct ProjectionStatus {
63 pub binding: String,
65 pub destination_mem: String,
67 pub operations: Vec<String>,
70 pub state: BTreeMap<String, FacetState>,
72 pub advance: AdvanceCounts,
74}
75
76fn signal_of(strategy: ChangeStrategy) -> &'static str {
79 match strategy {
80 ChangeStrategy::None => "none",
81 ChangeStrategy::Git => "git",
82 ChangeStrategy::Mtime => "mtime",
83 ChangeStrategy::Graph => "graph",
84 }
85}
86
87pub fn projection_status(engine: &Engine, workspace_root: &Path) -> Vec<ProjectionStatus> {
97 let Ok(configs) = load_pipeline_configs(workspace_root) else {
98 return Vec::new();
99 };
100
101 let mut out = Vec::with_capacity(configs.bindings.len());
102 for record in &configs.bindings {
103 let binding_id = format!("{}/{}", record.mem, record.name);
104 let binding = &record.config;
105
106 let mut operations = Vec::new();
107 if binding.operations.build.is_some() {
108 operations.push("build".to_string());
109 }
110 if binding.operations.sync.is_some() {
111 operations.push("sync".to_string());
112 }
113 if binding.operations.verify.is_some() {
114 operations.push("verify".to_string());
115 }
116
117 let sync_state = engine
119 .mem_config_for(&binding.destination_mem)
120 .map(|c| c.sync_state.clone())
121 .unwrap_or_default();
122
123 let mut state = BTreeMap::new();
126 if let Ok(resolved) = resolve_binding_run(&configs, &binding_id, binding) {
127 for source in &resolved.sources {
128 let (facet, signal) = match source {
129 ResolvedSource::Primary(p) => (
130 p.facet_ref.clone(),
131 signal_of(resolve_change_strategy(p, workspace_root)).to_string(),
132 ),
133 ResolvedSource::Reference { mem } => (mem.clone(), "graph".to_string()),
136 };
137 let synced = sync_state
138 .get(&format!("{binding_id}/{facet}#synced"))
139 .cloned();
140 let verified = sync_state
141 .get(&format!("{binding_id}/{facet}#verified"))
142 .cloned();
143 state.insert(
144 facet,
145 FacetState {
146 synced,
147 verified,
148 signal,
149 },
150 );
151 }
152 }
153
154 let advance = match read_advance_store(workspace_root, &record.mem, &record.name) {
156 Ok(Some(s)) => AdvanceCounts {
157 pending: s.pending(),
158 disposed: s.disposed(),
159 },
160 _ => AdvanceCounts {
161 pending: 0,
162 disposed: 0,
163 },
164 };
165
166 out.push(ProjectionStatus {
167 binding: binding_id,
168 destination_mem: binding.destination_mem.clone(),
169 operations,
170 state,
171 advance,
172 });
173 }
174 out
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "kebab-case")]
186pub enum RollupVerdict {
187 Clean,
190 Onboarding,
194 ActionNeeded,
198}
199
200impl RollupVerdict {
201 pub fn as_wire(&self) -> &'static str {
203 match self {
204 RollupVerdict::Clean => "clean",
205 RollupVerdict::Onboarding => "onboarding",
206 RollupVerdict::ActionNeeded => "action-needed",
207 }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct Rollup {
217 pub verdict: RollupVerdict,
219 pub headline: String,
221 pub actions: Vec<String>,
224}
225
226impl Default for Rollup {
227 fn default() -> Self {
228 Rollup {
229 verdict: RollupVerdict::Clean,
230 headline: "No projection bindings declared.".to_string(),
231 actions: Vec::new(),
232 }
233 }
234}
235
236struct Candidate {
239 severity: u8,
240 text: String,
241}
242
243pub fn projection_rollup(engine: &Engine, workspace_root: &Path) -> Rollup {
258 let Ok(configs) = load_pipeline_configs(workspace_root) else {
259 return Rollup::default();
260 };
261 if configs.bindings.is_empty() {
262 return Rollup::default();
263 }
264 let total = configs.bindings.len();
265
266 let mut candidates: Vec<Candidate> = Vec::new();
267 let mut action_bindings = 0usize;
268 let mut onboarding_bindings = 0usize;
269
270 for record in &configs.bindings {
271 let binding_id = format!("{}/{}", record.mem, record.name);
272 let binding = &record.config;
273 let Ok(resolved) = resolve_binding_run(&configs, &binding_id, binding) else {
274 continue;
275 };
276
277 if mem_predates_binding(engine, &resolved) {
281 onboarding_bindings += 1;
282 candidates.push(Candidate {
283 severity: 1,
284 text: format!(
285 "`{binding_id}` predates its binding — 0% anchored is expected; run \
286 `memstead projection sync {binding_id}` for a first-sync backfill"
287 ),
288 });
289 continue;
290 }
291
292 let mut binding_has_action = false;
293
294 if source_moved(engine, &resolved, workspace_root) {
296 binding_has_action = true;
297 candidates.push(Candidate {
298 severity: 4,
299 text: format!(
300 "`{binding_id}` source moved since the last sync — run `memstead projection \
301 sync {binding_id}`"
302 ),
303 });
304 }
305
306 if let Ok((_key, findings)) = current_findings(engine, workspace_root, binding, &resolved) {
308 let mut unresolvable = 0usize;
309 let mut drifted = 0usize;
310 let mut uncovered = 0usize;
311 let mut queued = 0usize;
312 for f in &findings {
313 match f.class {
314 FindingClass::UnresolvableAnchor => unresolvable += 1,
315 FindingClass::Drifted | FindingClass::Wrong => drifted += 1,
317 FindingClass::Uncovered => uncovered += 1,
318 FindingClass::QueuedForAdjudication => queued += 1,
319 }
320 }
321 if unresolvable > 0 {
322 binding_has_action = true;
323 candidates.push(Candidate {
324 severity: 6,
325 text: format!(
326 "{unresolvable} entit{} in `{binding_id}` describe source that no longer \
327 exists — run `memstead projection sync {binding_id}`",
328 if unresolvable == 1 { "y" } else { "ies" }
329 ),
330 });
331 }
332 if drifted > 0 {
333 binding_has_action = true;
334 candidates.push(Candidate {
335 severity: 5,
336 text: format!(
337 "{drifted} anchor(s) in `{binding_id}` drifted from their source — run \
338 `memstead projection sync {binding_id}`"
339 ),
340 });
341 }
342 if uncovered > 0 && matches!(binding.coverage_semantics, CoverageSemantics::Exhaustive)
346 {
347 binding_has_action = true;
348 candidates.push(Candidate {
349 severity: 3,
350 text: format!(
351 "{uncovered} in-scope source artifact(s) in `{binding_id}` carry no entity \
352 — run `memstead projection verify {binding_id}`, then sync"
353 ),
354 });
355 }
356 if queued > 0 {
357 binding_has_action = true;
358 candidates.push(Candidate {
359 severity: 2,
360 text: format!(
361 "{queued} finding(s) in `{binding_id}` queued for adjudication — run \
362 `memstead projection verify {binding_id}`"
363 ),
364 });
365 }
366 }
367
368 if binding_has_action {
369 action_bindings += 1;
370 }
371 }
372
373 candidates.sort_by_key(|c| std::cmp::Reverse(c.severity));
376 let actions: Vec<String> = candidates.into_iter().take(3).map(|c| c.text).collect();
377
378 let verdict = if action_bindings > 0 {
379 RollupVerdict::ActionNeeded
380 } else if onboarding_bindings > 0 {
381 RollupVerdict::Onboarding
382 } else {
383 RollupVerdict::Clean
384 };
385
386 let headline = match verdict {
387 RollupVerdict::ActionNeeded => format!(
388 "Action needed — {action_bindings} of {total} projection(s) have open findings or a \
389 moved source."
390 ),
391 RollupVerdict::Onboarding => format!(
392 "Onboarding — {onboarding_bindings} of {total} projection(s) predate their binding; a \
393 first-sync backfill is expected, not a defect."
394 ),
395 RollupVerdict::Clean => {
396 format!("All {total} projection(s) are in sync — no open findings, no moved sources.")
397 }
398 };
399
400 Rollup {
401 verdict,
402 headline,
403 actions,
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::binding::{
411 BINDING_VERSION, BindingV1, BuildMode, BuildOperation, CoverageSemantics, Operations,
412 SyncOperation,
413 };
414 use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry, PatternMode};
415 use crate::pipeline_store::{write_binding, write_facet, write_medium};
416 use crate::storage::FilesystemMemWriter;
417 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
418 use tempfile::TempDir;
419
420 #[test]
425 fn projection_status_reports_operations_signal_and_baseline() {
426 let tmp = TempDir::new().unwrap();
427 let root = tmp.path();
428 std::fs::create_dir_all(root.join(".memstead")).unwrap();
430 std::fs::write(
431 root.join(".memstead").join("config.json"),
432 br#"{"format":1,"schema":"default@1.0.0"}"#,
433 )
434 .unwrap();
435 std::fs::write(
436 root.join(".memstead").join("workspace.toml"),
437 "[workspace]\n",
438 )
439 .unwrap();
440 let out = std::process::Command::new("git")
442 .args(["init", "-q"])
443 .current_dir(root)
444 .output()
445 .unwrap();
446 assert!(out.status.success());
447
448 write_medium(
450 root,
451 "engine",
452 "graph",
453 &Medium {
454 name: "graph".to_string(),
455 medium_type: MediumType::Codebase,
456 pointer: String::new(),
457 change_detection: Some("git".to_string()),
458 },
459 )
460 .unwrap();
461 write_facet(
462 root,
463 "engine",
464 "graph",
465 &Facet {
466 name: "graph".to_string(),
467 medium: "graph".to_string(),
468 scope: vec![PatternEntry {
469 path: "**/*.rs".to_string(),
470 mode: PatternMode::Allow,
471 }],
472 engagement: None,
473 preparation: None,
474 },
475 )
476 .unwrap();
477 write_binding(
478 root,
479 "engine",
480 "graph",
481 &BindingV1 {
482 version: BINDING_VERSION,
483 intent: None,
484 source_facets: vec!["graph".to_string()],
485 reference_mems: Vec::new(),
486 destination_mem: "engine".to_string(),
487 deny_paths: Vec::new(),
488 coverage_semantics: CoverageSemantics::Exhaustive,
489 rules: None,
490 prune: None,
491 operations: Operations {
492 build: Some(BuildOperation {
493 mode: BuildMode::Discovery,
494 trigger: IngestTrigger::Loop,
495 batch_size: 20,
496 post_actions: None,
497 }),
498 sync: Some(SyncOperation {
499 trigger: IngestTrigger::Manual,
500 batch_size: 20,
501 }),
502 verify: None,
503 },
504 },
505 )
506 .unwrap();
507
508 let mount = Mount {
509 mem: "engine".to_string(),
510 schema: Some("default@1.0.0".parse().unwrap()),
511 storage: MountStorage::Folder {
512 path: root.to_path_buf(),
513 },
514 capability: MountCapability::Write,
515 lifecycle: MountLifecycle::Eager,
516 cross_linkable: false,
517 migration_target: None,
518 };
519 let mut engine = Engine::from_mounts(vec![(
520 mount,
521 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
522 as Box<dyn crate::backend::MemBackend>,
523 )])
524 .unwrap();
525 engine
526 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
527 .unwrap();
528
529 let ps = projection_status(&engine, root);
530 assert_eq!(ps.len(), 1);
531 let p = &ps[0];
532 assert_eq!(p.binding, "engine/graph");
533 assert_eq!(p.destination_mem, "engine");
534 assert_eq!(p.operations, vec!["build".to_string(), "sync".to_string()]);
535 let facet = p.state.get("graph").expect("the source facet's state");
536 assert_eq!(facet.signal, "git");
537 assert_eq!(facet.synced.as_deref(), Some("deadbeef"));
538 assert_eq!(facet.verified, None);
539 assert_eq!(
540 p.advance,
541 AdvanceCounts {
542 pending: 0,
543 disposed: 0
544 }
545 );
546 }
547
548 #[test]
551 fn projection_status_empty_without_bindings() {
552 let tmp = TempDir::new().unwrap();
553 let root = tmp.path();
554 std::fs::create_dir_all(root.join(".memstead")).unwrap();
555 std::fs::write(
556 root.join(".memstead").join("config.json"),
557 br#"{"format":1,"schema":"default@1.0.0"}"#,
558 )
559 .unwrap();
560 let mount = Mount {
561 mem: "engine".to_string(),
562 schema: Some("default@1.0.0".parse().unwrap()),
563 storage: MountStorage::Folder {
564 path: root.to_path_buf(),
565 },
566 capability: MountCapability::Write,
567 lifecycle: MountLifecycle::Eager,
568 cross_linkable: false,
569 migration_target: None,
570 };
571 let engine = Engine::from_mounts(vec![(
572 mount,
573 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
574 as Box<dyn crate::backend::MemBackend>,
575 )])
576 .unwrap();
577 assert!(projection_status(&engine, root).is_empty());
578 }
579
580 fn one_binding_workspace(tmp: &TempDir) -> Engine {
587 let root = tmp.path();
588 std::fs::create_dir_all(root.join(".memstead")).unwrap();
589 std::fs::write(
590 root.join(".memstead").join("config.json"),
591 br#"{"format":1,"schema":"default@1.0.0"}"#,
592 )
593 .unwrap();
594 std::fs::write(
595 root.join(".memstead").join("workspace.toml"),
596 "[workspace]\n",
597 )
598 .unwrap();
599 let out = std::process::Command::new("git")
600 .args(["init", "-q"])
601 .current_dir(root)
602 .output()
603 .unwrap();
604 assert!(out.status.success());
605
606 write_medium(
607 root,
608 "engine",
609 "graph",
610 &Medium {
611 name: "graph".to_string(),
612 medium_type: MediumType::Codebase,
613 pointer: String::new(),
614 change_detection: Some("git".to_string()),
615 },
616 )
617 .unwrap();
618 write_facet(
619 root,
620 "engine",
621 "graph",
622 &Facet {
623 name: "graph".to_string(),
624 medium: "graph".to_string(),
625 scope: vec![PatternEntry {
626 path: "**/*.rs".to_string(),
627 mode: PatternMode::Allow,
628 }],
629 engagement: None,
630 preparation: None,
631 },
632 )
633 .unwrap();
634 write_binding(
635 root,
636 "engine",
637 "graph",
638 &BindingV1 {
639 version: BINDING_VERSION,
640 intent: None,
641 source_facets: vec!["graph".to_string()],
642 reference_mems: Vec::new(),
643 destination_mem: "engine".to_string(),
644 deny_paths: Vec::new(),
645 coverage_semantics: CoverageSemantics::Exhaustive,
646 rules: None,
647 prune: None,
648 operations: Operations {
649 build: Some(BuildOperation {
650 mode: BuildMode::Discovery,
651 trigger: IngestTrigger::Loop,
652 batch_size: 20,
653 post_actions: None,
654 }),
655 sync: Some(SyncOperation {
656 trigger: IngestTrigger::Manual,
657 batch_size: 20,
658 }),
659 verify: None,
660 },
661 },
662 )
663 .unwrap();
664
665 let mount = Mount {
666 mem: "engine".to_string(),
667 schema: Some("default@1.0.0".parse().unwrap()),
668 storage: MountStorage::Folder {
669 path: root.to_path_buf(),
670 },
671 capability: MountCapability::Write,
672 lifecycle: MountLifecycle::Eager,
673 cross_linkable: false,
674 migration_target: None,
675 };
676 Engine::from_mounts(vec![(
677 mount,
678 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
679 as Box<dyn crate::backend::MemBackend>,
680 )])
681 .unwrap()
682 }
683
684 #[test]
689 fn rollup_adopt_binding_is_onboarding_not_action_needed() {
690 let tmp = TempDir::new().unwrap();
691 let engine = one_binding_workspace(&tmp);
692 let rollup = projection_rollup(&engine, tmp.path());
693 assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
694 assert_ne!(
695 rollup.verdict,
696 RollupVerdict::ActionNeeded,
697 "pre-binding history alone must never be a red verdict"
698 );
699 assert!(
700 rollup
701 .actions
702 .iter()
703 .any(|a| a.contains("predates its binding")),
704 "the onboarding action is surfaced: {:?}",
705 rollup.actions
706 );
707 assert!(rollup.headline.contains("Onboarding"));
708 }
709
710 #[test]
713 fn rollup_empty_without_bindings_is_clean() {
714 let tmp = TempDir::new().unwrap();
715 let root = tmp.path();
716 std::fs::create_dir_all(root.join(".memstead")).unwrap();
717 std::fs::write(
718 root.join(".memstead").join("config.json"),
719 br#"{"format":1,"schema":"default@1.0.0"}"#,
720 )
721 .unwrap();
722 let mount = Mount {
723 mem: "engine".to_string(),
724 schema: Some("default@1.0.0".parse().unwrap()),
725 storage: MountStorage::Folder {
726 path: root.to_path_buf(),
727 },
728 capability: MountCapability::Write,
729 lifecycle: MountLifecycle::Eager,
730 cross_linkable: false,
731 migration_target: None,
732 };
733 let engine = Engine::from_mounts(vec![(
734 mount,
735 Box::new(FilesystemMemWriter::new(root.to_path_buf()))
736 as Box<dyn crate::backend::MemBackend>,
737 )])
738 .unwrap();
739 let rollup = projection_rollup(&engine, root);
740 assert_eq!(rollup.verdict, RollupVerdict::Clean);
741 assert!(rollup.actions.is_empty());
742 assert!(rollup.headline.contains("No projection bindings"));
743 }
744}