1use std::collections::{BTreeMap, BTreeSet};
38use std::path::Path;
39
40use serde::{Deserialize, Serialize};
41
42use crate::Engine;
43use crate::binding::{Binding, BuildMode};
44use crate::pipeline::IngestTrigger;
45use crate::pipeline_store::BindingConfigs;
46
47use super::cursor::{source_moved, source_moved_since};
48use super::findings::current_findings;
49use super::resolve::{ResolvedIngest, resolve_binding_run};
50
51pub const MAX_SKIP_LEVEL: u32 = 10;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57#[serde(rename_all = "kebab-case")]
58pub enum OperationKind {
59 Build,
61 Sync,
63 Verify,
65}
66
67impl OperationKind {
68 pub const ALL: [OperationKind; 3] = [
70 OperationKind::Build,
71 OperationKind::Sync,
72 OperationKind::Verify,
73 ];
74
75 pub fn as_wire(&self) -> &'static str {
77 match self {
78 OperationKind::Build => "build",
79 OperationKind::Sync => "sync",
80 OperationKind::Verify => "verify",
81 }
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum OperationFilter {
91 Only(OperationKind),
93 Any,
95}
96
97impl OperationFilter {
98 fn admits(self, op: OperationKind) -> bool {
99 match self {
100 OperationFilter::Only(only) => only == op,
101 OperationFilter::Any => true,
102 }
103 }
104}
105
106fn pair_key(binding_id: &str, op: OperationKind) -> String {
109 format!("{binding_id}#{}", op.as_wire())
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
114pub struct BackoffEntry {
115 #[serde(default)]
117 pub skip_remaining: u32,
118 #[serde(default)]
120 pub skip_level: u32,
121 #[serde(default)]
123 pub snapshot: String,
124}
125
126#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Cursor {
132 #[serde(default)]
134 pub last: Option<String>,
135}
136
137pub fn apply_backoff(entry: &mut BackoffEntry, current: &str) -> bool {
148 if !entry.snapshot.is_empty() && current != entry.snapshot {
149 entry.skip_remaining = 0;
150 entry.skip_level = 0;
151 entry.snapshot = current.to_string();
152 return false;
153 }
154 if entry.skip_remaining > 0 {
155 entry.skip_remaining -= 1;
156 return true;
157 }
158 if !entry.snapshot.is_empty() && current == entry.snapshot {
159 entry.skip_level = (entry.skip_level + 1).min(MAX_SKIP_LEVEL);
160 entry.skip_remaining = entry.skip_level;
161 }
162 entry.snapshot = current.to_string();
163 false
164}
165
166fn would_skip_backoff(entry: &BackoffEntry, current: &str) -> bool {
172 if !entry.snapshot.is_empty() && current != entry.snapshot {
173 return false;
174 }
175 entry.skip_remaining > 0
176}
177
178fn would_skip(mode: BuildMode, source_moved: bool, entry: &BackoffEntry, current: &str) -> bool {
180 match mode {
181 BuildMode::OneShot => return false,
182 BuildMode::Discovery => {}
183 }
184 if source_moved {
185 return false;
186 }
187 would_skip_backoff(entry, current)
188}
189
190pub fn should_skip(
195 mode: BuildMode,
196 source_moved: bool,
197 entry: &mut BackoffEntry,
198 current: &str,
199) -> bool {
200 match mode {
201 BuildMode::OneShot => return false,
202 BuildMode::Discovery => {}
203 }
204 if source_moved {
205 return false;
206 }
207 apply_backoff(entry, current)
208}
209
210fn read_json<T: Default + for<'de> Deserialize<'de>>(cache_root: &Path, name: &str) -> T {
213 std::fs::read(cache_root.join(name))
214 .ok()
215 .and_then(|b| serde_json::from_slice(&b).ok())
216 .unwrap_or_default()
217}
218
219fn write_json<T: Serialize>(cache_root: &Path, name: &str, value: &T) {
220 let _ = std::fs::create_dir_all(cache_root);
221 if let Ok(bytes) = serde_json::to_vec(value) {
222 let _ = std::fs::write(cache_root.join(name), bytes);
223 }
224}
225
226fn read_one_shot_runs(cache_root: &Path) -> BTreeSet<String> {
228 let map: BTreeMap<String, bool> = read_json(cache_root, "ingest-one-shot-runs.json");
229 map.into_iter()
230 .filter(|(_, v)| *v)
231 .map(|(k, _)| k)
232 .collect()
233}
234
235pub fn select_next_due(
239 engine: &Engine,
240 workspace_root: &Path,
241 configs: &BindingConfigs,
242) -> Option<String> {
243 select_next_due_operation(
244 engine,
245 workspace_root,
246 configs,
247 OperationFilter::Only(OperationKind::Build),
248 true,
249 )
250 .map(|(name, _)| name)
251}
252
253pub fn not_loop_declared(
259 configs: &BindingConfigs,
260 filter: OperationFilter,
261) -> Vec<(String, OperationKind)> {
262 let mut out = Vec::new();
263 for record in &configs.bindings {
264 let binding_id = format!("{}/{}", record.mem, record.name);
265 for op in OperationKind::ALL {
266 if filter.admits(op) && !declared_for_loop(&record.config, op) {
267 out.push((binding_id.clone(), op));
268 }
269 }
270 }
271 out.sort();
272 out
273}
274
275struct Pair<'a> {
277 key: String,
279 ingest: ResolvedIngest,
281 binding: &'a Binding,
283 op: OperationKind,
285}
286
287fn declared_for_loop(binding: &Binding, op: OperationKind) -> bool {
292 match op {
293 OperationKind::Build => binding
294 .operations
295 .build
296 .as_ref()
297 .is_some_and(|b| b.trigger == IngestTrigger::Loop),
298 OperationKind::Sync => binding
299 .operations
300 .sync
301 .as_ref()
302 .is_some_and(|s| s.trigger == IngestTrigger::Loop),
303 OperationKind::Verify => binding
304 .operations
305 .verify
306 .as_ref()
307 .is_some_and(|v| v.trigger == IngestTrigger::Loop),
308 }
309}
310
311fn operation_due(engine: &Engine, workspace_root: &Path, pair: &Pair<'_>) -> bool {
321 match pair.op {
322 OperationKind::Build => true,
323 OperationKind::Sync => {
324 source_moved(engine, &pair.ingest, workspace_root)
325 || current_findings(engine, workspace_root, pair.binding, &pair.ingest)
326 .map(|(_key, findings)| !findings.is_empty())
327 .unwrap_or(false)
328 }
329 OperationKind::Verify => {
330 source_moved_since(engine, &pair.ingest, workspace_root, "verified", true)
331 }
332 }
333}
334
335pub fn select_next_due_operation(
345 engine: &Engine,
346 workspace_root: &Path,
347 configs: &BindingConfigs,
348 filter: OperationFilter,
349 consume: bool,
350) -> Option<(String, OperationKind)> {
351 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
352
353 let one_shot_ran = read_one_shot_runs(&cache_root);
358 let mut eligible: Vec<Pair<'_>> = Vec::new();
359 for record in &configs.bindings {
360 let binding_id = format!("{}/{}", record.mem, record.name);
361 let Ok(ingest) = resolve_binding_run(&binding_id, &record.config) else {
362 continue;
363 };
364 for op in OperationKind::ALL {
365 if !filter.admits(op) || !declared_for_loop(&record.config, op) {
366 continue;
367 }
368 if op == OperationKind::Build
369 && ingest.mode == BuildMode::OneShot
370 && one_shot_ran.contains(&ingest.name)
371 {
372 continue;
373 }
374 eligible.push(Pair {
375 key: pair_key(&ingest.name, op),
376 ingest: ingest.clone(),
377 binding: &record.config,
378 op,
379 });
380 }
381 }
382 eligible.sort_by(|a, b| a.key.cmp(&b.key));
383 let n = eligible.len();
384 if n == 0 {
385 return None;
386 }
387
388 let mut cursor: Cursor = read_json(&cache_root, "ingest-cursor.json");
390 let start = cursor
391 .last
392 .as_ref()
393 .and_then(|last| eligible.iter().position(|p| &p.key == last))
394 .map_or(0, |i| (i + 1) % n);
395 if consume {
396 cursor.last = Some(eligible[start].key.clone());
397 write_json(&cache_root, "ingest-cursor.json", &cursor);
398 }
399
400 let mut backoff: BTreeMap<String, BackoffEntry> = read_json(&cache_root, "ingest-backoff.json");
404 backoff.retain(|k, _| k.contains('#'));
405 let mut selected = None;
406 for offset in 0..n {
407 let pair = &eligible[(start + offset) % n];
408 if !operation_due(engine, workspace_root, pair) {
409 continue;
410 }
411 let current = engine
412 .mem_head_sha(&pair.ingest.destination_mem)
413 .ok()
414 .flatten()
415 .unwrap_or_default();
416 let (mode, moved) = match pair.op {
422 OperationKind::Build => (
423 pair.ingest.mode,
424 source_moved(engine, &pair.ingest, workspace_root),
425 ),
426 OperationKind::Sync | OperationKind::Verify => (BuildMode::Discovery, false),
427 };
428 if consume {
429 let entry = backoff.entry(pair.key.clone()).or_default();
430 if !should_skip(mode, moved, entry, ¤t) {
431 selected = Some((pair.ingest.name.clone(), pair.op));
432 break;
433 }
434 } else {
435 let entry = backoff.get(&pair.key).cloned().unwrap_or_default();
436 if !would_skip(mode, moved, &entry, ¤t) {
437 selected = Some((pair.ingest.name.clone(), pair.op));
438 break;
439 }
440 }
441 }
442 if consume {
443 write_json(&cache_root, "ingest-backoff.json", &backoff);
444 }
445 selected
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
456 fn backoff_ramps_and_resets() {
457 let mut e = BackoffEntry::default();
458
459 assert!(!apply_backoff(&mut e, "sha1"));
461 assert_eq!(e.snapshot, "sha1");
462 assert_eq!(e.skip_level, 0);
463
464 assert!(!apply_backoff(&mut e, "sha1"));
466 assert_eq!(e.skip_level, 1);
467 assert_eq!(e.skip_remaining, 1);
468
469 assert!(apply_backoff(&mut e, "sha1"));
471 assert_eq!(e.skip_remaining, 0);
472
473 assert!(!apply_backoff(&mut e, "sha1"));
475 assert_eq!(e.skip_level, 2);
476 assert_eq!(e.skip_remaining, 2);
477
478 assert!(!apply_backoff(&mut e, "sha2"));
480 assert_eq!(e.skip_level, 0);
481 assert_eq!(e.skip_remaining, 0);
482 assert_eq!(e.snapshot, "sha2");
483 }
484
485 #[test]
487 fn backoff_caps_at_max_level() {
488 let mut e = BackoffEntry {
489 skip_level: MAX_SKIP_LEVEL,
490 skip_remaining: 0,
491 snapshot: "s".to_string(),
492 };
493 assert!(!apply_backoff(&mut e, "s")); assert_eq!(e.skip_level, MAX_SKIP_LEVEL, "capped");
495 assert_eq!(e.skip_remaining, MAX_SKIP_LEVEL);
496 }
497
498 #[test]
501 fn should_skip_honours_mode_and_source_movement() {
502 let mut e = BackoffEntry {
503 skip_remaining: 3,
504 skip_level: 3,
505 snapshot: "s".to_string(),
506 };
507 assert!(!should_skip(BuildMode::OneShot, false, &mut e.clone(), "s"));
509 let mut e2 = e.clone();
511 assert!(!should_skip(BuildMode::Discovery, true, &mut e2, "s"));
512 assert_eq!(e2.skip_remaining, 3, "moved source does not touch backoff");
513 assert!(should_skip(BuildMode::Discovery, false, &mut e, "s"));
515 }
516
517 use crate::binding::{
520 BINDING_VERSION, BuildOperation, Operations, SyncOperation, VerifyOperation, hash_binding,
521 };
522 use crate::pipeline::{MediumType, PatternEntry, PatternMode, Source};
523 use crate::pipeline_store::MemPipelineRecord;
524
525 use super::super::findings::{
526 Finding, FindingClass, FindingKey, FindingTarget, FindingsStore, write_findings_store,
527 };
528
529 fn empty_engine() -> Engine {
530 Engine::from_mounts(Vec::new()).unwrap()
531 }
532
533 fn binding_with(operations: Operations) -> Binding {
534 Binding {
535 version: BINDING_VERSION,
536 intent: None,
537 sources: Vec::new(),
538 reference_mems: Vec::new(),
539 destination_mem: "m".to_string(),
540 deny_paths: Vec::new(),
541 coverage_semantics: None,
542 rules: None,
543 prune: None,
544 operations,
545 }
546 }
547
548 fn build_op(trigger: IngestTrigger) -> BuildOperation {
549 BuildOperation {
550 mode: BuildMode::Discovery,
551 trigger,
552 batch_size: 20,
553 post_actions: None,
554 }
555 }
556
557 fn record(name: &str, config: Binding) -> MemPipelineRecord<Binding> {
558 MemPipelineRecord {
559 mem: "m".to_string(),
560 name: name.to_string(),
561 config,
562 }
563 }
564
565 fn configs_of(bindings: Vec<MemPipelineRecord<Binding>>) -> BindingConfigs {
566 BindingConfigs {
567 bindings,
568 quarantined: Vec::new(),
569 }
570 }
571
572 #[test]
576 fn eligibility_requires_block_and_loop_trigger() {
577 let ws = tempfile::tempdir().unwrap();
578 let engine = empty_engine();
579 let configs = configs_of(vec![
580 record(
582 "a",
583 binding_with(Operations {
584 build: Some(build_op(IngestTrigger::Loop)),
585 sync: None,
586 verify: None,
587 }),
588 ),
589 record(
591 "b",
592 binding_with(Operations {
593 build: Some(build_op(IngestTrigger::Manual)),
594 sync: None,
595 verify: None,
596 }),
597 ),
598 record(
600 "c",
601 binding_with(Operations {
602 build: None,
603 sync: Some(SyncOperation {
604 trigger: IngestTrigger::Manual,
605 batch_size: 20,
606 }),
607 verify: Some(VerifyOperation {
608 trigger: IngestTrigger::Manual,
609 batch_size: 20,
610 adjudication_cap: 50,
611 full_resync_every: 20,
612 }),
613 }),
614 ),
615 ]);
616
617 assert_eq!(
619 select_next_due_operation(
620 &engine,
621 ws.path(),
622 &configs,
623 OperationFilter::Only(OperationKind::Build),
624 true
625 ),
626 Some(("m/a".to_string(), OperationKind::Build))
627 );
628 assert_eq!(
629 select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, true),
630 Some(("m/a".to_string(), OperationKind::Build))
631 );
632 assert_eq!(
634 select_next_due_operation(
635 &engine,
636 ws.path(),
637 &configs,
638 OperationFilter::Only(OperationKind::Sync),
639 true
640 ),
641 None
642 );
643 assert_eq!(
644 select_next_due_operation(
645 &engine,
646 ws.path(),
647 &configs,
648 OperationFilter::Only(OperationKind::Verify),
649 true
650 ),
651 None
652 );
653 }
654
655 #[test]
659 fn sync_pair_due_only_on_open_findings_when_source_unmoved() {
660 let ws = tempfile::tempdir().unwrap();
661 let engine = empty_engine();
662 let binding = binding_with(Operations {
663 build: None,
664 sync: Some(SyncOperation {
665 trigger: IngestTrigger::Loop,
666 batch_size: 20,
667 }),
668 verify: None,
669 });
670 let configs = configs_of(vec![record("s", binding.clone())]);
671
672 assert_eq!(
674 select_next_due_operation(
675 &engine,
676 ws.path(),
677 &configs,
678 OperationFilter::Only(OperationKind::Sync),
679 true
680 ),
681 None
682 );
683
684 let key = FindingKey {
686 binding_hash: hash_binding(&binding),
687 source_head: String::new(),
688 };
689
690 let mut store = FindingsStore {
692 binding: "m/s".to_string(),
693 batches: Vec::new(),
694 };
695 store.record(key.clone(), "0".to_string(), Vec::new());
696 write_findings_store(ws.path(), "m", "s", &store).unwrap();
697 assert_eq!(
698 select_next_due_operation(
699 &engine,
700 ws.path(),
701 &configs,
702 OperationFilter::Only(OperationKind::Sync),
703 true
704 ),
705 None
706 );
707
708 store.record(
710 key.clone(),
711 "1".to_string(),
712 vec![Finding {
713 key: key.clone(),
714 facet: "f".to_string(),
715 target: FindingTarget::Artifact {
716 artifact: "a.rs".to_string(),
717 },
718 class: FindingClass::Uncovered,
719 detail: "no anchor".to_string(),
720 created_at: "1".to_string(),
721 }],
722 );
723 write_findings_store(ws.path(), "m", "s", &store).unwrap();
724 assert_eq!(
725 select_next_due_operation(
726 &engine,
727 ws.path(),
728 &configs,
729 OperationFilter::Only(OperationKind::Sync),
730 true
731 ),
732 Some(("m/s".to_string(), OperationKind::Sync))
733 );
734
735 let mut stale = FindingsStore {
737 binding: "m/s".to_string(),
738 batches: Vec::new(),
739 };
740 let stale_key = FindingKey {
741 binding_hash: "0000".to_string(),
742 source_head: "old".to_string(),
743 };
744 stale.record(
745 stale_key.clone(),
746 "1".to_string(),
747 vec![Finding {
748 key: stale_key,
749 facet: "f".to_string(),
750 target: FindingTarget::Artifact {
751 artifact: "a.rs".to_string(),
752 },
753 class: FindingClass::Uncovered,
754 detail: "stale".to_string(),
755 created_at: "1".to_string(),
756 }],
757 );
758 write_findings_store(ws.path(), "m", "s", &stale).unwrap();
759 assert_eq!(
760 select_next_due_operation(
761 &engine,
762 ws.path(),
763 &configs,
764 OperationFilter::Only(OperationKind::Sync),
765 true
766 ),
767 None,
768 "superseded findings must not pull a sync into rotation"
769 );
770 }
771
772 fn configs_with_live_source(operations: Operations) -> BindingConfigs {
774 let mut binding = binding_with(operations);
775 binding.sources = vec![Source {
776 name: "f".to_string(),
777 medium_type: MediumType::Filesystem,
778 pointer: String::new(),
779 change_detection: Some("mtime".to_string()),
780 scope: vec![PatternEntry {
781 path: "**/*.rs".to_string(),
782 mode: PatternMode::Allow,
783 }],
784 engagement: None,
785 preparation: None,
786 }];
787 BindingConfigs {
788 bindings: vec![record("v", binding)],
789 quarantined: Vec::new(),
790 }
791 }
792
793 #[test]
797 fn verify_pair_due_when_never_verified_with_live_token() {
798 let ws = tempfile::tempdir().unwrap();
799 std::fs::write(ws.path().join("a.rs"), "x").unwrap();
800 let engine = empty_engine();
801 let verify_loop = Operations {
802 build: None,
803 sync: None,
804 verify: Some(VerifyOperation {
805 trigger: IngestTrigger::Loop,
806 batch_size: 20,
807 adjudication_cap: 50,
808 full_resync_every: 20,
809 }),
810 };
811
812 let configs = configs_with_live_source(verify_loop.clone());
814 assert_eq!(
815 select_next_due_operation(
816 &engine,
817 ws.path(),
818 &configs,
819 OperationFilter::Only(OperationKind::Verify),
820 true
821 ),
822 Some(("m/v".to_string(), OperationKind::Verify))
823 );
824
825 let mut no_signal = configs_with_live_source(verify_loop);
827 no_signal.bindings[0].config.sources[0].scope.clear();
828 assert_eq!(
829 select_next_due_operation(
830 &engine,
831 ws.path(),
832 &no_signal,
833 OperationFilter::Only(OperationKind::Verify),
834 true
835 ),
836 None
837 );
838 }
839
840 #[test]
843 fn any_filter_rotates_across_pairs() {
844 let ws = tempfile::tempdir().unwrap();
845 std::fs::write(ws.path().join("a.rs"), "x").unwrap();
846 let engine = empty_engine();
847
848 let mut configs = configs_with_live_source(Operations {
851 build: None,
852 sync: None,
853 verify: Some(VerifyOperation {
854 trigger: IngestTrigger::Loop,
855 batch_size: 20,
856 adjudication_cap: 50,
857 full_resync_every: 20,
858 }),
859 });
860 configs.bindings.push(record(
861 "a",
862 binding_with(Operations {
863 build: Some(build_op(IngestTrigger::Loop)),
864 sync: None,
865 verify: None,
866 }),
867 ));
868
869 let next = || {
870 select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, true)
871 .unwrap()
872 };
873 assert_eq!(next(), ("m/a".to_string(), OperationKind::Build));
874 assert_eq!(next(), ("m/v".to_string(), OperationKind::Verify));
875 assert_eq!(next(), ("m/a".to_string(), OperationKind::Build));
876 }
877
878 #[test]
882 fn legacy_single_key_backoff_entries_are_discarded() {
883 let ws = tempfile::tempdir().unwrap();
884 let engine = empty_engine();
885 let cache_root = ws.path().join(".memstead.cache").join("ingest");
886 std::fs::create_dir_all(&cache_root).unwrap();
887 let legacy: BTreeMap<String, BackoffEntry> = [(
888 "m/a".to_string(),
889 BackoffEntry {
890 skip_remaining: 5,
891 skip_level: 5,
892 snapshot: "s".to_string(),
893 },
894 )]
895 .into();
896 std::fs::write(
897 cache_root.join("ingest-backoff.json"),
898 serde_json::to_vec(&legacy).unwrap(),
899 )
900 .unwrap();
901
902 let configs = configs_of(vec![record(
903 "a",
904 binding_with(Operations {
905 build: Some(build_op(IngestTrigger::Loop)),
906 sync: None,
907 verify: None,
908 }),
909 )]);
910 assert_eq!(
911 select_next_due_operation(
912 &engine,
913 ws.path(),
914 &configs,
915 OperationFilter::Only(OperationKind::Build),
916 true
917 ),
918 Some(("m/a".to_string(), OperationKind::Build)),
919 "a legacy entry's pending skips are discarded, not honoured"
920 );
921
922 let rewritten: BTreeMap<String, BackoffEntry> =
923 serde_json::from_slice(&std::fs::read(cache_root.join("ingest-backoff.json")).unwrap())
924 .unwrap();
925 assert!(!rewritten.contains_key("m/a"), "legacy key pruned");
926 assert!(rewritten.contains_key("m/a#build"), "pair key written");
927 }
928
929 #[test]
933 fn peek_on_fresh_workspace_writes_nothing() {
934 let ws = tempfile::tempdir().unwrap();
935 let engine = empty_engine();
936 let configs = configs_of(vec![record(
937 "a",
938 binding_with(Operations {
939 build: Some(build_op(IngestTrigger::Loop)),
940 sync: None,
941 verify: None,
942 }),
943 )]);
944 assert_eq!(
945 select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, false),
946 Some(("m/a".to_string(), OperationKind::Build))
947 );
948 let cache_root = ws.path().join(".memstead.cache").join("ingest");
949 assert!(
950 !cache_root.join("ingest-cursor.json").exists()
951 && !cache_root.join("ingest-backoff.json").exists(),
952 "a pure render must not mint scheduler state"
953 );
954 }
955
956 #[test]
961 fn peek_is_idempotent_and_predicts_consumption() {
962 let ws = tempfile::tempdir().unwrap();
963 let engine = empty_engine();
964 let loop_build = Operations {
965 build: Some(build_op(IngestTrigger::Loop)),
966 sync: None,
967 verify: None,
968 };
969 let configs = configs_of(vec![
970 record("a", binding_with(loop_build.clone())),
971 record("b", binding_with(loop_build)),
972 ]);
973 let cache_root = ws.path().join(".memstead.cache").join("ingest");
974
975 assert_eq!(
977 select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, true),
978 Some(("m/a".to_string(), OperationKind::Build))
979 );
980 let cursor_bytes = std::fs::read(cache_root.join("ingest-cursor.json")).unwrap();
981 let backoff_bytes = std::fs::read(cache_root.join("ingest-backoff.json")).unwrap();
982
983 for _ in 0..3 {
985 assert_eq!(
986 select_next_due_operation(
987 &engine,
988 ws.path(),
989 &configs,
990 OperationFilter::Any,
991 false
992 ),
993 Some(("m/b".to_string(), OperationKind::Build))
994 );
995 }
996 assert_eq!(
997 std::fs::read(cache_root.join("ingest-cursor.json")).unwrap(),
998 cursor_bytes,
999 "peeks left the cursor byte-identical"
1000 );
1001 assert_eq!(
1002 std::fs::read(cache_root.join("ingest-backoff.json")).unwrap(),
1003 backoff_bytes,
1004 "peeks left the backoff byte-identical"
1005 );
1006
1007 assert_eq!(
1009 select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any, true),
1010 Some(("m/b".to_string(), OperationKind::Build))
1011 );
1012 }
1013
1014 #[test]
1018 fn not_loop_declared_lists_undeclared_pairs() {
1019 let configs = configs_of(vec![
1020 record(
1022 "a",
1023 binding_with(Operations {
1024 build: Some(build_op(IngestTrigger::Loop)),
1025 sync: None,
1026 verify: None,
1027 }),
1028 ),
1029 record(
1031 "b",
1032 binding_with(Operations {
1033 build: Some(build_op(IngestTrigger::Manual)),
1034 sync: None,
1035 verify: None,
1036 }),
1037 ),
1038 ]);
1039 assert_eq!(
1040 not_loop_declared(&configs, OperationFilter::Only(OperationKind::Build)),
1041 vec![("m/b".to_string(), OperationKind::Build)]
1042 );
1043 let any = not_loop_declared(&configs, OperationFilter::Any);
1044 assert_eq!(
1045 any,
1046 vec![
1047 ("m/a".to_string(), OperationKind::Sync),
1048 ("m/a".to_string(), OperationKind::Verify),
1049 ("m/b".to_string(), OperationKind::Build),
1050 ("m/b".to_string(), OperationKind::Sync),
1051 ("m/b".to_string(), OperationKind::Verify),
1052 ]
1053 );
1054 }
1055}