1use std::path::Path;
27
28use serde::Deserialize;
29
30use crate::binding::{
31 BINDING_VERSION, Binding, BuildMode, BuildOperation, CapabilityError, CoverageSemantics,
32 Operations, PruneConfig, validate_binding,
33};
34use crate::engine::Engine;
35use crate::pipeline::{IngestTrigger, Source};
36use crate::pipeline_store::{self, BindingConfigs};
37use crate::workspace_store::StoreError;
38
39#[derive(Debug, thiserror::Error)]
43pub enum PipelineEditError {
44 #[error("engine has no workspace root — pipeline edits require a workspace-backed engine")]
48 NoWorkspaceRoot,
49 #[error("pipeline edit landed, but recording provenance failed: {0}")]
55 Provenance(String),
56 #[error("{primitive} '{key}' already exists")]
58 AlreadyExists {
59 primitive: &'static str,
60 key: String,
61 },
62 #[error("{primitive} '{key}' does not exist")]
64 NotFound {
65 primitive: &'static str,
66 key: String,
67 },
68 #[error("rename target {primitive} '{key}' already exists")]
70 RenameTargetExists {
71 primitive: &'static str,
72 key: String,
73 },
74 #[error("invalid {primitive} JSON: {message}")]
77 InvalidJson {
78 primitive: &'static str,
79 message: String,
80 },
81 #[error(
88 "binding '{key}' edit refused by validation: {}",
89 format_refusals(refusals)
90 )]
91 Capability {
92 key: String,
93 refusals: Vec<CapabilityError>,
94 },
95 #[error(transparent)]
97 Store(#[from] StoreError),
98}
99
100fn key(mem: &str, name: &str) -> String {
101 format!("{mem}/{name}")
102}
103
104fn format_refusals(refusals: &[CapabilityError]) -> String {
105 refusals
106 .iter()
107 .map(|r| r.to_string())
108 .collect::<Vec<_>>()
109 .join("; ")
110}
111
112fn binding_exists(c: &BindingConfigs, mem: &str, name: &str) -> bool {
113 c.bindings.iter().any(|r| r.mem == mem && r.name == name)
114}
115
116fn patch_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
123where
124 D: serde::Deserializer<'de>,
125 T: serde::Deserialize<'de>,
126{
127 Option::<T>::deserialize(deserializer).map(Some)
128}
129
130#[derive(Debug, Default, Deserialize)]
147pub struct BindingPatch {
148 #[serde(default, deserialize_with = "patch_field")]
150 pub intent: Option<Option<String>>,
151 #[serde(default)]
153 pub sources: Option<Vec<Source>>,
154 #[serde(default)]
156 pub reference_mems: Option<Vec<String>>,
157 #[serde(default)]
160 pub destination_mem: Option<String>,
161 #[serde(default)]
163 pub deny_paths: Option<Vec<String>>,
164 #[serde(default)]
170 pub coverage_semantics: Option<CoverageSemantics>,
171 #[serde(default, deserialize_with = "patch_field")]
173 pub rules: Option<Option<serde_json::Value>>,
174 #[serde(default, deserialize_with = "patch_field")]
176 pub prune: Option<Option<PruneConfig>>,
177 #[serde(default)]
179 pub operations: Option<Operations>,
180}
181
182impl BindingPatch {
183 fn apply(self, binding: &mut Binding) {
186 if let Some(v) = self.intent {
187 binding.intent = v;
188 }
189 if let Some(v) = self.sources {
190 binding.sources = v;
191 }
192 if let Some(v) = self.reference_mems {
193 binding.reference_mems = v;
194 }
195 if let Some(v) = self.destination_mem {
196 binding.destination_mem = v;
197 }
198 if let Some(v) = self.deny_paths {
199 binding.deny_paths = v;
200 }
201 if let Some(v) = self.coverage_semantics {
202 binding.coverage_semantics = Some(v);
203 }
204 if let Some(v) = self.rules {
205 binding.rules = v;
206 }
207 if let Some(v) = self.prune {
208 binding.prune = v;
209 }
210 if let Some(v) = self.operations {
211 binding.operations = v;
212 }
213 }
214}
215
216fn default_binding_scaffold() -> Binding {
220 Binding {
221 version: BINDING_VERSION,
222 intent: None,
223 sources: Vec::new(),
224 reference_mems: Vec::new(),
225 destination_mem: String::new(),
226 deny_paths: Vec::new(),
227 coverage_semantics: None,
230 rules: None,
231 prune: None,
232 operations: Operations {
233 build: Some(BuildOperation {
234 mode: BuildMode::Discovery,
235 trigger: IngestTrigger::Loop,
236 batch_size: 20,
237 post_actions: None,
238 }),
239 sync: None,
240 verify: None,
241 },
242 }
243}
244
245pub fn add_binding_json(
254 root: &Path,
255 mem: &str,
256 name: &str,
257 patch_json: &str,
258) -> Result<Binding, PipelineEditError> {
259 let configs = pipeline_store::load_pipeline_configs_strict(root)?;
260 if binding_exists(&configs, mem, name) {
261 return Err(PipelineEditError::AlreadyExists {
262 primitive: "projection",
263 key: key(mem, name),
264 });
265 }
266 let patch: BindingPatch = parse_json(patch_json, "projection")?;
267 let mut binding = default_binding_scaffold();
268 patch.apply(&mut binding);
269 if binding.destination_mem.is_empty() {
270 return Err(PipelineEditError::InvalidJson {
271 primitive: "projection",
272 message: "destination_mem is required".to_string(),
273 });
274 }
275 if let Err(refusals) = validate_binding(&binding) {
276 return Err(PipelineEditError::Capability {
277 key: key(mem, name),
278 refusals,
279 });
280 }
281 pipeline_store::write_binding(root, mem, name, &binding)?;
282 Ok(binding)
283}
284
285pub fn update_binding_json(
300 root: &Path,
301 mem: &str,
302 name: &str,
303 patch_json: &str,
304) -> Result<Binding, PipelineEditError> {
305 let configs = pipeline_store::load_pipeline_configs_strict(root)?;
306 if !binding_exists(&configs, mem, name) {
307 return Err(PipelineEditError::NotFound {
308 primitive: "projection",
309 key: key(mem, name),
310 });
311 }
312 let patch: BindingPatch = parse_json(patch_json, "projection")?;
313 let existing = pipeline_store::read_binding(root, mem, name)?;
314 let mut patched = existing.clone();
315 patch.apply(&mut patched);
316 if let Err(refusals) = validate_binding(&patched) {
317 let before = validate_binding(&existing).err().unwrap_or_default();
318 let introduced: Vec<CapabilityError> = refusals
319 .into_iter()
320 .filter(|r| !before.contains(r))
321 .collect();
322 if !introduced.is_empty() {
323 return Err(PipelineEditError::Capability {
324 key: key(mem, name),
325 refusals: introduced,
326 });
327 }
328 }
329 pipeline_store::write_binding(root, mem, name, &patched)?;
330 Ok(patched)
331}
332
333pub fn delete_binding(root: &Path, mem: &str, name: &str) -> Result<(), PipelineEditError> {
338 let configs = pipeline_store::load_pipeline_configs_strict(root)?;
339 if !binding_exists(&configs, mem, name) {
340 return Err(PipelineEditError::NotFound {
341 primitive: "projection",
342 key: key(mem, name),
343 });
344 }
345 pipeline_store::delete_projection(root, mem, name)?;
346 Ok(())
347}
348
349pub fn rename_binding(
353 root: &Path,
354 mem: &str,
355 old: &str,
356 new: &str,
357) -> Result<(), PipelineEditError> {
358 if old == new {
359 return Ok(());
360 }
361 let configs = pipeline_store::load_pipeline_configs_strict(root)?;
362 if !binding_exists(&configs, mem, old) {
363 return Err(PipelineEditError::NotFound {
364 primitive: "projection",
365 key: key(mem, old),
366 });
367 }
368 if binding_exists(&configs, mem, new) {
369 return Err(PipelineEditError::RenameTargetExists {
370 primitive: "projection",
371 key: key(mem, new),
372 });
373 }
374 pipeline_store::rename_projection(root, mem, old, new)?;
375 Ok(())
376}
377
378impl Engine {
387 fn pipeline_edit_root(&self) -> Result<std::path::PathBuf, PipelineEditError> {
388 self.workspace_root()
389 .map(Path::to_path_buf)
390 .ok_or(PipelineEditError::NoWorkspaceRoot)
391 }
392
393 fn refresh_pipeline_configs(&mut self, root: &Path) -> Result<(), PipelineEditError> {
394 self.set_pipeline_configs(pipeline_store::load_pipeline_configs_strict(root)?);
395 Ok(())
396 }
397
398 fn pipeline_provenance(
403 &self,
404 mem: &str,
405 kind: &str,
406 edits: &[(String, Option<Vec<u8>>)],
407 note: Option<&str>,
408 verb: &str,
409 ) -> Result<(), PipelineEditError> {
410 self.record_pipeline_edit_provenance(mem, kind, edits, note, verb)
411 .map_err(|e| PipelineEditError::Provenance(e.to_string()))
412 }
413
414 pub fn add_projection_json(
421 &mut self,
422 mem: &str,
423 name: &str,
424 projection_json: &str,
425 note: Option<&str>,
426 ) -> Result<(), PipelineEditError> {
427 let root = self.pipeline_edit_root()?;
428 let binding = add_binding_json(&root, mem, name, projection_json)?;
429 self.record_binding_edit(mem, name, &binding, &root, note, "add")
430 }
431
432 pub fn update_projection_json(
439 &mut self,
440 mem: &str,
441 name: &str,
442 projection_json: &str,
443 note: Option<&str>,
444 ) -> Result<(), PipelineEditError> {
445 let root = self.pipeline_edit_root()?;
446 let binding = update_binding_json(&root, mem, name, projection_json)?;
447 self.record_binding_edit(mem, name, &binding, &root, note, "update")
448 }
449
450 pub fn delete_projection(
452 &mut self,
453 mem: &str,
454 name: &str,
455 note: Option<&str>,
456 ) -> Result<(), PipelineEditError> {
457 let root = self.pipeline_edit_root()?;
458 delete_binding(&root, mem, name)?;
459 self.pipeline_provenance(
460 mem,
461 "projections",
462 &[(name.to_string(), None)],
463 note,
464 "delete",
465 )?;
466 self.refresh_pipeline_configs(&root)
467 }
468
469 pub fn rename_projection(
471 &mut self,
472 mem: &str,
473 old: &str,
474 new: &str,
475 note: Option<&str>,
476 ) -> Result<(), PipelineEditError> {
477 let root = self.pipeline_edit_root()?;
478 rename_binding(&root, mem, old, new)?;
479 let bytes = self
484 .pipeline_configs()
485 .bindings
486 .iter()
487 .find(|r| r.mem == mem && r.name == old)
488 .map(|r| serde_json::to_vec_pretty(&r.config))
489 .transpose()
490 .map_err(|e| PipelineEditError::InvalidJson {
491 primitive: "config",
492 message: e.to_string(),
493 })?;
494 self.pipeline_provenance(
495 mem,
496 "projections",
497 &[(old.to_string(), None), (new.to_string(), bytes)],
498 note,
499 "rename",
500 )?;
501 self.refresh_pipeline_configs(&root)
502 }
503
504 fn record_binding_edit(
508 &mut self,
509 mem: &str,
510 name: &str,
511 binding: &Binding,
512 root: &Path,
513 note: Option<&str>,
514 verb: &str,
515 ) -> Result<(), PipelineEditError> {
516 let bytes =
517 serde_json::to_vec_pretty(binding).map_err(|e| PipelineEditError::InvalidJson {
518 primitive: "config",
519 message: e.to_string(),
520 })?;
521 self.pipeline_provenance(
522 mem,
523 "projections",
524 &[(name.to_string(), Some(bytes))],
525 note,
526 verb,
527 )?;
528 self.refresh_pipeline_configs(root)
529 }
530}
531
532fn parse_json<T: serde::de::DeserializeOwned>(
535 json: &str,
536 primitive: &'static str,
537) -> Result<T, PipelineEditError> {
538 serde_json::from_str(json).map_err(|e| PipelineEditError::InvalidJson {
539 primitive,
540 message: e.to_string(),
541 })
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::binding::{PruneGuarantee, SyncOperation};
548 use tempfile::TempDir;
549
550 const BASE_PAYLOAD: &str = r#"{
552 "intent": "i",
553 "sources": [{
554 "name": "f",
555 "type": "codebase",
556 "pointer": "../src",
557 "scope": [{ "path": "**/*.rs", "mode": "allow" }]
558 }],
559 "reference_mems": [],
560 "destination_mem": "v",
561 "rules": { "routing": "r" }
562 }"#;
563
564 fn full_binding_payload() -> &'static str {
567 r#"{
568 "intent": "i",
569 "sources": [{
570 "name": "f",
571 "type": "codebase",
572 "pointer": "../src",
573 "scope": [{ "path": "**/*.rs", "mode": "allow" }]
574 }],
575 "reference_mems": ["r"],
576 "destination_mem": "v",
577 "deny_paths": ["dev/**"],
578 "coverage_semantics": "curated",
579 "rules": { "routing": "r" },
580 "prune": { "guarantee": "never-clobber" },
581 "operations": {
582 "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
583 "sync": { "trigger": "manual", "batch_size": 20 },
584 "verify": { "trigger": "manual", "batch_size": 20 }
585 }
586 }"#
587 }
588
589 #[test]
592 fn add_binding_json_scaffolds_default_build() {
593 let tmp = TempDir::new().unwrap();
594 let root = tmp.path();
595 let b = add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap();
596 assert_eq!(b.version, BINDING_VERSION);
597 let build = b.operations.build.as_ref().unwrap();
598 assert_eq!(build.mode, BuildMode::Discovery);
599 assert_eq!(build.batch_size, 20);
600 assert!(b.operations.sync.is_none() && b.operations.verify.is_none());
601 assert_eq!(b.rules, Some(serde_json::json!({ "routing": "r" })));
602 assert_eq!(b.sources.len(), 1);
603 assert_eq!(b.sources[0].name, "f");
604 assert_eq!(pipeline_store::read_binding(root, "v", "p").unwrap(), b);
605 }
606
607 #[test]
610 fn add_binding_json_accepts_the_full_record() {
611 let tmp = TempDir::new().unwrap();
612 let root = tmp.path();
613 let b = add_binding_json(root, "v", "p", full_binding_payload()).unwrap();
614 assert_eq!(
615 b.operations.build.as_ref().unwrap().mode,
616 BuildMode::Discovery
617 );
618 assert_eq!(b.operations.sync.as_ref().unwrap().batch_size, 20);
619 assert!(b.operations.verify.is_some());
620 assert_eq!(b.deny_paths, vec!["dev/**"]);
621 assert_eq!(b.coverage_semantics, Some(CoverageSemantics::Curated));
622 assert_eq!(
623 b.prune.as_ref().unwrap().guarantee,
624 PruneGuarantee::NeverClobber
625 );
626 }
627
628 #[test]
629 fn add_binding_json_refuses_duplicate() {
630 let tmp = TempDir::new().unwrap();
631 let root = tmp.path();
632 add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap();
633 let err = add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap_err();
634 assert!(
635 matches!(err, PipelineEditError::AlreadyExists { .. }),
636 "got {err:?}"
637 );
638 }
639
640 #[test]
641 fn add_binding_json_requires_destination_mem() {
642 let tmp = TempDir::new().unwrap();
643 let err = add_binding_json(tmp.path(), "v", "p", r#"{"intent":"i"}"#).unwrap_err();
644 match err {
645 PipelineEditError::InvalidJson { message, .. } => {
646 assert!(message.contains("destination_mem"), "got: {message}")
647 }
648 other => panic!("expected InvalidJson, got {other:?}"),
649 }
650 }
651
652 #[test]
655 fn add_binding_json_refuses_duplicate_source_names() {
656 let tmp = TempDir::new().unwrap();
657 let root = tmp.path();
658 let err = add_binding_json(
659 root,
660 "v",
661 "p",
662 r#"{
663 "sources": [
664 { "name": "dup", "type": "codebase", "pointer": "../a" },
665 { "name": "dup", "type": "codebase", "pointer": "../b" }
666 ],
667 "destination_mem": "v"
668 }"#,
669 )
670 .unwrap_err();
671 match err {
672 PipelineEditError::Capability { refusals, .. } => {
673 assert!(
674 refusals.iter().any(|r| matches!(
675 r,
676 CapabilityError::DuplicateSourceName { name } if name == "dup"
677 )),
678 "expected DuplicateSourceName, got {refusals:?}"
679 );
680 }
681 other => panic!("expected Capability, got {other:?}"),
682 }
683 assert!(!root.join(".memstead/projections/v/p.json").exists());
684 }
685
686 #[test]
690 fn add_binding_json_refuses_capability_violation() {
691 let tmp = TempDir::new().unwrap();
692 let root = tmp.path();
693 let err = add_binding_json(
694 root,
695 "v",
696 "p",
697 r#"{
698 "sources": [{ "name": "wf", "type": "web", "pointer": "https://example.com" }],
699 "destination_mem": "v",
700 "operations": {
701 "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
702 "sync": { "trigger": "manual", "batch_size": 20 }
703 }
704 }"#,
705 )
706 .unwrap_err();
707 match &err {
708 PipelineEditError::Capability { refusals, .. } => {
709 assert!(
710 refusals.iter().any(|r| matches!(
711 r,
712 CapabilityError::OperationOutOfScope { operation, .. } if *operation == "sync"
713 )),
714 "expected an OperationOutOfScope(sync) refusal, got {refusals:?}"
715 );
716 }
717 other => panic!("expected Capability, got {other:?}"),
718 }
719 assert!(pipeline_store::read_binding(root, "v", "p").is_err());
720 }
721
722 #[test]
725 fn update_binding_json_patch_preserves_untouched_fields() {
726 let tmp = TempDir::new().unwrap();
727 let root = tmp.path();
728 let before = add_binding_json(root, "v", "p", full_binding_payload()).unwrap();
729 let after = update_binding_json(root, "v", "p", r#"{"intent":"new"}"#).unwrap();
730 assert_eq!(after.intent.as_deref(), Some("new"));
731 assert_eq!(after.version, before.version);
732 assert_eq!(after.sources, before.sources);
733 assert_eq!(after.reference_mems, before.reference_mems);
734 assert_eq!(after.destination_mem, before.destination_mem);
735 assert_eq!(after.deny_paths, before.deny_paths);
736 assert_eq!(after.coverage_semantics, before.coverage_semantics);
737 assert_eq!(after.rules, before.rules);
738 assert_eq!(after.prune, before.prune);
739 assert_eq!(after.operations, before.operations);
740 }
741
742 #[test]
745 fn update_binding_json_null_clears_and_absence_preserves() {
746 let tmp = TempDir::new().unwrap();
747 let root = tmp.path();
748 add_binding_json(root, "v", "p", full_binding_payload()).unwrap();
749
750 let untouched = update_binding_json(root, "v", "p", r#"{"deny_paths":[]}"#).unwrap();
752 assert!(untouched.rules.is_some() && untouched.prune.is_some());
753 assert_eq!(untouched.intent.as_deref(), Some("i"));
754 assert!(untouched.deny_paths.is_empty());
755
756 let cleared =
758 update_binding_json(root, "v", "p", r#"{"rules": null, "prune": null}"#).unwrap();
759 assert!(cleared.rules.is_none() && cleared.prune.is_none());
760 assert_eq!(cleared.intent.as_deref(), Some("i"), "intent untouched");
761 assert_eq!(
762 pipeline_store::read_binding(root, "v", "p").unwrap(),
763 cleared
764 );
765 }
766
767 #[test]
771 fn update_binding_json_replaces_whole_blocks() {
772 let tmp = TempDir::new().unwrap();
773 let root = tmp.path();
774 add_binding_json(root, "v", "p", full_binding_payload()).unwrap();
775 let after = update_binding_json(
776 root,
777 "v",
778 "p",
779 r#"{"operations": { "build": { "mode": "discovery", "trigger": "manual", "batch_size": 9 } }}"#,
780 )
781 .unwrap();
782 assert_eq!(after.operations.build.as_ref().unwrap().batch_size, 9);
783 assert!(
784 after.operations.sync.is_none(),
785 "sync removed with the block"
786 );
787 assert!(
788 after.operations.verify.is_none(),
789 "verify removed with the block"
790 );
791 assert_eq!(after.rules, Some(serde_json::json!({ "routing": "r" })));
792
793 let after = update_binding_json(
794 root,
795 "v",
796 "p",
797 r#"{"sources": [{ "name": "g", "type": "filesystem", "pointer": "../docs" }]}"#,
798 )
799 .unwrap();
800 assert_eq!(after.sources.len(), 1);
801 assert_eq!(after.sources[0].name, "g");
802 }
803
804 #[test]
807 fn update_binding_json_refuses_introduced_capability_violation() {
808 let tmp = TempDir::new().unwrap();
809 let root = tmp.path();
810 let before = add_binding_json(
811 root,
812 "v",
813 "p",
814 r#"{
815 "sources": [{ "name": "wf", "type": "web", "pointer": "https://example.com" }],
816 "destination_mem": "v"
817 }"#,
818 )
819 .unwrap();
820 let err = update_binding_json(
821 root,
822 "v",
823 "p",
824 r#"{"operations": {
825 "build": { "mode": "discovery", "trigger": "loop", "batch_size": 20 },
826 "sync": { "trigger": "manual", "batch_size": 20 }
827 }}"#,
828 )
829 .unwrap_err();
830 assert!(
831 matches!(err, PipelineEditError::Capability { .. }),
832 "got {err:?}"
833 );
834 assert_eq!(
835 pipeline_store::read_binding(root, "v", "p").unwrap(),
836 before,
837 "record unchanged on refusal"
838 );
839 }
840
841 #[test]
844 fn update_binding_json_allows_edit_despite_preexisting_refusal() {
845 let tmp = TempDir::new().unwrap();
846 let root = tmp.path();
847 let broken = Binding {
850 version: BINDING_VERSION,
851 intent: None,
852 sources: vec![Source {
853 name: "wf".to_string(),
854 medium_type: crate::pipeline::MediumType::Web,
855 pointer: "https://example.com".to_string(),
856 change_detection: None,
857 scope: vec![],
858 engagement: None,
859 preparation: None,
860 }],
861 reference_mems: vec![],
862 destination_mem: "v".to_string(),
863 deny_paths: vec![],
864 coverage_semantics: None,
865 rules: None,
866 prune: None,
867 operations: Operations {
868 build: Some(BuildOperation {
869 mode: BuildMode::Discovery,
870 trigger: IngestTrigger::Loop,
871 batch_size: 20,
872 post_actions: None,
873 }),
874 sync: Some(SyncOperation {
875 trigger: IngestTrigger::Manual,
876 batch_size: 20,
877 }),
878 verify: None,
879 },
880 };
881 pipeline_store::write_binding(root, "v", "p", &broken).unwrap();
882
883 let after = update_binding_json(root, "v", "p", r#"{"intent":"fixed"}"#).unwrap();
884 assert_eq!(after.intent.as_deref(), Some("fixed"));
885 assert!(
886 after.operations.sync.is_some(),
887 "pre-existing sync survives"
888 );
889
890 let err = update_binding_json(
892 root,
893 "v",
894 "p",
895 r#"{"sources": [
896 { "name": "dup", "type": "web", "pointer": "https://a" },
897 { "name": "dup", "type": "web", "pointer": "https://b" }
898 ]}"#,
899 )
900 .unwrap_err();
901 assert!(
902 matches!(err, PipelineEditError::Capability { .. }),
903 "got {err:?}"
904 );
905 }
906
907 #[test]
908 fn update_binding_json_refuses_missing_record() {
909 let tmp = TempDir::new().unwrap();
910 let err = update_binding_json(tmp.path(), "v", "p", r#"{"intent":"x"}"#).unwrap_err();
911 assert!(
912 matches!(err, PipelineEditError::NotFound { .. }),
913 "got {err:?}"
914 );
915 }
916
917 #[test]
920 fn update_binding_json_ignores_unknown_keys_and_version() {
921 let tmp = TempDir::new().unwrap();
922 let root = tmp.path();
923 add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap();
924 let after = update_binding_json(
925 root,
926 "v",
927 "p",
928 r#"{"version": 99, "future_key": { "x": 1 }, "intent": "i2"}"#,
929 )
930 .unwrap();
931 assert_eq!(
932 after.version, BINDING_VERSION,
933 "version stays engine-managed"
934 );
935 assert_eq!(after.intent.as_deref(), Some("i2"));
936 }
937
938 #[test]
939 fn delete_binding_removes_the_record() {
940 let tmp = TempDir::new().unwrap();
941 let root = tmp.path();
942 add_binding_json(root, "v", "p", BASE_PAYLOAD).unwrap();
943 delete_binding(root, "v", "p").unwrap();
944 assert!(!root.join(".memstead/projections/v/p.json").exists());
945 let err = delete_binding(root, "v", "p").unwrap_err();
946 assert!(
947 matches!(err, PipelineEditError::NotFound { .. }),
948 "got {err:?}"
949 );
950 }
951
952 #[test]
953 fn rename_binding_moves_the_record() {
954 let tmp = TempDir::new().unwrap();
955 let root = tmp.path();
956 let created = add_binding_json(root, "v", "old", BASE_PAYLOAD).unwrap();
957 rename_binding(root, "v", "old", "new").unwrap();
958 assert!(!root.join(".memstead/projections/v/old.json").exists());
959 assert_eq!(
960 pipeline_store::read_binding(root, "v", "new").unwrap(),
961 created
962 );
963 }
964
965 #[test]
966 fn rename_binding_refuses_existing_target_and_missing_source() {
967 let tmp = TempDir::new().unwrap();
968 let root = tmp.path();
969 add_binding_json(root, "v", "a", BASE_PAYLOAD).unwrap();
970 add_binding_json(root, "v", "b", BASE_PAYLOAD).unwrap();
971 let err = rename_binding(root, "v", "a", "b").unwrap_err();
972 assert!(
973 matches!(err, PipelineEditError::RenameTargetExists { .. }),
974 "got {err:?}"
975 );
976 let err = rename_binding(root, "v", "missing", "c").unwrap_err();
977 assert!(
978 matches!(err, PipelineEditError::NotFound { .. }),
979 "got {err:?}"
980 );
981 rename_binding(root, "v", "a", "a").unwrap();
983 }
984
985 #[test]
989 fn editing_a_pre_v2_store_refuses_with_migrate_pointer() {
990 let tmp = TempDir::new().unwrap();
991 let root = tmp.path();
992 let dir = root.join(".memstead/projections/v");
993 std::fs::create_dir_all(&dir).unwrap();
994 std::fs::write(
995 dir.join("p.json"),
996 br#"{"version": 1, "source_facets": ["f"], "destination_mem": "v", "operations": {}}"#,
997 )
998 .unwrap();
999 let err = add_binding_json(root, "v", "q", BASE_PAYLOAD).unwrap_err();
1000 match err {
1001 PipelineEditError::Store(StoreError::LegacyProjectionStore { .. }) => {}
1002 other => panic!("expected LegacyProjectionStore, got {other:?}"),
1003 }
1004 }
1005}