1use serde::{Deserialize, Serialize};
20use std::collections::BTreeMap;
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
46pub struct ManifestPatches {
47 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49 pub agents: BTreeMap<String, PatchData>,
50
51 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
53 pub snippets: BTreeMap<String, PatchData>,
54
55 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57 pub commands: BTreeMap<String, PatchData>,
58
59 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
61 pub scripts: BTreeMap<String, PatchData>,
62
63 #[serde(default, skip_serializing_if = "BTreeMap::is_empty", rename = "mcp-servers")]
65 pub mcp_servers: BTreeMap<String, PatchData>,
66
67 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
69 pub hooks: BTreeMap<String, PatchData>,
70}
71
72pub type PatchData = BTreeMap<String, toml::Value>;
87
88#[derive(Debug, Clone, Default, PartialEq)]
113pub struct AppliedPatches {
114 pub project: BTreeMap<String, toml::Value>,
116 pub private: BTreeMap<String, toml::Value>,
118}
119
120impl AppliedPatches {
121 pub fn new() -> Self {
123 Self::default()
124 }
125
126 pub fn from_lockfile_patches(patches: &BTreeMap<String, toml::Value>) -> Self {
131 Self {
132 project: patches.clone(),
133 private: BTreeMap::new(),
134 }
135 }
136
137 pub fn is_empty(&self) -> bool {
139 self.project.is_empty() && self.private.is_empty()
140 }
141
142 pub fn total_count(&self) -> usize {
144 self.project.len() + self.private.len()
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum PatchOrigin {
152 Project,
154 Private,
156}
157
158#[derive(Debug, Clone, PartialEq)]
163pub struct MergedPatch {
164 pub data: PatchData,
166 pub field_origins: BTreeMap<String, PatchOrigin>,
168}
169
170impl ManifestPatches {
171 pub fn new() -> Self {
173 Self::default()
174 }
175
176 pub fn is_empty(&self) -> bool {
178 self.agents.is_empty()
179 && self.snippets.is_empty()
180 && self.commands.is_empty()
181 && self.scripts.is_empty()
182 && self.mcp_servers.is_empty()
183 && self.hooks.is_empty()
184 }
185
186 pub fn get(&self, resource_type: &str, alias: &str) -> Option<&PatchData> {
190 match resource_type {
191 "agents" => self.agents.get(alias),
192 "snippets" => self.snippets.get(alias),
193 "commands" => self.commands.get(alias),
194 "scripts" => self.scripts.get(alias),
195 "mcp-servers" => self.mcp_servers.get(alias),
196 "hooks" => self.hooks.get(alias),
197 _ => None,
198 }
199 }
200
201 pub fn merge_with(&self, other: &ManifestPatches) -> (ManifestPatches, Vec<PatchConflict>) {
214 let mut merged = self.clone();
215 let mut conflicts = Vec::new();
216
217 Self::merge_resource_patches(&mut merged.agents, &other.agents, "agents", &mut conflicts);
219 Self::merge_resource_patches(
220 &mut merged.snippets,
221 &other.snippets,
222 "snippets",
223 &mut conflicts,
224 );
225 Self::merge_resource_patches(
226 &mut merged.commands,
227 &other.commands,
228 "commands",
229 &mut conflicts,
230 );
231 Self::merge_resource_patches(
232 &mut merged.scripts,
233 &other.scripts,
234 "scripts",
235 &mut conflicts,
236 );
237 Self::merge_resource_patches(
238 &mut merged.mcp_servers,
239 &other.mcp_servers,
240 "mcp-servers",
241 &mut conflicts,
242 );
243 Self::merge_resource_patches(&mut merged.hooks, &other.hooks, "hooks", &mut conflicts);
244
245 (merged, conflicts)
246 }
247
248 fn merge_resource_patches(
250 base: &mut BTreeMap<String, PatchData>,
251 overlay: &BTreeMap<String, PatchData>,
252 resource_type: &str,
253 conflicts: &mut Vec<PatchConflict>,
254 ) {
255 for (alias, overlay_patch) in overlay {
256 if let Some(base_patch) = base.get_mut(alias) {
257 for (key, overlay_value) in overlay_patch {
259 if let Some(base_value) = base_patch.get(key) {
260 if base_value != overlay_value {
262 conflicts.push(PatchConflict {
263 resource_type: resource_type.to_string(),
264 alias: alias.clone(),
265 field: key.clone(),
266 project_value: base_value.clone(),
267 private_value: overlay_value.clone(),
268 });
269 }
270 }
271 base_patch.insert(key.clone(), overlay_value.clone());
273 }
274 } else {
275 base.insert(alias.clone(), overlay_patch.clone());
277 }
278 }
279 }
280
281 pub fn get_for_resource_type(
285 &self,
286 resource_type: &str,
287 ) -> Option<&BTreeMap<String, PatchData>> {
288 match resource_type {
289 "agents" => Some(&self.agents),
290 "snippets" => Some(&self.snippets),
291 "commands" => Some(&self.commands),
292 "scripts" => Some(&self.scripts),
293 "mcp-servers" => Some(&self.mcp_servers),
294 "hooks" => Some(&self.hooks),
295 _ => None,
296 }
297 }
298}
299
300pub fn apply_patches_to_content(
335 content: &str,
336 file_path: &str,
337 patch_data: &PatchData,
338) -> anyhow::Result<(String, BTreeMap<String, toml::Value>)> {
339 tracing::info!(
340 "apply_patches_to_content: file={}, patches_empty={}, patch_count={}",
341 file_path,
342 patch_data.is_empty(),
343 patch_data.len()
344 );
345
346 if patch_data.is_empty() {
347 return Ok((content.to_string(), BTreeMap::new()));
348 }
349
350 let file_ext =
351 std::path::Path::new(file_path).extension().and_then(|s| s.to_str()).unwrap_or("");
352
353 match file_ext {
354 "md" => apply_patches_to_markdown(content, file_path, patch_data),
355 "json" => apply_patches_to_json(content, patch_data),
356 _ => {
357 tracing::warn!(
359 "Cannot apply patches to file type '{}' for file: {}",
360 file_ext,
361 file_path
362 );
363 Ok((content.to_string(), BTreeMap::new()))
364 }
365 }
366}
367
368pub fn apply_patches_to_content_with_origin(
413 content: &str,
414 file_path: &str,
415 project_patches: &PatchData,
416 private_patches: &PatchData,
417) -> anyhow::Result<(String, AppliedPatches)> {
418 let mut merged_patches = project_patches.clone();
420 for (key, value) in private_patches {
421 merged_patches.insert(key.clone(), value.clone());
422 }
423
424 let (final_content, all_applied) =
426 apply_patches_to_content(content, file_path, &merged_patches)?;
427
428 let mut project_applied = BTreeMap::new();
432 let mut private_applied = BTreeMap::new();
433
434 for key in all_applied.keys() {
435 if let Some(value) = project_patches.get(key) {
437 project_applied.insert(key.clone(), value.clone());
438 }
439 if let Some(value) = private_patches.get(key) {
441 private_applied.insert(key.clone(), value.clone());
442 }
443 }
444
445 Ok((
446 final_content,
447 AppliedPatches {
448 project: project_applied,
449 private: private_applied,
450 },
451 ))
452}
453
454fn apply_patches_to_markdown(
456 content: &str,
457 file_path: &str,
458 patch_data: &PatchData,
459) -> anyhow::Result<(String, BTreeMap<String, toml::Value>)> {
460 use crate::markdown::MarkdownDocument;
461
462 let mut md_doc = MarkdownDocument::parse_with_operation_context(
464 content,
465 Some(file_path),
466 None, )?;
468
469 let mut applied_patches = BTreeMap::new();
470
471 let mut sorted_keys: Vec<_> = patch_data.keys().cloned().collect();
474 sorted_keys.sort();
475
476 for key in sorted_keys {
477 let value = &patch_data[&key];
478 let json_value = toml_value_to_json(value)?;
480
481 let metadata = md_doc.metadata.get_or_insert_with(Default::default);
483
484 metadata.extra.insert(key.clone(), json_value);
486
487 applied_patches.insert(key.clone(), value.clone());
488 }
489
490 if let Some(metadata) = md_doc.metadata.clone() {
492 md_doc.set_metadata(metadata);
493 }
494
495 Ok((md_doc.raw, applied_patches))
497}
498
499fn apply_patches_to_json(
501 content: &str,
502 patch_data: &PatchData,
503) -> anyhow::Result<(String, BTreeMap<String, toml::Value>)> {
504 let mut json_value: serde_json::Value = serde_json::from_str(content)?;
506
507 let mut applied_patches = BTreeMap::new();
508
509 if let serde_json::Value::Object(ref mut map) = json_value {
512 let mut sorted_keys: Vec<_> = patch_data.keys().cloned().collect();
513 sorted_keys.sort();
514
515 for key in sorted_keys {
516 let value = &patch_data[&key];
517 let json_val = toml_value_to_json(value)?;
519 map.insert(key.clone(), json_val);
520 applied_patches.insert(key.clone(), value.clone());
521 }
522 } else {
523 anyhow::bail!("JSON file must have a top-level object to apply patches");
524 }
525
526 let new_content = serde_json::to_string_pretty(&json_value)?;
528
529 Ok((new_content, applied_patches))
530}
531
532pub(crate) fn toml_value_to_json(value: &toml::Value) -> anyhow::Result<serde_json::Value> {
534 toml_value_to_json_with_depth(value, 0)
535}
536
537fn toml_value_to_json_with_depth(
548 value: &toml::Value,
549 depth: usize,
550) -> anyhow::Result<serde_json::Value> {
551 const MAX_DEPTH: usize = 100;
552
553 if depth > MAX_DEPTH {
554 anyhow::bail!(
555 "TOML value nesting exceeds maximum depth of {}. \
556 This may indicate a malformed patch configuration.",
557 MAX_DEPTH
558 );
559 }
560
561 match value {
562 toml::Value::String(s) => Ok(serde_json::Value::String(s.clone())),
563 toml::Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
564 toml::Value::Float(f) => {
565 let num = serde_json::Number::from_f64(*f)
566 .ok_or_else(|| anyhow::anyhow!("Invalid float value: {}", f))?;
567 Ok(serde_json::Value::Number(num))
568 }
569 toml::Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
570 toml::Value::Array(arr) => {
571 let json_arr: Result<Vec<_>, _> =
572 arr.iter().map(|v| toml_value_to_json_with_depth(v, depth + 1)).collect();
573 Ok(serde_json::Value::Array(json_arr?))
574 }
575 toml::Value::Table(table) => {
576 let mut json_map = serde_json::Map::new();
577 for (k, v) in table {
578 json_map.insert(k.clone(), toml_value_to_json_with_depth(v, depth + 1)?);
579 }
580 Ok(serde_json::Value::Object(json_map))
581 }
582 toml::Value::Datetime(dt) => Ok(serde_json::Value::String(dt.to_string())),
583 }
584}
585
586#[derive(Debug, Clone, PartialEq)]
588pub struct PatchConflict {
589 pub resource_type: String,
591 pub alias: String,
593 pub field: String,
595 pub project_value: toml::Value,
597 pub private_value: toml::Value,
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 #[test]
606 fn test_empty_patches() {
607 let patches = ManifestPatches::new();
608 assert!(patches.is_empty());
609 assert_eq!(patches.get("agents", "test"), None);
610 }
611
612 #[test]
613 fn test_get_patch() {
614 let mut patches = ManifestPatches::new();
615 let mut patch_data = BTreeMap::new();
616 patch_data.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
617 patches.agents.insert("test-agent".to_string(), patch_data.clone());
618
619 assert!(!patches.is_empty());
620 assert_eq!(patches.get("agents", "test-agent"), Some(&patch_data));
621 assert_eq!(patches.get("agents", "other"), None);
622 assert_eq!(patches.get("snippets", "test-agent"), None);
623 }
624
625 #[test]
626 fn test_merge_no_conflict() {
627 let mut base = ManifestPatches::new();
628 let mut base_patch = BTreeMap::new();
629 base_patch.insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
630 base.agents.insert("test".to_string(), base_patch);
631
632 let mut overlay = ManifestPatches::new();
633 let mut overlay_patch = BTreeMap::new();
634 overlay_patch.insert("temperature".to_string(), toml::Value::String("0.7".to_string()));
635 overlay.agents.insert("test".to_string(), overlay_patch);
636
637 let (merged, conflicts) = base.merge_with(&overlay);
638 assert!(conflicts.is_empty());
639 assert_eq!(merged.agents.get("test").unwrap().len(), 2);
640 assert_eq!(
641 merged.agents.get("test").unwrap().get("model").unwrap(),
642 &toml::Value::String("claude-3-opus".to_string())
643 );
644 assert_eq!(
645 merged.agents.get("test").unwrap().get("temperature").unwrap(),
646 &toml::Value::String("0.7".to_string())
647 );
648 }
649
650 #[test]
651 fn test_merge_with_conflict() {
652 let mut base = ManifestPatches::new();
653 let mut base_patch = BTreeMap::new();
654 base_patch.insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
655 base.agents.insert("test".to_string(), base_patch);
656
657 let mut overlay = ManifestPatches::new();
658 let mut overlay_patch = BTreeMap::new();
659 overlay_patch
660 .insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
661 overlay.agents.insert("test".to_string(), overlay_patch);
662
663 let (merged, conflicts) = base.merge_with(&overlay);
664 assert_eq!(conflicts.len(), 1);
665 assert_eq!(conflicts[0].resource_type, "agents");
666 assert_eq!(conflicts[0].alias, "test");
667 assert_eq!(conflicts[0].field, "model");
668
669 assert_eq!(
671 merged.agents.get("test").unwrap().get("model").unwrap(),
672 &toml::Value::String("claude-3-haiku".to_string())
673 );
674 }
675
676 #[test]
677 fn test_apply_patches_to_markdown_simple() {
678 let content = r#"---
679model: claude-3-opus
680temperature: "0.5"
681---
682# Test Agent
683
684This is a test agent.
685"#;
686
687 let mut patches = BTreeMap::new();
688 patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
689
690 let (new_content, applied) =
691 apply_patches_to_content(content, "agent.md", &patches).unwrap();
692
693 assert_eq!(applied.len(), 1);
695 assert_eq!(
696 applied.get("model").unwrap(),
697 &toml::Value::String("claude-3-haiku".to_string())
698 );
699
700 assert!(new_content.contains("model: claude-3-haiku"));
702 assert!(new_content.contains("# Test Agent"));
703 }
704
705 #[test]
706 fn test_apply_patches_to_markdown_multiple_fields() {
707 let content = r#"---
708model: claude-3-opus
709temperature: "0.5"
710---
711# Test Agent
712"#;
713
714 let mut patches = BTreeMap::new();
715 patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
716 patches.insert("temperature".to_string(), toml::Value::String("0.7".to_string()));
717 patches.insert("max_tokens".to_string(), toml::Value::Integer(2000));
718
719 let (new_content, applied) =
720 apply_patches_to_content(content, "agent.md", &patches).unwrap();
721
722 assert_eq!(applied.len(), 3);
724 assert!(new_content.contains("model: claude-3-haiku"));
725 assert!(new_content.contains("temperature:"));
727 assert!(new_content.contains("0.7"));
728 assert!(new_content.contains("max_tokens: 2000"));
729 }
730
731 #[test]
732 fn test_apply_patches_to_markdown_create_frontmatter() {
733 let content = "# Test Agent\n\nThis is a test agent without frontmatter.";
734
735 let mut patches = BTreeMap::new();
736 patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
737 patches.insert("temperature".to_string(), toml::Value::String("0.7".to_string()));
738
739 let (new_content, applied) =
740 apply_patches_to_content(content, "agent.md", &patches).unwrap();
741
742 assert_eq!(applied.len(), 2);
744
745 assert!(new_content.starts_with("---\n"));
747 assert!(new_content.contains("model: claude-3-haiku"));
748 assert!(new_content.contains("temperature:"));
750 assert!(new_content.contains("0.7"));
751 assert!(new_content.contains("# Test Agent"));
752 }
753
754 #[test]
755 fn test_apply_patches_to_json_simple() {
756 let content = r#"{
757 "name": "test-server",
758 "command": "npx",
759 "args": ["server"]
760}"#;
761
762 let mut patches = BTreeMap::new();
763 patches.insert("timeout".to_string(), toml::Value::Integer(300));
764
765 let (new_content, applied) =
766 apply_patches_to_content(content, "server.json", &patches).unwrap();
767
768 assert_eq!(applied.len(), 1);
770
771 let json: serde_json::Value = serde_json::from_str(&new_content).unwrap();
773 assert_eq!(json["timeout"], 300);
774 assert_eq!(json["name"], "test-server");
775 assert_eq!(json["command"], "npx");
776 }
777
778 #[test]
779 fn test_apply_patches_to_json_nested() {
780 let content = r#"{
781 "name": "test-server",
782 "config": {
783 "host": "localhost"
784 }
785}"#;
786
787 let mut patches = BTreeMap::new();
788
789 let mut nested_table = toml::value::Table::new();
791 nested_table.insert("port".to_string(), toml::Value::Integer(8080));
792 nested_table.insert("ssl".to_string(), toml::Value::Boolean(true));
793 patches.insert("server".to_string(), toml::Value::Table(nested_table));
794
795 let array = vec![
797 toml::Value::String("option1".to_string()),
798 toml::Value::String("option2".to_string()),
799 ];
800 patches.insert("options".to_string(), toml::Value::Array(array));
801
802 let (new_content, applied) =
803 apply_patches_to_content(content, "server.json", &patches).unwrap();
804
805 assert_eq!(applied.len(), 2);
807
808 let json: serde_json::Value = serde_json::from_str(&new_content).unwrap();
810 assert_eq!(json["name"], "test-server");
811 assert_eq!(json["server"]["port"], 8080);
812 assert_eq!(json["server"]["ssl"], true);
813 assert_eq!(json["options"][0], "option1");
814 assert_eq!(json["options"][1], "option2");
815 }
816
817 #[test]
818 fn test_apply_patches_to_content_empty_patches() {
819 let content = r#"---
820model: claude-3-opus
821---
822# Test Agent
823"#;
824
825 let patches = BTreeMap::new();
826
827 let (new_content, applied) =
828 apply_patches_to_content(content, "agent.md", &patches).unwrap();
829
830 assert!(applied.is_empty());
832
833 assert_eq!(new_content, content);
835 }
836
837 #[test]
838 fn test_apply_patches_to_content_unsupported_extension() {
839 let content = "This is a text file.";
840
841 let mut patches = BTreeMap::new();
842 patches.insert("field".to_string(), toml::Value::String("value".to_string()));
843
844 let (new_content, applied) =
845 apply_patches_to_content(content, "file.txt", &patches).unwrap();
846
847 assert!(applied.is_empty());
849
850 assert_eq!(new_content, content);
852 }
853
854 #[test]
855 fn test_toml_value_to_json_conversions() {
856 let toml_str = toml::Value::String("test".to_string());
858 let json_str = toml_value_to_json(&toml_str).unwrap();
859 assert_eq!(json_str, serde_json::Value::String("test".to_string()));
860
861 let toml_int = toml::Value::Integer(42);
863 let json_int = toml_value_to_json(&toml_int).unwrap();
864 assert_eq!(json_int, serde_json::json!(42));
865
866 let toml_float = toml::Value::Float(2.5);
868 let json_float = toml_value_to_json(&toml_float).unwrap();
869 assert_eq!(json_float, serde_json::json!(2.5));
870
871 let toml_bool = toml::Value::Boolean(true);
873 let json_bool = toml_value_to_json(&toml_bool).unwrap();
874 assert_eq!(json_bool, serde_json::Value::Bool(true));
875
876 let toml_array =
878 toml::Value::Array(vec![toml::Value::String("a".to_string()), toml::Value::Integer(1)]);
879 let json_array = toml_value_to_json(&toml_array).unwrap();
880 assert_eq!(json_array, serde_json::json!(["a", 1]));
881
882 let mut table = toml::value::Table::new();
884 table.insert("key".to_string(), toml::Value::String("value".to_string()));
885 table.insert("num".to_string(), toml::Value::Integer(123));
886 let toml_table = toml::Value::Table(table);
887 let json_table = toml_value_to_json(&toml_table).unwrap();
888 assert_eq!(json_table, serde_json::json!({"key": "value", "num": 123}));
889
890 let datetime_str = "2025-01-01T12:00:00Z";
892 let toml_datetime = toml::Value::Datetime(datetime_str.parse().unwrap());
893 let json_datetime = toml_value_to_json(&toml_datetime).unwrap();
894 assert_eq!(json_datetime, serde_json::Value::String(datetime_str.to_string()));
895 }
896
897 #[test]
898 fn test_get_for_resource_type() {
899 let mut patches = ManifestPatches::new();
900 let mut agent_patch = BTreeMap::new();
901 agent_patch.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
902 patches.agents.insert("test-agent".to_string(), agent_patch.clone());
903
904 let mut snippet_patch = BTreeMap::new();
905 snippet_patch.insert("lang".to_string(), toml::Value::String("rust".to_string()));
906 patches.snippets.insert("test-snippet".to_string(), snippet_patch.clone());
907
908 assert_eq!(patches.get_for_resource_type("agents").unwrap().len(), 1);
910 assert_eq!(patches.get_for_resource_type("snippets").unwrap().len(), 1);
911 assert_eq!(patches.get_for_resource_type("commands").unwrap().len(), 0);
912
913 assert!(patches.get_for_resource_type("invalid").is_none());
915 }
916
917 #[test]
918 fn test_patch_origin_serialization() {
919 let project = PatchOrigin::Project;
920 let private = PatchOrigin::Private;
921
922 let project_str = serde_json::to_string(&project).unwrap();
924 let private_str = serde_json::to_string(&private).unwrap();
925
926 assert_eq!(project_str, r#""project""#);
927 assert_eq!(private_str, r#""private""#);
928
929 let project_de: PatchOrigin = serde_json::from_str(&project_str).unwrap();
931 let private_de: PatchOrigin = serde_json::from_str(&private_str).unwrap();
932
933 assert_eq!(project_de, PatchOrigin::Project);
934 assert_eq!(private_de, PatchOrigin::Private);
935 }
936
937 #[test]
938 fn test_merge_different_resource_types() {
939 let mut base = ManifestPatches::new();
940 let mut base_agent_patch = BTreeMap::new();
941 base_agent_patch
942 .insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
943 base.agents.insert("test".to_string(), base_agent_patch);
944
945 let mut overlay = ManifestPatches::new();
946 let mut overlay_snippet_patch = BTreeMap::new();
947 overlay_snippet_patch.insert("lang".to_string(), toml::Value::String("rust".to_string()));
948 overlay.snippets.insert("test".to_string(), overlay_snippet_patch);
949
950 let (merged, conflicts) = base.merge_with(&overlay);
951
952 assert!(conflicts.is_empty());
954 assert_eq!(merged.agents.len(), 1);
955 assert_eq!(merged.snippets.len(), 1);
956 }
957
958 #[test]
959 fn test_merge_adds_new_aliases() {
960 let mut base = ManifestPatches::new();
961 let mut base_patch = BTreeMap::new();
962 base_patch.insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
963 base.agents.insert("agent1".to_string(), base_patch);
964
965 let mut overlay = ManifestPatches::new();
966 let mut overlay_patch = BTreeMap::new();
967 overlay_patch
968 .insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
969 overlay.agents.insert("agent2".to_string(), overlay_patch);
970
971 let (merged, conflicts) = base.merge_with(&overlay);
972
973 assert!(conflicts.is_empty());
975 assert_eq!(merged.agents.len(), 2);
976 assert!(merged.agents.contains_key("agent1"));
977 assert!(merged.agents.contains_key("agent2"));
978 }
979
980 #[test]
981 fn test_apply_patches_preserves_markdown_body() {
982 let content = r#"---
983model: claude-3-opus
984---
985# Test Agent
986
987This is the agent body with **markdown** formatting.
988
989- Item 1
990- Item 2
991
992```rust
993fn main() {
994 println!("Hello");
995}
996```
997"#;
998
999 let mut patches = BTreeMap::new();
1000 patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
1001
1002 let (new_content, _) = apply_patches_to_content(content, "agent.md", &patches).unwrap();
1003
1004 assert!(new_content.contains("# Test Agent"));
1006 assert!(new_content.contains("This is the agent body"));
1007 assert!(new_content.contains("**markdown**"));
1008 assert!(new_content.contains("- Item 1"));
1009 assert!(new_content.contains("```rust"));
1010 assert!(new_content.contains("fn main()"));
1011 }
1012
1013 #[test]
1014 fn test_json_patch_requires_object() {
1015 let content = r#"["array", "of", "strings"]"#;
1016
1017 let mut patches = BTreeMap::new();
1018 patches.insert("field".to_string(), toml::Value::String("value".to_string()));
1019
1020 let result = apply_patches_to_json(content, &patches);
1021
1022 assert!(result.is_err());
1024 assert!(result.unwrap_err().to_string().contains("top-level object"));
1025 }
1026
1027 #[test]
1028 fn test_merge_multiple_conflicts() {
1029 let mut base = ManifestPatches::new();
1030 let mut base_patch = BTreeMap::new();
1031 base_patch.insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
1032 base_patch.insert("temperature".to_string(), toml::Value::String("0.5".to_string()));
1033 base.agents.insert("test".to_string(), base_patch);
1034
1035 let mut overlay = ManifestPatches::new();
1036 let mut overlay_patch = BTreeMap::new();
1037 overlay_patch
1038 .insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
1039 overlay_patch.insert("temperature".to_string(), toml::Value::String("0.7".to_string()));
1040 overlay.agents.insert("test".to_string(), overlay_patch);
1041
1042 let (merged, conflicts) = base.merge_with(&overlay);
1043
1044 assert_eq!(conflicts.len(), 2);
1046
1047 let model_conflict = conflicts.iter().find(|c| c.field == "model").unwrap();
1049 assert_eq!(model_conflict.project_value, toml::Value::String("claude-3-opus".to_string()));
1050 assert_eq!(model_conflict.private_value, toml::Value::String("claude-3-haiku".to_string()));
1051
1052 let temp_conflict = conflicts.iter().find(|c| c.field == "temperature").unwrap();
1053 assert_eq!(temp_conflict.project_value, toml::Value::String("0.5".to_string()));
1054 assert_eq!(temp_conflict.private_value, toml::Value::String("0.7".to_string()));
1055
1056 assert_eq!(
1058 merged.agents.get("test").unwrap().get("model").unwrap(),
1059 &toml::Value::String("claude-3-haiku".to_string())
1060 );
1061 assert_eq!(
1062 merged.agents.get("test").unwrap().get("temperature").unwrap(),
1063 &toml::Value::String("0.7".to_string())
1064 );
1065 }
1066
1067 #[test]
1068 fn test_applied_patches_struct() {
1069 let applied = AppliedPatches {
1070 project: BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]),
1071 private: BTreeMap::from([(
1072 "temperature".to_string(),
1073 toml::Value::String("0.9".into()),
1074 )]),
1075 };
1076
1077 assert!(!applied.is_empty());
1078 assert_eq!(applied.total_count(), 2);
1079 assert_eq!(applied.project.len(), 1);
1080 assert_eq!(applied.private.len(), 1);
1081
1082 let empty = AppliedPatches::new();
1083 assert!(empty.is_empty());
1084 assert_eq!(empty.total_count(), 0);
1085 }
1086
1087 #[test]
1088 fn test_apply_patches_with_origin_separates_project_and_private() {
1089 let content = "---\nmodel: gpt-4\n---\n# Test\n";
1090 let project = BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
1091 let private =
1092 BTreeMap::from([("temperature".to_string(), toml::Value::String("0.9".into()))]);
1093
1094 let (result, applied) =
1095 apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1096
1097 assert_eq!(applied.project.len(), 1);
1098 assert_eq!(applied.private.len(), 1);
1099 assert!(result.contains("model: haiku"));
1100 assert!(result.contains("temperature:"));
1101 assert!(result.contains("0.9"));
1102 }
1103
1104 #[test]
1105 fn test_apply_patches_with_origin_empty_patches() {
1106 let content = "---\nmodel: gpt-4\n---\n# Test\n";
1107 let project = BTreeMap::new();
1108 let private = BTreeMap::new();
1109
1110 let (result, applied) =
1111 apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1112
1113 assert!(applied.is_empty());
1114 assert_eq!(result, content);
1115 }
1116
1117 #[test]
1118 fn test_apply_patches_with_origin_only_project() {
1119 let content = "---\nmodel: gpt-4\n---\n# Test\n";
1120 let project = BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
1121 let private = BTreeMap::new();
1122
1123 let (result, applied) =
1124 apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1125
1126 assert_eq!(applied.project.len(), 1);
1127 assert_eq!(applied.private.len(), 0);
1128 assert!(result.contains("model: haiku"));
1129 }
1130
1131 #[test]
1132 fn test_apply_patches_with_origin_only_private() {
1133 let content = "---\nmodel: gpt-4\n---\n# Test\n";
1134 let project = BTreeMap::new();
1135 let private =
1136 BTreeMap::from([("temperature".to_string(), toml::Value::String("0.9".into()))]);
1137
1138 let (result, applied) =
1139 apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1140
1141 assert_eq!(applied.project.len(), 0);
1142 assert_eq!(applied.private.len(), 1);
1143 assert!(result.contains("temperature:"));
1144 assert!(result.contains("0.9"));
1145 }
1146
1147 #[test]
1148 fn test_apply_patches_with_origin_private_overrides_project() {
1149 let content = "---\nmodel: gpt-4\n---\n# Test\n";
1150 let project = BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
1151 let private = BTreeMap::from([("model".to_string(), toml::Value::String("sonnet".into()))]);
1152
1153 let (result, applied) =
1154 apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1155
1156 assert_eq!(applied.project.len(), 1);
1158 assert_eq!(applied.private.len(), 1);
1159
1160 assert!(result.contains("model: sonnet"));
1162 assert!(!result.contains("model: haiku"));
1163 }
1164
1165 #[test]
1166 fn test_manifest_patches_deserialization() {
1167 let toml_str = r#"
1169[agents.all-helpers]
1170model = "claude-3-haiku"
1171max_tokens = "4096"
1172category = "utility"
1173"#;
1174
1175 let patches: ManifestPatches = toml::from_str(toml_str).unwrap();
1176 println!("Deserialized patches: {:?}", patches);
1177 println!("Agents: {:?}", patches.agents);
1178
1179 let agent_patches = patches.get("agents", "all-helpers");
1181 println!("Got patches: {:?}", agent_patches);
1182 assert!(agent_patches.is_some(), "Should find patches for 'all-helpers'");
1183
1184 let patch_data = agent_patches.unwrap();
1185 assert_eq!(patch_data.len(), 3, "Should have 3 patch fields");
1186 assert_eq!(patch_data.get("model").unwrap().as_str().unwrap(), "claude-3-haiku");
1187 assert_eq!(patch_data.get("max_tokens").unwrap().as_str().unwrap(), "4096");
1188 assert_eq!(patch_data.get("category").unwrap().as_str().unwrap(), "utility");
1189 }
1190
1191 #[test]
1192 fn test_full_manifest_with_patches() {
1193 let toml_str = r#"
1195[sources]
1196test = "https://example.com/repo.git"
1197
1198[agents]
1199all-helpers = { source = "test", path = "agents/helpers/*.md", version = "v1.0.0" }
1200
1201[patch.agents.all-helpers]
1202model = "claude-3-haiku"
1203max_tokens = "4096"
1204"#;
1205
1206 let manifest: crate::manifest::Manifest = toml::from_str(toml_str).unwrap();
1207 println!("Manifest patches: {:?}", manifest.patches);
1208 println!("Agents patches: {:?}", manifest.patches.agents);
1209
1210 let agent_patches = manifest.patches.get("agents", "all-helpers");
1212 println!("Got patches: {:?}", agent_patches);
1213 assert!(agent_patches.is_some(), "Should find patches for 'all-helpers' in full manifest");
1214
1215 let patch_data = agent_patches.unwrap();
1216 assert_eq!(patch_data.len(), 2, "Should have 2 patch fields");
1217 assert_eq!(patch_data.get("model").unwrap().as_str().unwrap(), "claude-3-haiku");
1218 }
1219}