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::BTreeMap;
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 = "BTreeMap::is_empty")]
49    pub agents: BTreeMap<String, PatchData>,
50
51    /// Patches for snippet resources.
52    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
53    pub snippets: BTreeMap<String, PatchData>,
54
55    /// Patches for command resources.
56    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57    pub commands: BTreeMap<String, PatchData>,
58
59    /// Patches for script resources.
60    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
61    pub scripts: BTreeMap<String, PatchData>,
62
63    /// Patches for MCP server resources.
64    #[serde(default, skip_serializing_if = "BTreeMap::is_empty", rename = "mcp-servers")]
65    pub mcp_servers: BTreeMap<String, PatchData>,
66
67    /// Patches for hook resources.
68    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
69    pub hooks: BTreeMap<String, PatchData>,
70
71    /// Patches for skill resources.
72    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73    pub skills: BTreeMap<String, PatchData>,
74}
75
76/// Arbitrary key-value pairs to override in a resource's metadata.
77///
78/// This is a free-form map that can contain any valid TOML values (strings, numbers,
79/// booleans, arrays, tables). The values will be merged into the resource's YAML
80/// frontmatter (for Markdown files) or top-level fields (for JSON files).
81///
82/// # Examples
83///
84/// ```toml
85/// [patch.agents.my-agent]
86/// model = "claude-3-haiku"
87/// temperature = "0.7"
88/// max_tokens = 2000
89/// ```
90pub type PatchData = BTreeMap<String, toml::Value>;
91
92/// Result of applying patches, separated by origin.
93///
94/// This structure tracks which patches came from project-level configuration
95/// (`agpm.toml`) vs private configuration (`agpm.private.toml`). This separation
96/// ensures that lockfiles remain deterministic across team members.
97///
98/// # Examples
99///
100/// ```no_run
101/// use agpm_cli::manifest::patches::AppliedPatches;
102/// use std::collections::BTreeMap;
103///
104/// let applied = AppliedPatches {
105///     project: BTreeMap::from([
106///         ("model".to_string(), toml::Value::String("haiku".into())),
107///     ]),
108///     private: BTreeMap::from([
109///         ("temperature".to_string(), toml::Value::String("0.9".into())),
110///     ]),
111/// };
112///
113/// assert!(!applied.is_empty());
114/// assert_eq!(applied.total_count(), 2);
115/// ```
116#[derive(Debug, Clone, Default, PartialEq)]
117pub struct AppliedPatches {
118    /// Patches from `agpm.toml` (project-level).
119    pub project: BTreeMap<String, toml::Value>,
120    /// Patches from `agpm.private.toml` (user-level).
121    pub private: BTreeMap<String, toml::Value>,
122}
123
124impl AppliedPatches {
125    /// Creates an empty applied patches collection.
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// Creates an AppliedPatches from a lockfile's combined patches HashMap.
131    ///
132    /// The lockfile doesn't distinguish between project and private patches,
133    /// so this method places all patches in the `project` field.
134    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    /// Checks if no patches were applied.
142    pub fn is_empty(&self) -> bool {
143        self.project.is_empty() && self.private.is_empty()
144    }
145
146    /// Returns the total number of patches applied.
147    pub fn total_count(&self) -> usize {
148        self.project.len() + self.private.len()
149    }
150}
151
152/// Origin of a patch (project or private configuration).
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum PatchOrigin {
156    /// Patch defined in project-level `agpm.toml`.
157    Project,
158    /// Patch defined in user-level `agpm.private.toml`.
159    Private,
160}
161
162/// A merged patch with its origin information.
163///
164/// Tracks which fields came from which configuration file for debugging and
165/// diagnostic purposes.
166#[derive(Debug, Clone, PartialEq)]
167pub struct MergedPatch {
168    /// The merged patch data.
169    pub data: PatchData,
170    /// Origin of each field (for diagnostics).
171    pub field_origins: BTreeMap<String, PatchOrigin>,
172}
173
174impl ManifestPatches {
175    /// Creates an empty patches collection.
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Checks if there are any patches defined.
181    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    /// Gets the patch data for a specific resource type and alias.
192    ///
193    /// Returns `None` if no patch is defined for the given resource type and alias.
194    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    /// Merges another patches collection into this one.
208    ///
209    /// Fields from `other` take precedence over fields in `self`. This is used to
210    /// merge private patches over project patches.
211    ///
212    /// # Arguments
213    ///
214    /// * `other` - The patches to merge in (higher precedence)
215    ///
216    /// # Returns
217    ///
218    /// A new `ManifestPatches` with merged data and a map of conflicts detected.
219    pub fn merge_with(&self, other: &ManifestPatches) -> (ManifestPatches, Vec<PatchConflict>) {
220        let mut merged = self.clone();
221        let mut conflicts = Vec::new();
222
223        // Merge each resource type
224        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    /// Helper to merge patches for a specific resource type.
256    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                // Merge patches for this alias
265                for (key, overlay_value) in overlay_patch {
266                    if let Some(base_value) = base_patch.get(key) {
267                        // Check for conflicts
268                        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                    // Private patch takes precedence
279                    base_patch.insert(key.clone(), overlay_value.clone());
280                }
281            } else {
282                // No existing patch, insert the entire private patch
283                base.insert(alias.clone(), overlay_patch.clone());
284            }
285        }
286    }
287
288    /// Get all patch data for a specific resource type.
289    ///
290    /// Returns a reference to the map of patches for the given resource type.
291    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
307/// Apply patches to resource file content.
308///
309/// This function applies patch data to either Markdown files (with YAML frontmatter)
310/// or JSON files, returning the modified content and the patches that were applied.
311///
312/// # Arguments
313///
314/// * `content` - The original file content
315/// * `file_path` - Path to the file (used to determine file type)
316/// * `patch_data` - The patches to apply
317///
318/// # Returns
319///
320/// A tuple of:
321/// - Modified content string
322/// - Map of applied patches (for lockfile tracking)
323///
324/// # Examples
325///
326/// ```no_run
327/// use agpm_cli::manifest::patches::apply_patches_to_content;
328/// use std::collections::BTreeMap;
329///
330/// let content = "---\nmodel: claude-3-opus\n---\n# Agent\n\nContent here.";
331/// let mut patches = BTreeMap::new();
332/// patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
333///
334/// let (new_content, applied) = apply_patches_to_content(
335///     content,
336///     "agent.md",
337///     &patches
338/// )?;
339/// # Ok::<(), anyhow::Error>(())
340/// ```
341pub 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            // For other file types, we can't apply patches
365            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
375/// Apply patches from both project and private sources, tracking origins separately.
376///
377/// This function applies project patches first, then private patches, and tracks which
378/// patches came from which source. This separation is critical for maintaining
379/// deterministic lockfiles while allowing user-level customization.
380///
381/// # Arguments
382///
383/// * `content` - The original file content
384/// * `file_path` - Path to the file (used to determine file type)
385/// * `project_patches` - Patches from `agpm.toml`
386/// * `private_patches` - Patches from `agpm.private.toml`
387///
388/// # Returns
389///
390/// A tuple of:
391/// - Modified content string
392/// - `AppliedPatches` struct with separated project and private patches
393///
394/// # Examples
395///
396/// ```no_run
397/// use agpm_cli::manifest::patches::apply_patches_to_content_with_origin;
398/// use std::collections::BTreeMap;
399///
400/// let content = "---\nmodel: claude-3-opus\n---\n# Agent\n";
401/// let project = BTreeMap::from([
402///     ("model".to_string(), toml::Value::String("haiku".into())),
403/// ]);
404/// let private = BTreeMap::from([
405///     ("temperature".to_string(), toml::Value::String("0.9".into())),
406/// ]);
407///
408/// let (new_content, applied) = apply_patches_to_content_with_origin(
409///     content,
410///     "agent.md",
411///     &project,
412///     &private
413/// )?;
414///
415/// assert_eq!(applied.project.len(), 1);
416/// assert_eq!(applied.private.len(), 1);
417/// # Ok::<(), anyhow::Error>(())
418/// ```
419pub 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    // Merge patches first, with private taking precedence over project
426    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    // Apply the merged patches in a single pass to avoid duplicate frontmatter
432    let (final_content, all_applied) =
433        apply_patches_to_content(content, file_path, &merged_patches)?;
434
435    // Track which patches were actually applied by origin
436    // Note: When both project and private define the same key, we track BOTH
437    // even though only the private value ends up in the content
438    let mut project_applied = BTreeMap::new();
439    let mut private_applied = BTreeMap::new();
440
441    for key in all_applied.keys() {
442        // Track project patches
443        if let Some(value) = project_patches.get(key) {
444            project_applied.insert(key.clone(), value.clone());
445        }
446        // Track private patches (may override project)
447        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
461/// Apply patches to Markdown file with YAML frontmatter.
462fn 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    // Parse the markdown file (pass file_path for warning deduplication)
470    let mut md_doc = MarkdownDocument::parse_with_operation_context(
471        content,
472        Some(file_path),
473        None, // No operation context available here, but file path helps deduplication
474    )?;
475
476    let mut applied_patches = BTreeMap::new();
477
478    // Apply each patch to the metadata in deterministic order (sorted by key)
479    // This ensures consistent file content across runs
480    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        // Convert toml::Value to serde_json::Value for the extra fields
486        let json_value = toml_value_to_json(value)?;
487
488        // Get or create metadata
489        let metadata = md_doc.metadata.get_or_insert_with(Default::default);
490
491        // Update the extra fields
492        metadata.extra.insert(key.clone(), json_value);
493
494        applied_patches.insert(key.clone(), value.clone());
495    }
496
497    // Update the document with the modified metadata
498    if let Some(metadata) = md_doc.metadata.clone() {
499        md_doc.set_metadata(metadata);
500    }
501
502    // Return the raw content with frontmatter
503    Ok((md_doc.raw, applied_patches))
504}
505
506/// Apply patches to JSON file.
507fn apply_patches_to_json(
508    content: &str,
509    patch_data: &PatchData,
510) -> anyhow::Result<(String, BTreeMap<String, toml::Value>)> {
511    // Parse the JSON
512    let mut json_value: serde_json::Value = serde_json::from_str(content)?;
513
514    let mut applied_patches = BTreeMap::new();
515
516    // Apply each patch to the top-level JSON object in deterministic order (sorted by key)
517    // This ensures consistent file content across runs
518    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            // Convert toml::Value to serde_json::Value
525            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    // Serialize back to pretty JSON
534    let new_content = serde_json::to_string_pretty(&json_value)?;
535
536    Ok((new_content, applied_patches))
537}
538
539/// Convert toml::Value to serde_json::Value.
540pub(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
544/// Convert toml::Value to serde_json::Value with recursion depth limit.
545///
546/// # Arguments
547///
548/// * `value` - The TOML value to convert
549/// * `depth` - Current recursion depth
550///
551/// # Returns
552///
553/// The converted JSON value, or an error if depth limit is exceeded
554fn 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/// Represents a conflict between project and private patches.
594#[derive(Debug, Clone, PartialEq)]
595pub struct PatchConflict {
596    /// Resource type (agents, snippets, etc.).
597    pub resource_type: String,
598    /// Manifest alias.
599    pub alias: String,
600    /// Field name that has conflicting values.
601    pub field: String,
602    /// Value from project-level patch.
603    pub project_value: toml::Value,
604    /// Value from private-level patch.
605    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        // Private (overlay) should win
677        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        // Verify the patch was applied
701        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        // Verify the content contains the new model
708        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        // Verify all patches were applied
730        assert_eq!(applied.len(), 3);
731        assert!(new_content.contains("model: claude-3-haiku"));
732        // Temperature can be serialized as "0.7" or 0.7 depending on YAML serializer
733        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        // Verify patches were applied
750        assert_eq!(applied.len(), 2);
751
752        // Verify frontmatter was created
753        assert!(new_content.starts_with("---\n"));
754        assert!(new_content.contains("model: claude-3-haiku"));
755        // Temperature can be serialized as "0.7" or 0.7 depending on YAML serializer
756        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        // Verify patch was applied
776        assert_eq!(applied.len(), 1);
777
778        // Parse JSON to verify structure
779        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        // Add nested object
797        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        // Add array
803        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        // Verify patches were applied
813        assert_eq!(applied.len(), 2);
814
815        // Parse JSON to verify structure
816        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        // Verify no patches were applied
838        assert!(applied.is_empty());
839
840        // Content should be unchanged
841        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        // Verify no patches were applied (unsupported file type)
855        assert!(applied.is_empty());
856
857        // Content should be unchanged
858        assert_eq!(new_content, content);
859    }
860
861    #[test]
862    fn test_toml_value_to_json_conversions() {
863        // Test string conversion
864        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        // Test integer conversion
869        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        // Test float conversion
874        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        // Test boolean conversion
879        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        // Test array conversion
884        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        // Test table (object) conversion
890        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        // Test datetime conversion
898        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        // Test getting valid resource types
916        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        // Test invalid resource type
921        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        // Test serialization
930        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        // Test deserialization
937        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        // No conflicts since they're different resource types
960        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        // No conflicts since they're different aliases
981        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        // Verify body is preserved
1012        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        // Should fail because JSON is not an object
1030        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        // Should have 2 conflicts
1052        assert_eq!(conflicts.len(), 2);
1053
1054        // Check both conflicts are present
1055        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        // Private values should win
1064        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        // Both patches are tracked separately
1164        assert_eq!(applied.project.len(), 1);
1165        assert_eq!(applied.private.len(), 1);
1166
1167        // But private wins in the final content
1168        assert!(result.contains("model: sonnet"));
1169        assert!(!result.contains("model: haiku"));
1170    }
1171
1172    #[test]
1173    fn test_manifest_patches_deserialization() {
1174        // Test that patches can be deserialized from TOML correctly
1175        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        // Verify we can get the patches
1187        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        // Test that patches work correctly when embedded in a full Manifest
1201        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        // Verify we can get the patches using the get method
1218        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}