1use std::path::{Path, PathBuf};
28
29use serde::Serialize;
30use serde::de::DeserializeOwned;
31
32use serde::Deserialize;
33
34use crate::binding::BindingV1;
35use crate::pipeline::{Facet, IngestTrigger, Medium, Projection};
36use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub(crate) struct LegacyIngest {
51 pub projection: String,
53 pub mode: LegacyIngestMode,
56 pub trigger: IngestTrigger,
58 pub batch_size: u32,
60 #[serde(default)]
62 pub deny_paths: Vec<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub post_actions: Option<serde_json::Value>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub(crate) enum LegacyIngestMode {
74 Discovery,
76 Refinement,
78 OneShot,
80}
81
82pub const MEDIUMS_DIR: &str = "mediums";
84pub const FACETS_DIR: &str = "facets";
86pub const PROJECTIONS_DIR: &str = "projections";
88pub const INGESTS_DIR: &str = "ingests";
90
91#[derive(Debug, Clone, PartialEq, Serialize)]
94pub struct MemPipelineRecord<T> {
95 pub mem: String,
97 pub name: String,
99 pub config: T,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize)]
105pub struct PipelineRecord<T> {
106 pub name: String,
108 pub config: T,
110}
111
112#[derive(Debug, Default, Clone, PartialEq, Serialize)]
120pub struct PipelineConfigs {
121 pub mediums: Vec<MemPipelineRecord<Medium>>,
123 pub facets: Vec<MemPipelineRecord<Facet>>,
125 pub projections: Vec<MemPipelineRecord<Projection>>,
127 pub(crate) ingests: Vec<PipelineRecord<LegacyIngest>>,
130}
131
132#[derive(Debug, Default, Clone, PartialEq, Serialize)]
138pub struct BindingConfigs {
139 pub mediums: Vec<MemPipelineRecord<Medium>>,
141 pub facets: Vec<MemPipelineRecord<Facet>>,
143 pub bindings: Vec<MemPipelineRecord<BindingV1>>,
146}
147
148fn primitive_dir(workspace_root: &Path, primitive: &str) -> PathBuf {
150 workspace_root.join(WORKSPACE_STORE_DIR).join(primitive)
151}
152
153fn validate_component(kind: &str, value: &str) -> Result<(), StoreError> {
160 let invalid = value.is_empty()
161 || value == "."
162 || value == ".."
163 || value.contains('/')
164 || value.contains('\\')
165 || value.contains(':')
166 || value.contains('\0');
167 if invalid {
168 return Err(StoreError::Other(format!(
169 "invalid {kind} '{}': must be a single path component \
170 (no separators, traversal segments, ':' or NUL)",
171 value.escape_default()
172 )));
173 }
174 Ok(())
175}
176
177fn mem_scoped_path(
179 workspace_root: &Path,
180 primitive: &str,
181 mem: &str,
182 name: &str,
183) -> Result<PathBuf, StoreError> {
184 validate_component("mem", mem)?;
185 validate_component("name", name)?;
186 Ok(primitive_dir(workspace_root, primitive)
187 .join(mem)
188 .join(format!("{name}.json")))
189}
190
191fn flat_path(workspace_root: &Path, primitive: &str, name: &str) -> Result<PathBuf, StoreError> {
193 validate_component("name", name)?;
194 Ok(primitive_dir(workspace_root, primitive).join(format!("{name}.json")))
195}
196
197fn remove_file(path: &Path) -> Result<(), StoreError> {
202 std::fs::remove_file(path).map_err(|e| StoreError::Io {
203 path: path.to_path_buf(),
204 source: e,
205 })
206}
207
208fn rename_file(from: &Path, to: &Path) -> Result<(), StoreError> {
214 if to.exists() {
215 return Err(StoreError::Other(format!(
216 "rename target already exists: {}",
217 to.display()
218 )));
219 }
220 std::fs::rename(from, to).map_err(|e| StoreError::Io {
221 path: from.to_path_buf(),
222 source: e,
223 })
224}
225
226fn write_json<T: Serialize>(path: &Path, config: &T) -> Result<(), StoreError> {
228 if let Some(parent) = path.parent() {
229 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
230 path: parent.to_path_buf(),
231 source: e,
232 })?;
233 }
234 let bytes = serde_json::to_vec_pretty(config).map_err(|e| StoreError::Parse {
235 path: path.to_path_buf(),
236 message: e.to_string(),
237 })?;
238 std::fs::write(path, bytes).map_err(|e| StoreError::Io {
239 path: path.to_path_buf(),
240 source: e,
241 })
242}
243
244fn load_mem_scoped<T: DeserializeOwned>(
248 workspace_root: &Path,
249 primitive: &str,
250) -> Result<Vec<MemPipelineRecord<T>>, StoreError> {
251 let dir = primitive_dir(workspace_root, primitive);
252 let mut out: Vec<MemPipelineRecord<T>> = Vec::new();
253 let mem_dirs = match std::fs::read_dir(&dir) {
254 Ok(rd) => rd,
255 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
256 Err(e) => {
257 return Err(StoreError::Io {
258 path: dir,
259 source: e,
260 });
261 }
262 };
263 for mem_entry in mem_dirs.flatten() {
264 let mem_path = mem_entry.path();
265 if !mem_path.is_dir() {
266 continue;
267 }
268 let mem = mem_entry.file_name().to_string_lossy().into_owned();
269 let files = match std::fs::read_dir(&mem_path) {
270 Ok(rd) => rd,
271 Err(e) => {
272 return Err(StoreError::Io {
273 path: mem_path,
274 source: e,
275 });
276 }
277 };
278 for file in files.flatten() {
279 let path = file.path();
280 if path.extension().and_then(|e| e.to_str()) != Some("json") {
281 continue;
282 }
283 let Some(name) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
284 continue;
285 };
286 let config = read_json::<T>(&path)?;
287 out.push(MemPipelineRecord {
288 mem: mem.clone(),
289 name,
290 config,
291 });
292 }
293 }
294 out.sort_by(|a, b| (a.mem.as_str(), a.name.as_str()).cmp(&(b.mem.as_str(), b.name.as_str())));
296 Ok(out)
297}
298
299fn load_flat<T: DeserializeOwned>(
301 workspace_root: &Path,
302 primitive: &str,
303) -> Result<Vec<PipelineRecord<T>>, StoreError> {
304 let dir = primitive_dir(workspace_root, primitive);
305 let mut out: Vec<PipelineRecord<T>> = Vec::new();
306 let files = match std::fs::read_dir(&dir) {
307 Ok(rd) => rd,
308 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
309 Err(e) => {
310 return Err(StoreError::Io {
311 path: dir,
312 source: e,
313 });
314 }
315 };
316 for file in files.flatten() {
317 let path = file.path();
318 if path.extension().and_then(|e| e.to_str()) != Some("json") {
319 continue;
320 }
321 let Some(name) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
322 continue;
323 };
324 let config = read_json::<T>(&path)?;
325 out.push(PipelineRecord { name, config });
326 }
327 out.sort_by(|a, b| a.name.cmp(&b.name));
328 Ok(out)
329}
330
331fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T, StoreError> {
334 let bytes = std::fs::read(path).map_err(|e| StoreError::Io {
335 path: path.to_path_buf(),
336 source: e,
337 })?;
338 serde_json::from_slice(&bytes).map_err(|e| StoreError::Parse {
339 path: path.to_path_buf(),
340 message: e.to_string(),
341 })
342}
343
344pub fn write_medium(
346 workspace_root: &Path,
347 mem: &str,
348 name: &str,
349 medium: &Medium,
350) -> Result<(), StoreError> {
351 write_json(
352 &mem_scoped_path(workspace_root, MEDIUMS_DIR, mem, name)?,
353 medium,
354 )
355}
356
357pub fn write_facet(
359 workspace_root: &Path,
360 mem: &str,
361 name: &str,
362 facet: &Facet,
363) -> Result<(), StoreError> {
364 write_json(
365 &mem_scoped_path(workspace_root, FACETS_DIR, mem, name)?,
366 facet,
367 )
368}
369
370pub fn write_projection(
372 workspace_root: &Path,
373 mem: &str,
374 name: &str,
375 projection: &Projection,
376) -> Result<(), StoreError> {
377 write_json(
378 &mem_scoped_path(workspace_root, PROJECTIONS_DIR, mem, name)?,
379 projection,
380 )
381}
382
383pub(crate) fn write_ingest(
387 workspace_root: &Path,
388 name: &str,
389 ingest: &LegacyIngest,
390) -> Result<(), StoreError> {
391 write_json(&flat_path(workspace_root, INGESTS_DIR, name)?, ingest)
392}
393
394pub fn write_binding(
402 workspace_root: &Path,
403 mem: &str,
404 name: &str,
405 binding: &BindingV1,
406) -> Result<(), StoreError> {
407 write_json(
408 &mem_scoped_path(workspace_root, PROJECTIONS_DIR, mem, name)?,
409 binding,
410 )
411}
412
413pub fn read_binding(workspace_root: &Path, mem: &str, name: &str) -> Result<BindingV1, StoreError> {
423 read_json(&mem_scoped_path(
424 workspace_root,
425 PROJECTIONS_DIR,
426 mem,
427 name,
428 )?)
429}
430
431pub fn delete_medium(workspace_root: &Path, mem: &str, name: &str) -> Result<(), StoreError> {
434 remove_file(&mem_scoped_path(workspace_root, MEDIUMS_DIR, mem, name)?)
435}
436
437pub fn delete_facet(workspace_root: &Path, mem: &str, name: &str) -> Result<(), StoreError> {
439 remove_file(&mem_scoped_path(workspace_root, FACETS_DIR, mem, name)?)
440}
441
442pub fn delete_projection(workspace_root: &Path, mem: &str, name: &str) -> Result<(), StoreError> {
444 remove_file(&mem_scoped_path(
445 workspace_root,
446 PROJECTIONS_DIR,
447 mem,
448 name,
449 )?)
450}
451
452pub fn delete_ingest(workspace_root: &Path, name: &str) -> Result<(), StoreError> {
454 remove_file(&flat_path(workspace_root, INGESTS_DIR, name)?)
455}
456
457pub fn rename_projection(
469 workspace_root: &Path,
470 mem: &str,
471 old: &str,
472 new: &str,
473) -> Result<(), StoreError> {
474 rename_file(
475 &mem_scoped_path(workspace_root, PROJECTIONS_DIR, mem, old)?,
476 &mem_scoped_path(workspace_root, PROJECTIONS_DIR, mem, new)?,
477 )
478}
479
480pub fn rename_ingest(workspace_root: &Path, old: &str, new: &str) -> Result<(), StoreError> {
484 rename_file(
485 &flat_path(workspace_root, INGESTS_DIR, old)?,
486 &flat_path(workspace_root, INGESTS_DIR, new)?,
487 )
488}
489
490pub fn load_legacy_pipeline_configs(workspace_root: &Path) -> Result<PipelineConfigs, StoreError> {
500 Ok(PipelineConfigs {
501 mediums: load_mem_scoped(workspace_root, MEDIUMS_DIR)?,
502 facets: load_mem_scoped(workspace_root, FACETS_DIR)?,
503 projections: load_mem_scoped(workspace_root, PROJECTIONS_DIR)?,
504 ingests: load_flat(workspace_root, INGESTS_DIR)?,
505 })
506}
507
508fn load_bindings(workspace_root: &Path) -> Result<Vec<MemPipelineRecord<BindingV1>>, StoreError> {
521 let dir = primitive_dir(workspace_root, PROJECTIONS_DIR);
522 let mut out: Vec<MemPipelineRecord<BindingV1>> = Vec::new();
523 let mem_dirs = match std::fs::read_dir(&dir) {
524 Ok(rd) => rd,
525 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
526 Err(e) => {
527 return Err(StoreError::Io {
528 path: dir,
529 source: e,
530 });
531 }
532 };
533 for mem_entry in mem_dirs.flatten() {
534 let mem_path = mem_entry.path();
535 if !mem_path.is_dir() {
536 continue;
537 }
538 let mem = mem_entry.file_name().to_string_lossy().into_owned();
539 let files = match std::fs::read_dir(&mem_path) {
540 Ok(rd) => rd,
541 Err(e) => {
542 return Err(StoreError::Io {
543 path: mem_path,
544 source: e,
545 });
546 }
547 };
548 for file in files.flatten() {
549 let path = file.path();
550 if path.extension().and_then(|e| e.to_str()) != Some("json") {
551 continue;
552 }
553 let Some(name) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
554 continue;
555 };
556 let value: serde_json::Value = read_json(&path)?;
560 match value.get("version") {
561 None => return Err(StoreError::LegacyProjectionStore { path }),
562 Some(v) => {
563 let n = v.as_i64();
564 if n != Some(i64::from(crate::binding::BINDING_VERSION)) {
565 return Err(StoreError::UnknownBindingVersion {
566 path,
567 version: n.unwrap_or(-1),
568 });
569 }
570 }
571 }
572 let config: BindingV1 =
573 serde_json::from_value(value).map_err(|e| StoreError::Parse {
574 path: path.clone(),
575 message: e.to_string(),
576 })?;
577 out.push(MemPipelineRecord {
578 mem: mem.clone(),
579 name,
580 config,
581 });
582 }
583 }
584 out.sort_by(|a, b| (a.mem.as_str(), a.name.as_str()).cmp(&(b.mem.as_str(), b.name.as_str())));
585 Ok(out)
586}
587
588pub fn load_pipeline_configs(workspace_root: &Path) -> Result<BindingConfigs, StoreError> {
596 Ok(BindingConfigs {
597 mediums: load_mem_scoped(workspace_root, MEDIUMS_DIR)?,
598 facets: load_mem_scoped(workspace_root, FACETS_DIR)?,
599 bindings: load_bindings(workspace_root)?,
600 })
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::pipeline::{MediumType, PatternEntry, PatternMode};
607 use tempfile::TempDir;
608
609 fn sample() -> (Medium, Facet, Projection, LegacyIngest) {
610 let medium = Medium {
611 name: "source-tree".to_string(),
612 medium_type: MediumType::Codebase,
613 pointer: "../macos".to_string(),
614 change_detection: None,
615 };
616 let facet = Facet {
617 name: "source-files".to_string(),
618 medium: "source-tree".to_string(),
619 scope: vec![PatternEntry {
620 path: "../macos/**/*.swift".to_string(),
621 mode: PatternMode::Allow,
622 }],
623 engagement: None,
624 preparation: None,
625 };
626 let projection = Projection {
627 intent: Some("Swift macOS app source.".to_string()),
628 source_facets: vec!["source-files".to_string()],
629 reference_mems: vec!["engine".to_string()],
630 destination_mem: "macos".to_string(),
631 rules: None,
632 };
633 let ingest = LegacyIngest {
634 projection: "macos/graph".to_string(),
635 mode: LegacyIngestMode::Discovery,
636 trigger: IngestTrigger::Loop,
637 batch_size: 20,
638 deny_paths: vec!["VISION.md".to_string()],
639 post_actions: None,
640 };
641 (medium, facet, projection, ingest)
642 }
643
644 #[test]
645 fn mutations_refuse_traversal_in_mem_and_name() {
646 let tmp = TempDir::new().unwrap();
650 let root = tmp.path();
651 let (medium, _, _, ingest) = sample();
652
653 let evil_values = [
654 "..",
655 ".",
656 "",
657 "../escape",
658 "a/b",
659 "a\\b",
660 "..\\up",
661 "c:evil",
662 "nul\0byte",
663 ];
664 for evil in evil_values {
665 assert!(
666 write_medium(root, evil, "ok", &medium).is_err(),
667 "mem '{}' must refuse",
668 evil.escape_default()
669 );
670 assert!(
671 write_medium(root, "ok", evil, &medium).is_err(),
672 "name '{}' must refuse",
673 evil.escape_default()
674 );
675 assert!(write_ingest(root, evil, &ingest).is_err());
676 assert!(delete_medium(root, evil, "ok").is_err());
677 assert!(delete_ingest(root, evil).is_err());
678 assert!(rename_projection(root, evil, "a", "b").is_err());
679 assert!(rename_projection(root, "ok", evil, "b").is_err());
680 assert!(rename_projection(root, "ok", "a", evil).is_err());
681 assert!(rename_ingest(root, evil, "b").is_err());
682 assert!(rename_ingest(root, "a", evil).is_err());
683 }
684
685 assert!(
689 !root.parent().unwrap().join("escape.json").exists(),
690 "no write may land outside the workspace"
691 );
692
693 write_medium(root, "macos", "source-tree", &medium).unwrap();
695 assert!(
696 root.join(".memstead/mediums/macos/source-tree.json")
697 .is_file()
698 );
699 }
700
701 #[test]
702 fn empty_store_loads_empty_configs() {
703 let tmp = TempDir::new().unwrap();
704 let configs = load_legacy_pipeline_configs(tmp.path()).unwrap();
705 assert_eq!(configs, PipelineConfigs::default());
706 }
707
708 #[test]
709 fn write_then_load_round_trips_all_four_primitives() {
710 let tmp = TempDir::new().unwrap();
711 let root = tmp.path();
712 let (medium, facet, projection, ingest) = sample();
713
714 write_medium(root, "macos", "source-tree", &medium).unwrap();
715 write_facet(root, "macos", "source-files", &facet).unwrap();
716 write_projection(root, "macos", "graph", &projection).unwrap();
717 write_ingest(root, "macos-graph", &ingest).unwrap();
718
719 assert!(
721 root.join(".memstead/mediums/macos/source-tree.json")
722 .is_file()
723 );
724 assert!(
725 root.join(".memstead/facets/macos/source-files.json")
726 .is_file()
727 );
728 assert!(
729 root.join(".memstead/projections/macos/graph.json")
730 .is_file()
731 );
732 assert!(root.join(".memstead/ingests/macos-graph.json").is_file());
733
734 let configs = load_legacy_pipeline_configs(root).unwrap();
735 assert_eq!(configs.mediums.len(), 1);
736 assert_eq!(configs.mediums[0].mem, "macos");
737 assert_eq!(configs.mediums[0].name, "source-tree");
738 assert_eq!(configs.mediums[0].config, medium);
739 assert_eq!(configs.facets[0].config, facet);
740 assert_eq!(configs.projections[0].config, projection);
741 assert_eq!(configs.ingests.len(), 1);
742 assert_eq!(configs.ingests[0].name, "macos-graph");
743 assert_eq!(configs.ingests[0].config, ingest);
744 }
745
746 #[test]
747 fn load_enumeration_is_sorted_and_per_mem() {
748 let tmp = TempDir::new().unwrap();
749 let root = tmp.path();
750 let (medium, _, _, _) = sample();
751 write_medium(root, "engine", "z-medium", &medium).unwrap();
752 write_medium(root, "engine", "a-medium", &medium).unwrap();
753 write_medium(root, "macos", "m-medium", &medium).unwrap();
754
755 let configs = load_legacy_pipeline_configs(root).unwrap();
756 let keys: Vec<_> = configs
757 .mediums
758 .iter()
759 .map(|r| (r.mem.as_str(), r.name.as_str()))
760 .collect();
761 assert_eq!(
762 keys,
763 vec![
764 ("engine", "a-medium"),
765 ("engine", "z-medium"),
766 ("macos", "m-medium"),
767 ]
768 );
769 }
770
771 #[test]
772 fn malformed_config_surfaces_typed_parse_error_naming_the_file() {
773 let tmp = TempDir::new().unwrap();
774 let root = tmp.path();
775 let bad = root.join(".memstead/mediums/macos");
776 std::fs::create_dir_all(&bad).unwrap();
777 std::fs::write(bad.join("broken.json"), b"{ not valid json").unwrap();
778
779 let err = load_legacy_pipeline_configs(root).unwrap_err();
780 match err {
781 StoreError::Parse { path, .. } => {
782 assert!(path.ends_with("broken.json"), "got {path:?}");
783 }
784 other => panic!("expected Parse error, got {other:?}"),
785 }
786 }
787
788 #[test]
789 fn delete_removes_the_record_and_load_reflects_it() {
790 let tmp = TempDir::new().unwrap();
791 let root = tmp.path();
792 let (medium, _, _, ingest) = sample();
793 write_medium(root, "macos", "source-tree", &medium).unwrap();
794 write_ingest(root, "macos-graph", &ingest).unwrap();
795
796 delete_medium(root, "macos", "source-tree").unwrap();
797 delete_ingest(root, "macos-graph").unwrap();
798
799 assert!(
800 !root
801 .join(".memstead/mediums/macos/source-tree.json")
802 .exists()
803 );
804 assert!(!root.join(".memstead/ingests/macos-graph.json").exists());
805 let configs = load_legacy_pipeline_configs(root).unwrap();
806 assert!(configs.mediums.is_empty());
807 assert!(configs.ingests.is_empty());
808 }
809
810 #[test]
811 fn delete_of_missing_record_surfaces_io_error() {
812 let tmp = TempDir::new().unwrap();
813 let err = delete_medium(tmp.path(), "macos", "nope").unwrap_err();
814 match err {
815 StoreError::Io { source, .. } => {
816 assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
817 }
818 other => panic!("expected Io error, got {other:?}"),
819 }
820 }
821
822 #[test]
823 fn rename_moves_the_record_preserving_config() {
824 let tmp = TempDir::new().unwrap();
825 let root = tmp.path();
826 let (_, _, projection, _) = sample();
827 write_projection(root, "macos", "old-name", &projection).unwrap();
828
829 rename_projection(root, "macos", "old-name", "new-name").unwrap();
830
831 assert!(
832 !root
833 .join(".memstead/projections/macos/old-name.json")
834 .exists()
835 );
836 let configs = load_legacy_pipeline_configs(root).unwrap();
837 assert_eq!(configs.projections.len(), 1);
838 assert_eq!(configs.projections[0].name, "new-name");
839 assert_eq!(configs.projections[0].config, projection);
840 }
841
842 #[test]
843 fn rename_refuses_to_clobber_an_existing_target() {
844 let tmp = TempDir::new().unwrap();
845 let root = tmp.path();
846 let (_, _, projection, _) = sample();
847 write_projection(root, "macos", "a", &projection).unwrap();
848 write_projection(root, "macos", "b", &projection).unwrap();
849
850 let err = rename_projection(root, "macos", "a", "b").unwrap_err();
851 assert!(matches!(err, StoreError::Other(_)), "got {err:?}");
852 assert!(root.join(".memstead/projections/macos/a.json").exists());
854 assert!(root.join(".memstead/projections/macos/b.json").exists());
855 }
856
857 #[test]
858 fn rename_of_missing_source_surfaces_io_error() {
859 let tmp = TempDir::new().unwrap();
860 let err = rename_ingest(tmp.path(), "missing", "whatever").unwrap_err();
861 assert!(matches!(err, StoreError::Io { .. }), "got {err:?}");
862 }
863
864 fn sample_binding() -> BindingV1 {
867 use crate::binding::{
868 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, Operations,
869 };
870 use crate::pipeline::IngestTrigger;
871 BindingV1 {
872 version: BINDING_VERSION,
873 intent: Some("prose".to_string()),
874 source_facets: vec!["source-tree".to_string()],
875 reference_mems: vec![],
876 destination_mem: "engine".to_string(),
877 deny_paths: vec![],
878 coverage_semantics: CoverageSemantics::Exhaustive,
879 rules: None,
880 prune: None,
881 operations: Operations {
882 build: Some(BuildOperation {
883 mode: BuildMode::Discovery,
884 trigger: IngestTrigger::Loop,
885 batch_size: 20,
886 post_actions: None,
887 }),
888 sync: None,
889 verify: None,
890 },
891 }
892 }
893
894 #[test]
895 fn empty_store_loads_empty_binding_configs() {
896 let tmp = TempDir::new().unwrap();
897 let configs = load_pipeline_configs(tmp.path()).unwrap();
898 assert_eq!(configs, BindingConfigs::default());
899 }
900
901 #[test]
902 fn binding_loader_round_trips_a_v1_binding() {
903 let tmp = TempDir::new().unwrap();
904 let root = tmp.path();
905 let binding = sample_binding();
906 write_binding(root, "engine", "graph", &binding).unwrap();
907
908 let configs = load_pipeline_configs(root).unwrap();
909 assert_eq!(configs.bindings.len(), 1);
910 assert_eq!(configs.bindings[0].mem, "engine");
911 assert_eq!(configs.bindings[0].name, "graph");
912 assert_eq!(configs.bindings[0].config, binding);
913 }
914
915 #[test]
916 fn version_less_projection_refuses_with_migrate_naming_error() {
917 let tmp = TempDir::new().unwrap();
918 let root = tmp.path();
919 let projection = Projection {
921 intent: Some("legacy".to_string()),
922 source_facets: vec!["f".to_string()],
923 reference_mems: vec![],
924 destination_mem: "engine".to_string(),
925 rules: None,
926 };
927 write_projection(root, "engine", "graph", &projection).unwrap();
928
929 let err = load_pipeline_configs(root).unwrap_err();
930 match err {
931 StoreError::LegacyProjectionStore { path } => {
932 assert!(path.ends_with("graph.json"), "got {path:?}");
933 assert!(
934 err_message(&StoreError::LegacyProjectionStore { path })
935 .contains("memstead projection migrate")
936 );
937 }
938 other => panic!("expected LegacyProjectionStore, got {other:?}"),
939 }
940 }
941
942 #[test]
943 fn unknown_binding_version_refuses() {
944 let tmp = TempDir::new().unwrap();
945 let root = tmp.path();
946 let dir = root.join(".memstead/projections/engine");
947 std::fs::create_dir_all(&dir).unwrap();
948 std::fs::write(
949 dir.join("graph.json"),
950 br#"{"version": 99, "destination_mem": "engine", "operations": {"build": {"mode": "discovery", "trigger": "loop", "batch_size": 20}}}"#,
951 )
952 .unwrap();
953
954 let err = load_pipeline_configs(root).unwrap_err();
955 assert!(
956 matches!(err, StoreError::UnknownBindingVersion { version: 99, .. }),
957 "got {err:?}"
958 );
959 }
960
961 fn err_message(e: &StoreError) -> String {
962 e.to_string()
963 }
964}