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