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