1use std::path::Path;
23
24use crate::engine::Engine;
25use crate::pipeline::{Facet, Medium, Projection};
26use crate::pipeline_store::{self, PipelineConfigs};
27use crate::workspace_store::StoreError;
28
29#[derive(Debug, thiserror::Error)]
33pub enum PipelineEditError {
34 #[error("engine has no workspace root — pipeline edits require a workspace-backed engine")]
38 NoWorkspaceRoot,
39 #[error("pipeline edit landed, but recording provenance failed: {0}")]
45 Provenance(String),
46 #[error("{primitive} '{key}' already exists")]
48 AlreadyExists {
49 primitive: &'static str,
50 key: String,
51 },
52 #[error("{primitive} '{key}' does not exist")]
54 NotFound {
55 primitive: &'static str,
56 key: String,
57 },
58 #[error("{primitive} '{key}' is referenced by {referrers:?} — remove or repoint them first")]
60 Referenced {
61 primitive: &'static str,
62 key: String,
63 referrers: Vec<String>,
64 },
65 #[error("rename target {primitive} '{key}' already exists")]
67 RenameTargetExists {
68 primitive: &'static str,
69 key: String,
70 },
71 #[error("invalid {primitive} JSON: {message}")]
74 InvalidJson {
75 primitive: &'static str,
76 message: String,
77 },
78 #[error(transparent)]
80 Store(#[from] StoreError),
81}
82
83fn key(mem: &str, name: &str) -> String {
84 format!("{mem}/{name}")
85}
86
87fn medium_exists(c: &PipelineConfigs, mem: &str, name: &str) -> bool {
88 c.mediums.iter().any(|r| r.mem == mem && r.name == name)
89}
90
91fn facet_exists(c: &PipelineConfigs, mem: &str, name: &str) -> bool {
92 c.facets.iter().any(|r| r.mem == mem && r.name == name)
93}
94
95fn projection_exists(c: &PipelineConfigs, mem: &str, name: &str) -> bool {
96 c.projections.iter().any(|r| r.mem == mem && r.name == name)
97}
98
99fn facets_referencing_medium(c: &PipelineConfigs, mem: &str, name: &str) -> Vec<String> {
101 c.facets
102 .iter()
103 .filter(|r| r.mem == mem && r.config.medium == name)
104 .map(|r| r.name.clone())
105 .collect()
106}
107
108fn projections_referencing_facet(c: &PipelineConfigs, mem: &str, name: &str) -> Vec<String> {
110 c.projections
111 .iter()
112 .filter(|r| r.mem == mem && r.config.source_facets.iter().any(|f| f == name))
113 .map(|r| r.name.clone())
114 .collect()
115}
116
117fn ingests_referencing_projection(c: &PipelineConfigs, mem: &str, name: &str) -> Vec<String> {
119 let target = key(mem, name);
120 c.ingests
121 .iter()
122 .filter(|r| r.config.projection == target)
123 .map(|r| r.name.clone())
124 .collect()
125}
126
127pub fn add_medium(
131 root: &Path,
132 mem: &str,
133 name: &str,
134 medium: &Medium,
135) -> Result<(), PipelineEditError> {
136 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
137 if medium_exists(&configs, mem, name) {
138 return Err(PipelineEditError::AlreadyExists {
139 primitive: "medium",
140 key: key(mem, name),
141 });
142 }
143 pipeline_store::write_medium(root, mem, name, medium)?;
144 Ok(())
145}
146
147pub fn update_medium(
149 root: &Path,
150 mem: &str,
151 name: &str,
152 medium: &Medium,
153) -> Result<(), PipelineEditError> {
154 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
155 if !medium_exists(&configs, mem, name) {
156 return Err(PipelineEditError::NotFound {
157 primitive: "medium",
158 key: key(mem, name),
159 });
160 }
161 pipeline_store::write_medium(root, mem, name, medium)?;
162 Ok(())
163}
164
165pub fn delete_medium(root: &Path, mem: &str, name: &str) -> Result<(), PipelineEditError> {
167 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
168 if !medium_exists(&configs, mem, name) {
169 return Err(PipelineEditError::NotFound {
170 primitive: "medium",
171 key: key(mem, name),
172 });
173 }
174 let referrers = facets_referencing_medium(&configs, mem, name);
175 if !referrers.is_empty() {
176 return Err(PipelineEditError::Referenced {
177 primitive: "medium",
178 key: key(mem, name),
179 referrers,
180 });
181 }
182 pipeline_store::delete_medium(root, mem, name)?;
183 Ok(())
184}
185
186pub fn rename_medium(
189 root: &Path,
190 mem: &str,
191 old: &str,
192 new: &str,
193) -> Result<(), PipelineEditError> {
194 if old == new {
195 return Ok(());
196 }
197 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
198 let existing = configs
199 .mediums
200 .iter()
201 .find(|r| r.mem == mem && r.name == old)
202 .ok_or_else(|| PipelineEditError::NotFound {
203 primitive: "medium",
204 key: key(mem, old),
205 })?;
206 if medium_exists(&configs, mem, new) {
207 return Err(PipelineEditError::RenameTargetExists {
208 primitive: "medium",
209 key: key(mem, new),
210 });
211 }
212 let mut renamed = existing.config.clone();
213 renamed.name = new.to_string();
214 pipeline_store::write_medium(root, mem, new, &renamed)?;
217 for facet in facets_referencing_medium(&configs, mem, old) {
218 if let Some(rec) = configs
219 .facets
220 .iter()
221 .find(|r| r.mem == mem && r.name == facet)
222 {
223 let mut updated = rec.config.clone();
224 updated.medium = new.to_string();
225 pipeline_store::write_facet(root, mem, &facet, &updated)?;
226 }
227 }
228 pipeline_store::delete_medium(root, mem, old)?;
229 Ok(())
230}
231
232pub fn add_facet(
236 root: &Path,
237 mem: &str,
238 name: &str,
239 facet: &Facet,
240) -> Result<(), PipelineEditError> {
241 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
242 if facet_exists(&configs, mem, name) {
243 return Err(PipelineEditError::AlreadyExists {
244 primitive: "facet",
245 key: key(mem, name),
246 });
247 }
248 pipeline_store::write_facet(root, mem, name, facet)?;
249 Ok(())
250}
251
252pub fn update_facet(
254 root: &Path,
255 mem: &str,
256 name: &str,
257 facet: &Facet,
258) -> Result<(), PipelineEditError> {
259 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
260 if !facet_exists(&configs, mem, name) {
261 return Err(PipelineEditError::NotFound {
262 primitive: "facet",
263 key: key(mem, name),
264 });
265 }
266 pipeline_store::write_facet(root, mem, name, facet)?;
267 Ok(())
268}
269
270pub fn delete_facet(root: &Path, mem: &str, name: &str) -> Result<(), PipelineEditError> {
272 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
273 if !facet_exists(&configs, mem, name) {
274 return Err(PipelineEditError::NotFound {
275 primitive: "facet",
276 key: key(mem, name),
277 });
278 }
279 let referrers = projections_referencing_facet(&configs, mem, name);
280 if !referrers.is_empty() {
281 return Err(PipelineEditError::Referenced {
282 primitive: "facet",
283 key: key(mem, name),
284 referrers,
285 });
286 }
287 pipeline_store::delete_facet(root, mem, name)?;
288 Ok(())
289}
290
291pub fn rename_facet(root: &Path, mem: &str, old: &str, new: &str) -> Result<(), PipelineEditError> {
294 if old == new {
295 return Ok(());
296 }
297 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
298 let existing = configs
299 .facets
300 .iter()
301 .find(|r| r.mem == mem && r.name == old)
302 .ok_or_else(|| PipelineEditError::NotFound {
303 primitive: "facet",
304 key: key(mem, old),
305 })?;
306 if facet_exists(&configs, mem, new) {
307 return Err(PipelineEditError::RenameTargetExists {
308 primitive: "facet",
309 key: key(mem, new),
310 });
311 }
312 let mut renamed = existing.config.clone();
313 renamed.name = new.to_string();
314 pipeline_store::write_facet(root, mem, new, &renamed)?;
315 for proj in projections_referencing_facet(&configs, mem, old) {
316 if let Some(rec) = configs
317 .projections
318 .iter()
319 .find(|r| r.mem == mem && r.name == proj)
320 {
321 let mut updated = rec.config.clone();
322 for f in updated.source_facets.iter_mut() {
323 if f == old {
324 *f = new.to_string();
325 }
326 }
327 pipeline_store::write_projection(root, mem, &proj, &updated)?;
328 }
329 }
330 pipeline_store::delete_facet(root, mem, old)?;
331 Ok(())
332}
333
334pub fn add_projection(
338 root: &Path,
339 mem: &str,
340 name: &str,
341 projection: &Projection,
342) -> Result<(), PipelineEditError> {
343 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
344 if projection_exists(&configs, mem, name) {
345 return Err(PipelineEditError::AlreadyExists {
346 primitive: "projection",
347 key: key(mem, name),
348 });
349 }
350 pipeline_store::write_projection(root, mem, name, projection)?;
351 Ok(())
352}
353
354pub fn update_projection(
356 root: &Path,
357 mem: &str,
358 name: &str,
359 projection: &Projection,
360) -> Result<(), PipelineEditError> {
361 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
362 if !projection_exists(&configs, mem, name) {
363 return Err(PipelineEditError::NotFound {
364 primitive: "projection",
365 key: key(mem, name),
366 });
367 }
368 pipeline_store::write_projection(root, mem, name, projection)?;
369 Ok(())
370}
371
372pub fn delete_projection(root: &Path, mem: &str, name: &str) -> Result<(), PipelineEditError> {
374 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
375 if !projection_exists(&configs, mem, name) {
376 return Err(PipelineEditError::NotFound {
377 primitive: "projection",
378 key: key(mem, name),
379 });
380 }
381 let referrers = ingests_referencing_projection(&configs, mem, name);
382 if !referrers.is_empty() {
383 return Err(PipelineEditError::Referenced {
384 primitive: "projection",
385 key: key(mem, name),
386 referrers,
387 });
388 }
389 pipeline_store::delete_projection(root, mem, name)?;
390 Ok(())
391}
392
393pub fn rename_projection(
397 root: &Path,
398 mem: &str,
399 old: &str,
400 new: &str,
401) -> Result<(), PipelineEditError> {
402 if old == new {
403 return Ok(());
404 }
405 let configs = pipeline_store::load_legacy_pipeline_configs(root)?;
406 if !projection_exists(&configs, mem, old) {
407 return Err(PipelineEditError::NotFound {
408 primitive: "projection",
409 key: key(mem, old),
410 });
411 }
412 if projection_exists(&configs, mem, new) {
413 return Err(PipelineEditError::RenameTargetExists {
414 primitive: "projection",
415 key: key(mem, new),
416 });
417 }
418 pipeline_store::rename_projection(root, mem, old, new)?;
419 let new_ref = key(mem, new);
420 for ingest in ingests_referencing_projection(&configs, mem, old) {
421 if let Some(rec) = configs.ingests.iter().find(|r| r.name == ingest) {
422 let mut updated = rec.config.clone();
423 updated.projection = new_ref.clone();
424 pipeline_store::write_ingest(root, &ingest, &updated)?;
425 }
426 }
427 Ok(())
428}
429
430impl Engine {
439 fn pipeline_edit_root(&self) -> Result<std::path::PathBuf, PipelineEditError> {
440 self.workspace_root()
441 .map(Path::to_path_buf)
442 .ok_or(PipelineEditError::NoWorkspaceRoot)
443 }
444
445 fn refresh_pipeline_configs(&mut self, root: &Path) -> Result<(), PipelineEditError> {
446 self.set_pipeline_configs(pipeline_store::load_legacy_pipeline_configs(root)?);
450 Ok(())
451 }
452
453 fn pipeline_provenance(
458 &self,
459 mem: &str,
460 kind: &str,
461 edits: &[(String, Option<Vec<u8>>)],
462 note: Option<&str>,
463 verb: &str,
464 ) -> Result<(), PipelineEditError> {
465 self.record_pipeline_edit_provenance(mem, kind, edits, note, verb)
466 .map_err(|e| PipelineEditError::Provenance(e.to_string()))
467 }
468
469 pub fn add_medium(
471 &mut self,
472 mem: &str,
473 name: &str,
474 medium: &Medium,
475 note: Option<&str>,
476 ) -> Result<(), PipelineEditError> {
477 let root = self.pipeline_edit_root()?;
478 add_medium(&root, mem, name, medium)?;
479 let bytes =
480 serde_json::to_vec_pretty(medium).map_err(|e| PipelineEditError::InvalidJson {
481 primitive: "config",
482 message: e.to_string(),
483 })?;
484 self.pipeline_provenance(
485 mem,
486 "mediums",
487 &[(name.to_string(), Some(bytes))],
488 note,
489 "add",
490 )?;
491 self.refresh_pipeline_configs(&root)
492 }
493
494 pub fn update_medium(
496 &mut self,
497 mem: &str,
498 name: &str,
499 medium: &Medium,
500 note: Option<&str>,
501 ) -> Result<(), PipelineEditError> {
502 let root = self.pipeline_edit_root()?;
503 update_medium(&root, mem, name, medium)?;
504 let bytes =
505 serde_json::to_vec_pretty(medium).map_err(|e| PipelineEditError::InvalidJson {
506 primitive: "config",
507 message: e.to_string(),
508 })?;
509 self.pipeline_provenance(
510 mem,
511 "mediums",
512 &[(name.to_string(), Some(bytes))],
513 note,
514 "update",
515 )?;
516 self.refresh_pipeline_configs(&root)
517 }
518
519 pub fn delete_medium(
521 &mut self,
522 mem: &str,
523 name: &str,
524 note: Option<&str>,
525 ) -> Result<(), PipelineEditError> {
526 let root = self.pipeline_edit_root()?;
527 delete_medium(&root, mem, name)?;
528 self.pipeline_provenance(mem, "mediums", &[(name.to_string(), None)], note, "delete")?;
529 self.refresh_pipeline_configs(&root)
530 }
531
532 pub fn rename_medium(
534 &mut self,
535 mem: &str,
536 old: &str,
537 new: &str,
538 note: Option<&str>,
539 ) -> Result<(), PipelineEditError> {
540 let root = self.pipeline_edit_root()?;
541 rename_medium(&root, mem, old, new)?;
542 let bytes = self
547 .pipeline_configs()
548 .mediums
549 .iter()
550 .find(|r| r.mem == mem && r.name == old)
551 .map(|r| serde_json::to_vec_pretty(&r.config))
552 .transpose()
553 .map_err(|e| PipelineEditError::InvalidJson {
554 primitive: "config",
555 message: e.to_string(),
556 })?;
557 self.pipeline_provenance(
558 mem,
559 "mediums",
560 &[(old.to_string(), None), (new.to_string(), bytes)],
561 note,
562 "rename",
563 )?;
564 self.refresh_pipeline_configs(&root)
565 }
566
567 pub fn add_facet(
569 &mut self,
570 mem: &str,
571 name: &str,
572 facet: &Facet,
573 note: Option<&str>,
574 ) -> Result<(), PipelineEditError> {
575 let root = self.pipeline_edit_root()?;
576 add_facet(&root, mem, name, facet)?;
577 let bytes =
578 serde_json::to_vec_pretty(facet).map_err(|e| PipelineEditError::InvalidJson {
579 primitive: "config",
580 message: e.to_string(),
581 })?;
582 self.pipeline_provenance(
583 mem,
584 "facets",
585 &[(name.to_string(), Some(bytes))],
586 note,
587 "add",
588 )?;
589 self.refresh_pipeline_configs(&root)
590 }
591
592 pub fn update_facet(
594 &mut self,
595 mem: &str,
596 name: &str,
597 facet: &Facet,
598 note: Option<&str>,
599 ) -> Result<(), PipelineEditError> {
600 let root = self.pipeline_edit_root()?;
601 update_facet(&root, mem, name, facet)?;
602 let bytes =
603 serde_json::to_vec_pretty(facet).map_err(|e| PipelineEditError::InvalidJson {
604 primitive: "config",
605 message: e.to_string(),
606 })?;
607 self.pipeline_provenance(
608 mem,
609 "facets",
610 &[(name.to_string(), Some(bytes))],
611 note,
612 "update",
613 )?;
614 self.refresh_pipeline_configs(&root)
615 }
616
617 pub fn delete_facet(
619 &mut self,
620 mem: &str,
621 name: &str,
622 note: Option<&str>,
623 ) -> Result<(), PipelineEditError> {
624 let root = self.pipeline_edit_root()?;
625 delete_facet(&root, mem, name)?;
626 self.pipeline_provenance(mem, "facets", &[(name.to_string(), None)], note, "delete")?;
627 self.refresh_pipeline_configs(&root)
628 }
629
630 pub fn rename_facet(
632 &mut self,
633 mem: &str,
634 old: &str,
635 new: &str,
636 note: Option<&str>,
637 ) -> Result<(), PipelineEditError> {
638 let root = self.pipeline_edit_root()?;
639 rename_facet(&root, mem, old, new)?;
640 let bytes = self
645 .pipeline_configs()
646 .facets
647 .iter()
648 .find(|r| r.mem == mem && r.name == old)
649 .map(|r| serde_json::to_vec_pretty(&r.config))
650 .transpose()
651 .map_err(|e| PipelineEditError::InvalidJson {
652 primitive: "config",
653 message: e.to_string(),
654 })?;
655 self.pipeline_provenance(
656 mem,
657 "facets",
658 &[(old.to_string(), None), (new.to_string(), bytes)],
659 note,
660 "rename",
661 )?;
662 self.refresh_pipeline_configs(&root)
663 }
664
665 pub fn add_projection(
667 &mut self,
668 mem: &str,
669 name: &str,
670 projection: &Projection,
671 note: Option<&str>,
672 ) -> Result<(), PipelineEditError> {
673 let root = self.pipeline_edit_root()?;
674 add_projection(&root, mem, name, projection)?;
675 let bytes =
676 serde_json::to_vec_pretty(projection).map_err(|e| PipelineEditError::InvalidJson {
677 primitive: "config",
678 message: e.to_string(),
679 })?;
680 self.pipeline_provenance(
681 mem,
682 "projections",
683 &[(name.to_string(), Some(bytes))],
684 note,
685 "add",
686 )?;
687 self.refresh_pipeline_configs(&root)
688 }
689
690 pub fn update_projection(
692 &mut self,
693 mem: &str,
694 name: &str,
695 projection: &Projection,
696 note: Option<&str>,
697 ) -> Result<(), PipelineEditError> {
698 let root = self.pipeline_edit_root()?;
699 update_projection(&root, mem, name, projection)?;
700 let bytes =
701 serde_json::to_vec_pretty(projection).map_err(|e| PipelineEditError::InvalidJson {
702 primitive: "config",
703 message: e.to_string(),
704 })?;
705 self.pipeline_provenance(
706 mem,
707 "projections",
708 &[(name.to_string(), Some(bytes))],
709 note,
710 "update",
711 )?;
712 self.refresh_pipeline_configs(&root)
713 }
714
715 pub fn delete_projection(
717 &mut self,
718 mem: &str,
719 name: &str,
720 note: Option<&str>,
721 ) -> Result<(), PipelineEditError> {
722 let root = self.pipeline_edit_root()?;
723 delete_projection(&root, mem, name)?;
724 self.pipeline_provenance(
725 mem,
726 "projections",
727 &[(name.to_string(), None)],
728 note,
729 "delete",
730 )?;
731 self.refresh_pipeline_configs(&root)
732 }
733
734 pub fn rename_projection(
736 &mut self,
737 mem: &str,
738 old: &str,
739 new: &str,
740 note: Option<&str>,
741 ) -> Result<(), PipelineEditError> {
742 let root = self.pipeline_edit_root()?;
743 rename_projection(&root, mem, old, new)?;
744 let bytes = self
749 .pipeline_configs()
750 .projections
751 .iter()
752 .find(|r| r.mem == mem && r.name == old)
753 .map(|r| serde_json::to_vec_pretty(&r.config))
754 .transpose()
755 .map_err(|e| PipelineEditError::InvalidJson {
756 primitive: "config",
757 message: e.to_string(),
758 })?;
759 self.pipeline_provenance(
760 mem,
761 "projections",
762 &[(old.to_string(), None), (new.to_string(), bytes)],
763 note,
764 "rename",
765 )?;
766 self.refresh_pipeline_configs(&root)
767 }
768
769 pub fn add_medium_json(
779 &mut self,
780 mem: &str,
781 name: &str,
782 medium_json: &str,
783 note: Option<&str>,
784 ) -> Result<(), PipelineEditError> {
785 self.add_medium(mem, name, &parse_json(medium_json, "medium")?, note)
786 }
787
788 pub fn update_medium_json(
790 &mut self,
791 mem: &str,
792 name: &str,
793 medium_json: &str,
794 note: Option<&str>,
795 ) -> Result<(), PipelineEditError> {
796 self.update_medium(mem, name, &parse_json(medium_json, "medium")?, note)
797 }
798
799 pub fn add_facet_json(
801 &mut self,
802 mem: &str,
803 name: &str,
804 facet_json: &str,
805 note: Option<&str>,
806 ) -> Result<(), PipelineEditError> {
807 self.add_facet(mem, name, &parse_json(facet_json, "facet")?, note)
808 }
809
810 pub fn update_facet_json(
812 &mut self,
813 mem: &str,
814 name: &str,
815 facet_json: &str,
816 note: Option<&str>,
817 ) -> Result<(), PipelineEditError> {
818 self.update_facet(mem, name, &parse_json(facet_json, "facet")?, note)
819 }
820
821 pub fn add_projection_json(
830 &mut self,
831 mem: &str,
832 name: &str,
833 projection_json: &str,
834 note: Option<&str>,
835 ) -> Result<(), PipelineEditError> {
836 let root = self.pipeline_edit_root()?;
837 let incoming: Projection = parse_json(projection_json, "projection")?;
838 let binding = crate::binding::BindingV1 {
839 version: crate::binding::BINDING_VERSION,
840 intent: incoming.intent,
841 source_facets: incoming.source_facets,
842 reference_mems: incoming.reference_mems,
843 destination_mem: incoming.destination_mem,
844 deny_paths: Vec::new(),
845 coverage_semantics: crate::binding::CoverageSemantics::default(),
846 rules: incoming.rules,
847 prune: None,
848 operations: crate::binding::Operations {
849 build: Some(crate::binding::BuildOperation {
850 mode: crate::binding::BuildMode::Discovery,
851 trigger: crate::pipeline::IngestTrigger::Loop,
852 batch_size: 20,
853 post_actions: None,
854 }),
855 sync: None,
856 verify: None,
857 },
858 };
859 self.write_binding_edit(mem, name, &binding, &root, note, "add")
860 }
861
862 pub fn update_projection_json(
868 &mut self,
869 mem: &str,
870 name: &str,
871 projection_json: &str,
872 note: Option<&str>,
873 ) -> Result<(), PipelineEditError> {
874 let root = self.pipeline_edit_root()?;
875 let incoming: Projection = parse_json(projection_json, "projection")?;
876 let mut binding = pipeline_store::read_binding(&root, mem, name)?;
877 binding.intent = incoming.intent;
878 binding.source_facets = incoming.source_facets;
879 binding.reference_mems = incoming.reference_mems;
880 binding.destination_mem = incoming.destination_mem;
881 if incoming.rules.is_some() {
882 binding.rules = incoming.rules;
883 }
884 self.write_binding_edit(mem, name, &binding, &root, note, "update")
885 }
886
887 fn write_binding_edit(
890 &mut self,
891 mem: &str,
892 name: &str,
893 binding: &crate::binding::BindingV1,
894 root: &Path,
895 note: Option<&str>,
896 verb: &str,
897 ) -> Result<(), PipelineEditError> {
898 pipeline_store::write_binding(root, mem, name, binding)?;
899 let bytes =
900 serde_json::to_vec_pretty(binding).map_err(|e| PipelineEditError::InvalidJson {
901 primitive: "config",
902 message: e.to_string(),
903 })?;
904 self.pipeline_provenance(
905 mem,
906 "projections",
907 &[(name.to_string(), Some(bytes))],
908 note,
909 verb,
910 )?;
911 self.refresh_pipeline_configs(root)
912 }
913}
914
915fn parse_json<T: serde::de::DeserializeOwned>(
918 json: &str,
919 primitive: &'static str,
920) -> Result<T, PipelineEditError> {
921 serde_json::from_str(json).map_err(|e| PipelineEditError::InvalidJson {
922 primitive,
923 message: e.to_string(),
924 })
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
931 use crate::pipeline_store::{LegacyIngest, LegacyIngestMode};
932 use tempfile::TempDir;
933
934 fn medium(name: &str) -> Medium {
935 Medium {
936 name: name.to_string(),
937 medium_type: MediumType::Codebase,
938 pointer: "../src".to_string(),
939 change_detection: None,
940 }
941 }
942
943 fn facet(name: &str, medium: &str) -> Facet {
944 Facet {
945 name: name.to_string(),
946 medium: medium.to_string(),
947 scope: vec![PatternEntry {
948 path: "**/*.rs".to_string(),
949 mode: PatternMode::Allow,
950 }],
951 engagement: None,
952 preparation: None,
953 }
954 }
955
956 fn projection(facets: &[&str]) -> Projection {
957 Projection {
958 intent: Some("test".to_string()),
959 source_facets: facets.iter().map(|s| s.to_string()).collect(),
960 reference_mems: vec![],
961 destination_mem: "v".to_string(),
962 rules: None,
963 }
964 }
965
966 fn ingest(projection: &str) -> LegacyIngest {
967 LegacyIngest {
968 projection: projection.to_string(),
969 mode: LegacyIngestMode::Discovery,
970 trigger: IngestTrigger::Loop,
971 batch_size: 10,
972 deny_paths: vec![],
973 post_actions: None,
974 }
975 }
976
977 #[test]
978 fn add_then_duplicate_medium_refuses() {
979 let tmp = TempDir::new().unwrap();
980 let root = tmp.path();
981 add_medium(root, "v", "m", &medium("m")).unwrap();
982 let err = add_medium(root, "v", "m", &medium("m")).unwrap_err();
983 assert!(
984 matches!(err, PipelineEditError::AlreadyExists { .. }),
985 "got {err:?}"
986 );
987 }
988
989 #[test]
990 fn update_missing_medium_refuses() {
991 let tmp = TempDir::new().unwrap();
992 let err = update_medium(tmp.path(), "v", "m", &medium("m")).unwrap_err();
993 assert!(
994 matches!(err, PipelineEditError::NotFound { .. }),
995 "got {err:?}"
996 );
997 }
998
999 #[test]
1000 fn delete_medium_refused_while_a_facet_references_it() {
1001 let tmp = TempDir::new().unwrap();
1002 let root = tmp.path();
1003 add_medium(root, "v", "m", &medium("m")).unwrap();
1004 add_facet(root, "v", "f", &facet("f", "m")).unwrap();
1005
1006 let err = delete_medium(root, "v", "m").unwrap_err();
1007 match err {
1008 PipelineEditError::Referenced { referrers, .. } => assert_eq!(referrers, vec!["f"]),
1009 other => panic!("expected Referenced, got {other:?}"),
1010 }
1011 delete_facet(root, "v", "f").unwrap();
1013 delete_medium(root, "v", "m").unwrap();
1014 let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1015 assert!(configs.mediums.is_empty() && configs.facets.is_empty());
1016 }
1017
1018 #[test]
1019 fn rename_medium_repoints_dependent_facets_and_updates_embedded_name() {
1020 let tmp = TempDir::new().unwrap();
1021 let root = tmp.path();
1022 add_medium(root, "v", "old", &medium("old")).unwrap();
1023 add_facet(root, "v", "f", &facet("f", "old")).unwrap();
1024
1025 rename_medium(root, "v", "old", "new").unwrap();
1026
1027 let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1028 assert_eq!(configs.mediums.len(), 1);
1029 assert_eq!(configs.mediums[0].name, "new");
1030 assert_eq!(configs.mediums[0].config.name, "new");
1032 assert_eq!(configs.facets[0].config.medium, "new");
1034 }
1035
1036 #[test]
1037 fn rename_medium_refuses_existing_target() {
1038 let tmp = TempDir::new().unwrap();
1039 let root = tmp.path();
1040 add_medium(root, "v", "a", &medium("a")).unwrap();
1041 add_medium(root, "v", "b", &medium("b")).unwrap();
1042 let err = rename_medium(root, "v", "a", "b").unwrap_err();
1043 assert!(
1044 matches!(err, PipelineEditError::RenameTargetExists { .. }),
1045 "got {err:?}"
1046 );
1047 let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1049 assert_eq!(configs.mediums.len(), 2);
1050 }
1051
1052 #[test]
1053 fn rename_medium_to_same_name_is_a_noop() {
1054 let tmp = TempDir::new().unwrap();
1055 let root = tmp.path();
1056 add_medium(root, "v", "m", &medium("m")).unwrap();
1057 rename_medium(root, "v", "m", "m").unwrap();
1058 let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1059 assert_eq!(configs.mediums.len(), 1);
1060 assert_eq!(configs.mediums[0].config, medium("m"));
1061 }
1062
1063 #[test]
1064 fn delete_facet_refused_while_a_projection_references_it() {
1065 let tmp = TempDir::new().unwrap();
1066 let root = tmp.path();
1067 add_facet(root, "v", "f", &facet("f", "m")).unwrap();
1068 add_projection(root, "v", "p", &projection(&["f"])).unwrap();
1069 let err = delete_facet(root, "v", "f").unwrap_err();
1070 assert!(
1071 matches!(err, PipelineEditError::Referenced { .. }),
1072 "got {err:?}"
1073 );
1074 }
1075
1076 #[test]
1077 fn rename_facet_repoints_dependent_projections() {
1078 let tmp = TempDir::new().unwrap();
1079 let root = tmp.path();
1080 add_facet(root, "v", "old", &facet("old", "m")).unwrap();
1081 add_projection(root, "v", "p", &projection(&["old", "other"])).unwrap();
1082
1083 rename_facet(root, "v", "old", "new").unwrap();
1084
1085 let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1086 assert_eq!(configs.facets[0].name, "new");
1087 assert_eq!(configs.facets[0].config.name, "new");
1088 assert_eq!(
1089 configs.projections[0].config.source_facets,
1090 vec!["new", "other"]
1091 );
1092 }
1093
1094 #[test]
1095 fn delete_projection_refused_while_an_ingest_runs_it() {
1096 let tmp = TempDir::new().unwrap();
1097 let root = tmp.path();
1098 add_projection(root, "v", "p", &projection(&[])).unwrap();
1099 pipeline_store::write_ingest(root, "i", &ingest("v/p")).unwrap();
1100 let err = delete_projection(root, "v", "p").unwrap_err();
1101 match err {
1102 PipelineEditError::Referenced { referrers, .. } => assert_eq!(referrers, vec!["i"]),
1103 other => panic!("expected Referenced, got {other:?}"),
1104 }
1105 }
1106
1107 #[test]
1108 fn parse_json_accepts_a_valid_medium() {
1109 let m: Medium =
1110 parse_json(r#"{"name":"m","type":"codebase","pointer":".."}"#, "medium").unwrap();
1111 assert_eq!(m.name, "m");
1112 assert_eq!(m.medium_type, MediumType::Codebase);
1113 }
1114
1115 #[test]
1116 fn parse_json_maps_a_bad_payload_to_invalid_json() {
1117 let err = parse_json::<Medium>("{ not json", "medium").unwrap_err();
1118 assert!(
1119 matches!(
1120 err,
1121 PipelineEditError::InvalidJson {
1122 primitive: "medium",
1123 ..
1124 }
1125 ),
1126 "got {err:?}"
1127 );
1128 }
1129
1130 #[test]
1131 fn rename_projection_repoints_dependent_ingests() {
1132 let tmp = TempDir::new().unwrap();
1133 let root = tmp.path();
1134 add_projection(root, "v", "old", &projection(&[])).unwrap();
1135 pipeline_store::write_ingest(root, "i", &ingest("v/old")).unwrap();
1136
1137 rename_projection(root, "v", "old", "new").unwrap();
1138
1139 let configs = pipeline_store::load_legacy_pipeline_configs(root).unwrap();
1140 assert_eq!(configs.projections[0].name, "new");
1141 assert_eq!(configs.ingests[0].config.projection, "v/new");
1142 }
1143}