agpm_cli/manifest/
patches.rs

1//! Manifest patch support for overriding resource metadata.
2//!
3//! Patches allow users to override YAML frontmatter fields in resources without forking
4//! upstream repositories. They work at both project-level (`agpm.toml`) and user-level
5//! (`agpm.private.toml`) with clear precedence rules.
6//!
7//! # Examples
8//!
9//! ```toml
10//! # In agpm.toml or agpm.private.toml
11//! [patch.agents.my-agent]
12//! model = "claude-3-haiku"
13//! temperature = "0.7"
14//!
15//! [patch.commands.deploy]
16//! timeout = "300"
17//! ```
18
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21
22/// Collection of patches for all resource types.
23///
24/// Patches are keyed by resource type (agents, snippets, commands, etc.) and then by
25/// manifest alias. Each patch contains arbitrary key-value pairs that will be merged
26/// into the resource's YAML frontmatter or JSON fields.
27///
28/// # Precedence
29///
30/// When patches are defined in both `agpm.toml` and `agpm.private.toml`:
31/// - Private patches silently override project patches for the same field
32/// - If a field is only defined in one location, that value is used
33/// - No error is raised for conflicts - private always wins
34///
35/// # Examples
36///
37/// ```toml
38/// [patch.agents.gpt-agent]
39/// model = "claude-3-haiku"
40/// temperature = "0.7"
41///
42/// [patch.commands.deploy]
43/// timeout = "300"
44/// ```
45#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
46pub struct ManifestPatches {
47    /// Patches for agent resources.
48    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
49    pub agents: HashMap<String, PatchData>,
50
51    /// Patches for snippet resources.
52    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
53    pub snippets: HashMap<String, PatchData>,
54
55    /// Patches for command resources.
56    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
57    pub commands: HashMap<String, PatchData>,
58
59    /// Patches for script resources.
60    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
61    pub scripts: HashMap<String, PatchData>,
62
63    /// Patches for MCP server resources.
64    #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "mcp-servers")]
65    pub mcp_servers: HashMap<String, PatchData>,
66
67    /// Patches for hook resources.
68    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69    pub hooks: HashMap<String, PatchData>,
70}
71
72/// Arbitrary key-value pairs to override in a resource's metadata.
73///
74/// This is a free-form map that can contain any valid TOML values (strings, numbers,
75/// booleans, arrays, tables). The values will be merged into the resource's YAML
76/// frontmatter (for Markdown files) or top-level fields (for JSON files).
77///
78/// # Examples
79///
80/// ```toml
81/// [patch.agents.my-agent]
82/// model = "claude-3-haiku"
83/// temperature = "0.7"
84/// max_tokens = 2000
85/// ```
86pub type PatchData = HashMap<String, toml::Value>;
87
88/// Result of applying patches, separated by origin.
89///
90/// This structure tracks which patches came from project-level configuration
91/// (`agpm.toml`) vs private configuration (`agpm.private.toml`). This separation
92/// ensures that lockfiles remain deterministic across team members.
93///
94/// # Examples
95///
96/// ```no_run
97/// use agpm_cli::manifest::patches::AppliedPatches;
98/// use std::collections::HashMap;
99///
100/// let applied = AppliedPatches {
101///     project: HashMap::from([
102///         ("model".to_string(), toml::Value::String("haiku".into())),
103///     ]),
104///     private: HashMap::from([
105///         ("temperature".to_string(), toml::Value::String("0.9".into())),
106///     ]),
107/// };
108///
109/// assert!(!applied.is_empty());
110/// assert_eq!(applied.total_count(), 2);
111/// ```
112#[derive(Debug, Clone, Default, PartialEq)]
113pub struct AppliedPatches {
114    /// Patches from `agpm.toml` (project-level).
115    pub project: HashMap<String, toml::Value>,
116    /// Patches from `agpm.private.toml` (user-level).
117    pub private: HashMap<String, toml::Value>,
118}
119
120impl AppliedPatches {
121    /// Creates an empty applied patches collection.
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Checks if no patches were applied.
127    pub fn is_empty(&self) -> bool {
128        self.project.is_empty() && self.private.is_empty()
129    }
130
131    /// Returns the total number of patches applied.
132    pub fn total_count(&self) -> usize {
133        self.project.len() + self.private.len()
134    }
135}
136
137/// Origin of a patch (project or private configuration).
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "lowercase")]
140pub enum PatchOrigin {
141    /// Patch defined in project-level `agpm.toml`.
142    Project,
143    /// Patch defined in user-level `agpm.private.toml`.
144    Private,
145}
146
147/// A merged patch with its origin information.
148///
149/// Tracks which fields came from which configuration file for debugging and
150/// diagnostic purposes.
151#[derive(Debug, Clone, PartialEq)]
152pub struct MergedPatch {
153    /// The merged patch data.
154    pub data: PatchData,
155    /// Origin of each field (for diagnostics).
156    pub field_origins: HashMap<String, PatchOrigin>,
157}
158
159impl ManifestPatches {
160    /// Creates an empty patches collection.
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Checks if there are any patches defined.
166    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    /// Gets the patch data for a specific resource type and alias.
176    ///
177    /// Returns `None` if no patch is defined for the given resource type and alias.
178    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    /// Merges another patches collection into this one.
191    ///
192    /// Fields from `other` take precedence over fields in `self`. This is used to
193    /// merge private patches over project patches.
194    ///
195    /// # Arguments
196    ///
197    /// * `other` - The patches to merge in (higher precedence)
198    ///
199    /// # Returns
200    ///
201    /// A new `ManifestPatches` with merged data and a map of conflicts detected.
202    pub fn merge_with(&self, other: &ManifestPatches) -> (ManifestPatches, Vec<PatchConflict>) {
203        let mut merged = self.clone();
204        let mut conflicts = Vec::new();
205
206        // Merge each resource type
207        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    /// Helper to merge patches for a specific resource type.
238    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                // Merge patches for this alias
247                for (key, overlay_value) in overlay_patch {
248                    if let Some(base_value) = base_patch.get(key) {
249                        // Check for conflicts
250                        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                    // Private patch takes precedence
261                    base_patch.insert(key.clone(), overlay_value.clone());
262                }
263            } else {
264                // No existing patch, insert the entire private patch
265                base.insert(alias.clone(), overlay_patch.clone());
266            }
267        }
268    }
269
270    /// Get all patch data for a specific resource type.
271    ///
272    /// Returns a reference to the map of patches for the given resource type.
273    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
289/// Apply patches to resource file content.
290///
291/// This function applies patch data to either Markdown files (with YAML frontmatter)
292/// or JSON files, returning the modified content and the patches that were applied.
293///
294/// # Arguments
295///
296/// * `content` - The original file content
297/// * `file_path` - Path to the file (used to determine file type)
298/// * `patch_data` - The patches to apply
299///
300/// # Returns
301///
302/// A tuple of:
303/// - Modified content string
304/// - Map of applied patches (for lockfile tracking)
305///
306/// # Examples
307///
308/// ```no_run
309/// use agpm_cli::manifest::patches::apply_patches_to_content;
310/// use std::collections::HashMap;
311///
312/// let content = "---\nmodel: claude-3-opus\n---\n# Agent\n\nContent here.";
313/// let mut patches = HashMap::new();
314/// patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
315///
316/// let (new_content, applied) = apply_patches_to_content(
317///     content,
318///     "agent.md",
319///     &patches
320/// )?;
321/// # Ok::<(), anyhow::Error>(())
322/// ```
323pub 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            // For other file types, we can't apply patches
347            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
357/// Apply patches from both project and private sources, tracking origins separately.
358///
359/// This function applies project patches first, then private patches, and tracks which
360/// patches came from which source. This separation is critical for maintaining
361/// deterministic lockfiles while allowing user-level customization.
362///
363/// # Arguments
364///
365/// * `content` - The original file content
366/// * `file_path` - Path to the file (used to determine file type)
367/// * `project_patches` - Patches from `agpm.toml`
368/// * `private_patches` - Patches from `agpm.private.toml`
369///
370/// # Returns
371///
372/// A tuple of:
373/// - Modified content string
374/// - `AppliedPatches` struct with separated project and private patches
375///
376/// # Examples
377///
378/// ```no_run
379/// use agpm_cli::manifest::patches::apply_patches_to_content_with_origin;
380/// use std::collections::HashMap;
381///
382/// let content = "---\nmodel: claude-3-opus\n---\n# Agent\n";
383/// let project = HashMap::from([
384///     ("model".to_string(), toml::Value::String("haiku".into())),
385/// ]);
386/// let private = HashMap::from([
387///     ("temperature".to_string(), toml::Value::String("0.9".into())),
388/// ]);
389///
390/// let (new_content, applied) = apply_patches_to_content_with_origin(
391///     content,
392///     "agent.md",
393///     &project,
394///     &private
395/// )?;
396///
397/// assert_eq!(applied.project.len(), 1);
398/// assert_eq!(applied.private.len(), 1);
399/// # Ok::<(), anyhow::Error>(())
400/// ```
401pub 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    // Merge patches first, with private taking precedence over project
408    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    // Apply the merged patches in a single pass to avoid duplicate frontmatter
414    let (final_content, all_applied) =
415        apply_patches_to_content(content, file_path, &merged_patches)?;
416
417    // Track which patches were actually applied by origin
418    // Note: When both project and private define the same key, we track BOTH
419    // even though only the private value ends up in the content
420    let mut project_applied = HashMap::new();
421    let mut private_applied = HashMap::new();
422
423    for key in all_applied.keys() {
424        // Track project patches
425        if let Some(value) = project_patches.get(key) {
426            project_applied.insert(key.clone(), value.clone());
427        }
428        // Track private patches (may override project)
429        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
443/// Apply patches to Markdown file with YAML frontmatter.
444fn 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    // Parse the markdown file
451    let mut md_doc = MarkdownDocument::parse(content)?;
452
453    let mut applied_patches = HashMap::new();
454
455    // Apply each patch to the metadata
456    for (key, value) in patch_data {
457        // Convert toml::Value to serde_json::Value for the extra fields
458        let json_value = toml_value_to_json(value)?;
459
460        // Get or create metadata
461        let metadata = md_doc.metadata.get_or_insert_with(Default::default);
462
463        // Update the extra fields
464        metadata.extra.insert(key.clone(), json_value);
465
466        applied_patches.insert(key.clone(), value.clone());
467    }
468
469    // Update the document with the modified metadata
470    if let Some(metadata) = md_doc.metadata.clone() {
471        md_doc.set_metadata(metadata);
472    }
473
474    // Return the raw content with frontmatter
475    Ok((md_doc.raw, applied_patches))
476}
477
478/// Apply patches to JSON file.
479fn apply_patches_to_json(
480    content: &str,
481    patch_data: &PatchData,
482) -> anyhow::Result<(String, HashMap<String, toml::Value>)> {
483    // Parse the JSON
484    let mut json_value: serde_json::Value = serde_json::from_str(content)?;
485
486    let mut applied_patches = HashMap::new();
487
488    // Apply each patch to the top-level JSON object
489    if let serde_json::Value::Object(ref mut map) = json_value {
490        for (key, value) in patch_data {
491            // Convert toml::Value to serde_json::Value
492            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    // Serialize back to pretty JSON
501    let new_content = serde_json::to_string_pretty(&json_value)?;
502
503    Ok((new_content, applied_patches))
504}
505
506/// Convert toml::Value to serde_json::Value.
507fn toml_value_to_json(value: &toml::Value) -> anyhow::Result<serde_json::Value> {
508    toml_value_to_json_with_depth(value, 0)
509}
510
511/// Convert toml::Value to serde_json::Value with recursion depth limit.
512///
513/// # Arguments
514///
515/// * `value` - The TOML value to convert
516/// * `depth` - Current recursion depth
517///
518/// # Returns
519///
520/// The converted JSON value, or an error if depth limit is exceeded
521fn 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/// Represents a conflict between project and private patches.
561#[derive(Debug, Clone, PartialEq)]
562pub struct PatchConflict {
563    /// Resource type (agents, snippets, etc.).
564    pub resource_type: String,
565    /// Manifest alias.
566    pub alias: String,
567    /// Field name that has conflicting values.
568    pub field: String,
569    /// Value from project-level patch.
570    pub project_value: toml::Value,
571    /// Value from private-level patch.
572    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        // Private (overlay) should win
644        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        // Verify the patch was applied
668        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        // Verify the content contains the new model
675        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        // Verify all patches were applied
697        assert_eq!(applied.len(), 3);
698        assert!(new_content.contains("model: claude-3-haiku"));
699        // Temperature can be serialized as "0.7" or 0.7 depending on YAML serializer
700        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        // Verify patches were applied
717        assert_eq!(applied.len(), 2);
718
719        // Verify frontmatter was created
720        assert!(new_content.starts_with("---\n"));
721        assert!(new_content.contains("model: claude-3-haiku"));
722        // Temperature can be serialized as "0.7" or 0.7 depending on YAML serializer
723        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        // Verify patch was applied
743        assert_eq!(applied.len(), 1);
744
745        // Parse JSON to verify structure
746        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        // Add nested object
764        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        // Add array
770        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        // Verify patches were applied
780        assert_eq!(applied.len(), 2);
781
782        // Parse JSON to verify structure
783        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        // Verify no patches were applied
805        assert!(applied.is_empty());
806
807        // Content should be unchanged
808        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        // Verify no patches were applied (unsupported file type)
822        assert!(applied.is_empty());
823
824        // Content should be unchanged
825        assert_eq!(new_content, content);
826    }
827
828    #[test]
829    fn test_toml_value_to_json_conversions() {
830        // Test string conversion
831        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        // Test integer conversion
836        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        // Test float conversion
841        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        // Test boolean conversion
846        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        // Test array conversion
851        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        // Test table (object) conversion
857        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        // Test datetime conversion
865        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        // Test getting valid resource types
883        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        // Test invalid resource type
888        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        // Test serialization
897        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        // Test deserialization
904        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        // No conflicts since they're different resource types
927        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        // No conflicts since they're different aliases
948        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        // Verify body is preserved
979        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        // Should fail because JSON is not an object
997        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        // Should have 2 conflicts
1019        assert_eq!(conflicts.len(), 2);
1020
1021        // Check both conflicts are present
1022        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        // Private values should win
1031        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        // Both patches are tracked separately
1131        assert_eq!(applied.project.len(), 1);
1132        assert_eq!(applied.private.len(), 1);
1133
1134        // But private wins in the final content
1135        assert!(result.contains("model: sonnet"));
1136        assert!(!result.contains("model: haiku"));
1137    }
1138
1139    #[test]
1140    fn test_manifest_patches_deserialization() {
1141        // Test that patches can be deserialized from TOML correctly
1142        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        // Verify we can get the patches
1154        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        // Test that patches work correctly when embedded in a full Manifest
1168        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        // Verify we can get the patches using the get method
1185        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}