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
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 = BTreeMap<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::BTreeMap;
99///
100/// let applied = AppliedPatches {
101///     project: BTreeMap::from([
102///         ("model".to_string(), toml::Value::String("haiku".into())),
103///     ]),
104///     private: BTreeMap::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: BTreeMap<String, toml::Value>,
116    /// Patches from `agpm.private.toml` (user-level).
117    pub private: BTreeMap<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    /// Creates an AppliedPatches from a lockfile's combined patches HashMap.
127    ///
128    /// The lockfile doesn't distinguish between project and private patches,
129    /// so this method places all patches in the `project` field.
130    pub fn from_lockfile_patches(patches: &BTreeMap<String, toml::Value>) -> Self {
131        Self {
132            project: patches.clone(),
133            private: BTreeMap::new(),
134        }
135    }
136
137    /// Checks if no patches were applied.
138    pub fn is_empty(&self) -> bool {
139        self.project.is_empty() && self.private.is_empty()
140    }
141
142    /// Returns the total number of patches applied.
143    pub fn total_count(&self) -> usize {
144        self.project.len() + self.private.len()
145    }
146}
147
148/// Origin of a patch (project or private configuration).
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum PatchOrigin {
152    /// Patch defined in project-level `agpm.toml`.
153    Project,
154    /// Patch defined in user-level `agpm.private.toml`.
155    Private,
156}
157
158/// A merged patch with its origin information.
159///
160/// Tracks which fields came from which configuration file for debugging and
161/// diagnostic purposes.
162#[derive(Debug, Clone, PartialEq)]
163pub struct MergedPatch {
164    /// The merged patch data.
165    pub data: PatchData,
166    /// Origin of each field (for diagnostics).
167    pub field_origins: BTreeMap<String, PatchOrigin>,
168}
169
170impl ManifestPatches {
171    /// Creates an empty patches collection.
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Checks if there are any patches defined.
177    pub fn is_empty(&self) -> bool {
178        self.agents.is_empty()
179            && self.snippets.is_empty()
180            && self.commands.is_empty()
181            && self.scripts.is_empty()
182            && self.mcp_servers.is_empty()
183            && self.hooks.is_empty()
184    }
185
186    /// Gets the patch data for a specific resource type and alias.
187    ///
188    /// Returns `None` if no patch is defined for the given resource type and alias.
189    pub fn get(&self, resource_type: &str, alias: &str) -> Option<&PatchData> {
190        match resource_type {
191            "agents" => self.agents.get(alias),
192            "snippets" => self.snippets.get(alias),
193            "commands" => self.commands.get(alias),
194            "scripts" => self.scripts.get(alias),
195            "mcp-servers" => self.mcp_servers.get(alias),
196            "hooks" => self.hooks.get(alias),
197            _ => None,
198        }
199    }
200
201    /// Merges another patches collection into this one.
202    ///
203    /// Fields from `other` take precedence over fields in `self`. This is used to
204    /// merge private patches over project patches.
205    ///
206    /// # Arguments
207    ///
208    /// * `other` - The patches to merge in (higher precedence)
209    ///
210    /// # Returns
211    ///
212    /// A new `ManifestPatches` with merged data and a map of conflicts detected.
213    pub fn merge_with(&self, other: &ManifestPatches) -> (ManifestPatches, Vec<PatchConflict>) {
214        let mut merged = self.clone();
215        let mut conflicts = Vec::new();
216
217        // Merge each resource type
218        Self::merge_resource_patches(&mut merged.agents, &other.agents, "agents", &mut conflicts);
219        Self::merge_resource_patches(
220            &mut merged.snippets,
221            &other.snippets,
222            "snippets",
223            &mut conflicts,
224        );
225        Self::merge_resource_patches(
226            &mut merged.commands,
227            &other.commands,
228            "commands",
229            &mut conflicts,
230        );
231        Self::merge_resource_patches(
232            &mut merged.scripts,
233            &other.scripts,
234            "scripts",
235            &mut conflicts,
236        );
237        Self::merge_resource_patches(
238            &mut merged.mcp_servers,
239            &other.mcp_servers,
240            "mcp-servers",
241            &mut conflicts,
242        );
243        Self::merge_resource_patches(&mut merged.hooks, &other.hooks, "hooks", &mut conflicts);
244
245        (merged, conflicts)
246    }
247
248    /// Helper to merge patches for a specific resource type.
249    fn merge_resource_patches(
250        base: &mut BTreeMap<String, PatchData>,
251        overlay: &BTreeMap<String, PatchData>,
252        resource_type: &str,
253        conflicts: &mut Vec<PatchConflict>,
254    ) {
255        for (alias, overlay_patch) in overlay {
256            if let Some(base_patch) = base.get_mut(alias) {
257                // Merge patches for this alias
258                for (key, overlay_value) in overlay_patch {
259                    if let Some(base_value) = base_patch.get(key) {
260                        // Check for conflicts
261                        if base_value != overlay_value {
262                            conflicts.push(PatchConflict {
263                                resource_type: resource_type.to_string(),
264                                alias: alias.clone(),
265                                field: key.clone(),
266                                project_value: base_value.clone(),
267                                private_value: overlay_value.clone(),
268                            });
269                        }
270                    }
271                    // Private patch takes precedence
272                    base_patch.insert(key.clone(), overlay_value.clone());
273                }
274            } else {
275                // No existing patch, insert the entire private patch
276                base.insert(alias.clone(), overlay_patch.clone());
277            }
278        }
279    }
280
281    /// Get all patch data for a specific resource type.
282    ///
283    /// Returns a reference to the map of patches for the given resource type.
284    pub fn get_for_resource_type(
285        &self,
286        resource_type: &str,
287    ) -> Option<&BTreeMap<String, PatchData>> {
288        match resource_type {
289            "agents" => Some(&self.agents),
290            "snippets" => Some(&self.snippets),
291            "commands" => Some(&self.commands),
292            "scripts" => Some(&self.scripts),
293            "mcp-servers" => Some(&self.mcp_servers),
294            "hooks" => Some(&self.hooks),
295            _ => None,
296        }
297    }
298}
299
300/// Apply patches to resource file content.
301///
302/// This function applies patch data to either Markdown files (with YAML frontmatter)
303/// or JSON files, returning the modified content and the patches that were applied.
304///
305/// # Arguments
306///
307/// * `content` - The original file content
308/// * `file_path` - Path to the file (used to determine file type)
309/// * `patch_data` - The patches to apply
310///
311/// # Returns
312///
313/// A tuple of:
314/// - Modified content string
315/// - Map of applied patches (for lockfile tracking)
316///
317/// # Examples
318///
319/// ```no_run
320/// use agpm_cli::manifest::patches::apply_patches_to_content;
321/// use std::collections::BTreeMap;
322///
323/// let content = "---\nmodel: claude-3-opus\n---\n# Agent\n\nContent here.";
324/// let mut patches = BTreeMap::new();
325/// patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
326///
327/// let (new_content, applied) = apply_patches_to_content(
328///     content,
329///     "agent.md",
330///     &patches
331/// )?;
332/// # Ok::<(), anyhow::Error>(())
333/// ```
334pub fn apply_patches_to_content(
335    content: &str,
336    file_path: &str,
337    patch_data: &PatchData,
338) -> anyhow::Result<(String, BTreeMap<String, toml::Value>)> {
339    tracing::info!(
340        "apply_patches_to_content: file={}, patches_empty={}, patch_count={}",
341        file_path,
342        patch_data.is_empty(),
343        patch_data.len()
344    );
345
346    if patch_data.is_empty() {
347        return Ok((content.to_string(), BTreeMap::new()));
348    }
349
350    let file_ext =
351        std::path::Path::new(file_path).extension().and_then(|s| s.to_str()).unwrap_or("");
352
353    match file_ext {
354        "md" => apply_patches_to_markdown(content, file_path, patch_data),
355        "json" => apply_patches_to_json(content, patch_data),
356        _ => {
357            // For other file types, we can't apply patches
358            tracing::warn!(
359                "Cannot apply patches to file type '{}' for file: {}",
360                file_ext,
361                file_path
362            );
363            Ok((content.to_string(), BTreeMap::new()))
364        }
365    }
366}
367
368/// Apply patches from both project and private sources, tracking origins separately.
369///
370/// This function applies project patches first, then private patches, and tracks which
371/// patches came from which source. This separation is critical for maintaining
372/// deterministic lockfiles while allowing user-level customization.
373///
374/// # Arguments
375///
376/// * `content` - The original file content
377/// * `file_path` - Path to the file (used to determine file type)
378/// * `project_patches` - Patches from `agpm.toml`
379/// * `private_patches` - Patches from `agpm.private.toml`
380///
381/// # Returns
382///
383/// A tuple of:
384/// - Modified content string
385/// - `AppliedPatches` struct with separated project and private patches
386///
387/// # Examples
388///
389/// ```no_run
390/// use agpm_cli::manifest::patches::apply_patches_to_content_with_origin;
391/// use std::collections::BTreeMap;
392///
393/// let content = "---\nmodel: claude-3-opus\n---\n# Agent\n";
394/// let project = BTreeMap::from([
395///     ("model".to_string(), toml::Value::String("haiku".into())),
396/// ]);
397/// let private = BTreeMap::from([
398///     ("temperature".to_string(), toml::Value::String("0.9".into())),
399/// ]);
400///
401/// let (new_content, applied) = apply_patches_to_content_with_origin(
402///     content,
403///     "agent.md",
404///     &project,
405///     &private
406/// )?;
407///
408/// assert_eq!(applied.project.len(), 1);
409/// assert_eq!(applied.private.len(), 1);
410/// # Ok::<(), anyhow::Error>(())
411/// ```
412pub fn apply_patches_to_content_with_origin(
413    content: &str,
414    file_path: &str,
415    project_patches: &PatchData,
416    private_patches: &PatchData,
417) -> anyhow::Result<(String, AppliedPatches)> {
418    // Merge patches first, with private taking precedence over project
419    let mut merged_patches = project_patches.clone();
420    for (key, value) in private_patches {
421        merged_patches.insert(key.clone(), value.clone());
422    }
423
424    // Apply the merged patches in a single pass to avoid duplicate frontmatter
425    let (final_content, all_applied) =
426        apply_patches_to_content(content, file_path, &merged_patches)?;
427
428    // Track which patches were actually applied by origin
429    // Note: When both project and private define the same key, we track BOTH
430    // even though only the private value ends up in the content
431    let mut project_applied = BTreeMap::new();
432    let mut private_applied = BTreeMap::new();
433
434    for key in all_applied.keys() {
435        // Track project patches
436        if let Some(value) = project_patches.get(key) {
437            project_applied.insert(key.clone(), value.clone());
438        }
439        // Track private patches (may override project)
440        if let Some(value) = private_patches.get(key) {
441            private_applied.insert(key.clone(), value.clone());
442        }
443    }
444
445    Ok((
446        final_content,
447        AppliedPatches {
448            project: project_applied,
449            private: private_applied,
450        },
451    ))
452}
453
454/// Apply patches to Markdown file with YAML frontmatter.
455fn apply_patches_to_markdown(
456    content: &str,
457    file_path: &str,
458    patch_data: &PatchData,
459) -> anyhow::Result<(String, BTreeMap<String, toml::Value>)> {
460    use crate::markdown::MarkdownDocument;
461
462    // Parse the markdown file (pass file_path for warning deduplication)
463    let mut md_doc = MarkdownDocument::parse_with_operation_context(
464        content,
465        Some(file_path),
466        None, // No operation context available here, but file path helps deduplication
467    )?;
468
469    let mut applied_patches = BTreeMap::new();
470
471    // Apply each patch to the metadata in deterministic order (sorted by key)
472    // This ensures consistent file content across runs
473    let mut sorted_keys: Vec<_> = patch_data.keys().cloned().collect();
474    sorted_keys.sort();
475
476    for key in sorted_keys {
477        let value = &patch_data[&key];
478        // Convert toml::Value to serde_json::Value for the extra fields
479        let json_value = toml_value_to_json(value)?;
480
481        // Get or create metadata
482        let metadata = md_doc.metadata.get_or_insert_with(Default::default);
483
484        // Update the extra fields
485        metadata.extra.insert(key.clone(), json_value);
486
487        applied_patches.insert(key.clone(), value.clone());
488    }
489
490    // Update the document with the modified metadata
491    if let Some(metadata) = md_doc.metadata.clone() {
492        md_doc.set_metadata(metadata);
493    }
494
495    // Return the raw content with frontmatter
496    Ok((md_doc.raw, applied_patches))
497}
498
499/// Apply patches to JSON file.
500fn apply_patches_to_json(
501    content: &str,
502    patch_data: &PatchData,
503) -> anyhow::Result<(String, BTreeMap<String, toml::Value>)> {
504    // Parse the JSON
505    let mut json_value: serde_json::Value = serde_json::from_str(content)?;
506
507    let mut applied_patches = BTreeMap::new();
508
509    // Apply each patch to the top-level JSON object in deterministic order (sorted by key)
510    // This ensures consistent file content across runs
511    if let serde_json::Value::Object(ref mut map) = json_value {
512        let mut sorted_keys: Vec<_> = patch_data.keys().cloned().collect();
513        sorted_keys.sort();
514
515        for key in sorted_keys {
516            let value = &patch_data[&key];
517            // Convert toml::Value to serde_json::Value
518            let json_val = toml_value_to_json(value)?;
519            map.insert(key.clone(), json_val);
520            applied_patches.insert(key.clone(), value.clone());
521        }
522    } else {
523        anyhow::bail!("JSON file must have a top-level object to apply patches");
524    }
525
526    // Serialize back to pretty JSON
527    let new_content = serde_json::to_string_pretty(&json_value)?;
528
529    Ok((new_content, applied_patches))
530}
531
532/// Convert toml::Value to serde_json::Value.
533pub(crate) fn toml_value_to_json(value: &toml::Value) -> anyhow::Result<serde_json::Value> {
534    toml_value_to_json_with_depth(value, 0)
535}
536
537/// Convert toml::Value to serde_json::Value with recursion depth limit.
538///
539/// # Arguments
540///
541/// * `value` - The TOML value to convert
542/// * `depth` - Current recursion depth
543///
544/// # Returns
545///
546/// The converted JSON value, or an error if depth limit is exceeded
547fn toml_value_to_json_with_depth(
548    value: &toml::Value,
549    depth: usize,
550) -> anyhow::Result<serde_json::Value> {
551    const MAX_DEPTH: usize = 100;
552
553    if depth > MAX_DEPTH {
554        anyhow::bail!(
555            "TOML value nesting exceeds maximum depth of {}. \
556             This may indicate a malformed patch configuration.",
557            MAX_DEPTH
558        );
559    }
560
561    match value {
562        toml::Value::String(s) => Ok(serde_json::Value::String(s.clone())),
563        toml::Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
564        toml::Value::Float(f) => {
565            let num = serde_json::Number::from_f64(*f)
566                .ok_or_else(|| anyhow::anyhow!("Invalid float value: {}", f))?;
567            Ok(serde_json::Value::Number(num))
568        }
569        toml::Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
570        toml::Value::Array(arr) => {
571            let json_arr: Result<Vec<_>, _> =
572                arr.iter().map(|v| toml_value_to_json_with_depth(v, depth + 1)).collect();
573            Ok(serde_json::Value::Array(json_arr?))
574        }
575        toml::Value::Table(table) => {
576            let mut json_map = serde_json::Map::new();
577            for (k, v) in table {
578                json_map.insert(k.clone(), toml_value_to_json_with_depth(v, depth + 1)?);
579            }
580            Ok(serde_json::Value::Object(json_map))
581        }
582        toml::Value::Datetime(dt) => Ok(serde_json::Value::String(dt.to_string())),
583    }
584}
585
586/// Represents a conflict between project and private patches.
587#[derive(Debug, Clone, PartialEq)]
588pub struct PatchConflict {
589    /// Resource type (agents, snippets, etc.).
590    pub resource_type: String,
591    /// Manifest alias.
592    pub alias: String,
593    /// Field name that has conflicting values.
594    pub field: String,
595    /// Value from project-level patch.
596    pub project_value: toml::Value,
597    /// Value from private-level patch.
598    pub private_value: toml::Value,
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn test_empty_patches() {
607        let patches = ManifestPatches::new();
608        assert!(patches.is_empty());
609        assert_eq!(patches.get("agents", "test"), None);
610    }
611
612    #[test]
613    fn test_get_patch() {
614        let mut patches = ManifestPatches::new();
615        let mut patch_data = BTreeMap::new();
616        patch_data.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
617        patches.agents.insert("test-agent".to_string(), patch_data.clone());
618
619        assert!(!patches.is_empty());
620        assert_eq!(patches.get("agents", "test-agent"), Some(&patch_data));
621        assert_eq!(patches.get("agents", "other"), None);
622        assert_eq!(patches.get("snippets", "test-agent"), None);
623    }
624
625    #[test]
626    fn test_merge_no_conflict() {
627        let mut base = ManifestPatches::new();
628        let mut base_patch = BTreeMap::new();
629        base_patch.insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
630        base.agents.insert("test".to_string(), base_patch);
631
632        let mut overlay = ManifestPatches::new();
633        let mut overlay_patch = BTreeMap::new();
634        overlay_patch.insert("temperature".to_string(), toml::Value::String("0.7".to_string()));
635        overlay.agents.insert("test".to_string(), overlay_patch);
636
637        let (merged, conflicts) = base.merge_with(&overlay);
638        assert!(conflicts.is_empty());
639        assert_eq!(merged.agents.get("test").unwrap().len(), 2);
640        assert_eq!(
641            merged.agents.get("test").unwrap().get("model").unwrap(),
642            &toml::Value::String("claude-3-opus".to_string())
643        );
644        assert_eq!(
645            merged.agents.get("test").unwrap().get("temperature").unwrap(),
646            &toml::Value::String("0.7".to_string())
647        );
648    }
649
650    #[test]
651    fn test_merge_with_conflict() {
652        let mut base = ManifestPatches::new();
653        let mut base_patch = BTreeMap::new();
654        base_patch.insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
655        base.agents.insert("test".to_string(), base_patch);
656
657        let mut overlay = ManifestPatches::new();
658        let mut overlay_patch = BTreeMap::new();
659        overlay_patch
660            .insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
661        overlay.agents.insert("test".to_string(), overlay_patch);
662
663        let (merged, conflicts) = base.merge_with(&overlay);
664        assert_eq!(conflicts.len(), 1);
665        assert_eq!(conflicts[0].resource_type, "agents");
666        assert_eq!(conflicts[0].alias, "test");
667        assert_eq!(conflicts[0].field, "model");
668
669        // Private (overlay) should win
670        assert_eq!(
671            merged.agents.get("test").unwrap().get("model").unwrap(),
672            &toml::Value::String("claude-3-haiku".to_string())
673        );
674    }
675
676    #[test]
677    fn test_apply_patches_to_markdown_simple() {
678        let content = r#"---
679model: claude-3-opus
680temperature: "0.5"
681---
682# Test Agent
683
684This is a test agent.
685"#;
686
687        let mut patches = BTreeMap::new();
688        patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
689
690        let (new_content, applied) =
691            apply_patches_to_content(content, "agent.md", &patches).unwrap();
692
693        // Verify the patch was applied
694        assert_eq!(applied.len(), 1);
695        assert_eq!(
696            applied.get("model").unwrap(),
697            &toml::Value::String("claude-3-haiku".to_string())
698        );
699
700        // Verify the content contains the new model
701        assert!(new_content.contains("model: claude-3-haiku"));
702        assert!(new_content.contains("# Test Agent"));
703    }
704
705    #[test]
706    fn test_apply_patches_to_markdown_multiple_fields() {
707        let content = r#"---
708model: claude-3-opus
709temperature: "0.5"
710---
711# Test Agent
712"#;
713
714        let mut patches = BTreeMap::new();
715        patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
716        patches.insert("temperature".to_string(), toml::Value::String("0.7".to_string()));
717        patches.insert("max_tokens".to_string(), toml::Value::Integer(2000));
718
719        let (new_content, applied) =
720            apply_patches_to_content(content, "agent.md", &patches).unwrap();
721
722        // Verify all patches were applied
723        assert_eq!(applied.len(), 3);
724        assert!(new_content.contains("model: claude-3-haiku"));
725        // Temperature can be serialized as "0.7" or 0.7 depending on YAML serializer
726        assert!(new_content.contains("temperature:"));
727        assert!(new_content.contains("0.7"));
728        assert!(new_content.contains("max_tokens: 2000"));
729    }
730
731    #[test]
732    fn test_apply_patches_to_markdown_create_frontmatter() {
733        let content = "# Test Agent\n\nThis is a test agent without frontmatter.";
734
735        let mut patches = BTreeMap::new();
736        patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
737        patches.insert("temperature".to_string(), toml::Value::String("0.7".to_string()));
738
739        let (new_content, applied) =
740            apply_patches_to_content(content, "agent.md", &patches).unwrap();
741
742        // Verify patches were applied
743        assert_eq!(applied.len(), 2);
744
745        // Verify frontmatter was created
746        assert!(new_content.starts_with("---\n"));
747        assert!(new_content.contains("model: claude-3-haiku"));
748        // Temperature can be serialized as "0.7" or 0.7 depending on YAML serializer
749        assert!(new_content.contains("temperature:"));
750        assert!(new_content.contains("0.7"));
751        assert!(new_content.contains("# Test Agent"));
752    }
753
754    #[test]
755    fn test_apply_patches_to_json_simple() {
756        let content = r#"{
757  "name": "test-server",
758  "command": "npx",
759  "args": ["server"]
760}"#;
761
762        let mut patches = BTreeMap::new();
763        patches.insert("timeout".to_string(), toml::Value::Integer(300));
764
765        let (new_content, applied) =
766            apply_patches_to_content(content, "server.json", &patches).unwrap();
767
768        // Verify patch was applied
769        assert_eq!(applied.len(), 1);
770
771        // Parse JSON to verify structure
772        let json: serde_json::Value = serde_json::from_str(&new_content).unwrap();
773        assert_eq!(json["timeout"], 300);
774        assert_eq!(json["name"], "test-server");
775        assert_eq!(json["command"], "npx");
776    }
777
778    #[test]
779    fn test_apply_patches_to_json_nested() {
780        let content = r#"{
781  "name": "test-server",
782  "config": {
783    "host": "localhost"
784  }
785}"#;
786
787        let mut patches = BTreeMap::new();
788
789        // Add nested object
790        let mut nested_table = toml::value::Table::new();
791        nested_table.insert("port".to_string(), toml::Value::Integer(8080));
792        nested_table.insert("ssl".to_string(), toml::Value::Boolean(true));
793        patches.insert("server".to_string(), toml::Value::Table(nested_table));
794
795        // Add array
796        let array = vec![
797            toml::Value::String("option1".to_string()),
798            toml::Value::String("option2".to_string()),
799        ];
800        patches.insert("options".to_string(), toml::Value::Array(array));
801
802        let (new_content, applied) =
803            apply_patches_to_content(content, "server.json", &patches).unwrap();
804
805        // Verify patches were applied
806        assert_eq!(applied.len(), 2);
807
808        // Parse JSON to verify structure
809        let json: serde_json::Value = serde_json::from_str(&new_content).unwrap();
810        assert_eq!(json["name"], "test-server");
811        assert_eq!(json["server"]["port"], 8080);
812        assert_eq!(json["server"]["ssl"], true);
813        assert_eq!(json["options"][0], "option1");
814        assert_eq!(json["options"][1], "option2");
815    }
816
817    #[test]
818    fn test_apply_patches_to_content_empty_patches() {
819        let content = r#"---
820model: claude-3-opus
821---
822# Test Agent
823"#;
824
825        let patches = BTreeMap::new();
826
827        let (new_content, applied) =
828            apply_patches_to_content(content, "agent.md", &patches).unwrap();
829
830        // Verify no patches were applied
831        assert!(applied.is_empty());
832
833        // Content should be unchanged
834        assert_eq!(new_content, content);
835    }
836
837    #[test]
838    fn test_apply_patches_to_content_unsupported_extension() {
839        let content = "This is a text file.";
840
841        let mut patches = BTreeMap::new();
842        patches.insert("field".to_string(), toml::Value::String("value".to_string()));
843
844        let (new_content, applied) =
845            apply_patches_to_content(content, "file.txt", &patches).unwrap();
846
847        // Verify no patches were applied (unsupported file type)
848        assert!(applied.is_empty());
849
850        // Content should be unchanged
851        assert_eq!(new_content, content);
852    }
853
854    #[test]
855    fn test_toml_value_to_json_conversions() {
856        // Test string conversion
857        let toml_str = toml::Value::String("test".to_string());
858        let json_str = toml_value_to_json(&toml_str).unwrap();
859        assert_eq!(json_str, serde_json::Value::String("test".to_string()));
860
861        // Test integer conversion
862        let toml_int = toml::Value::Integer(42);
863        let json_int = toml_value_to_json(&toml_int).unwrap();
864        assert_eq!(json_int, serde_json::json!(42));
865
866        // Test float conversion
867        let toml_float = toml::Value::Float(2.5);
868        let json_float = toml_value_to_json(&toml_float).unwrap();
869        assert_eq!(json_float, serde_json::json!(2.5));
870
871        // Test boolean conversion
872        let toml_bool = toml::Value::Boolean(true);
873        let json_bool = toml_value_to_json(&toml_bool).unwrap();
874        assert_eq!(json_bool, serde_json::Value::Bool(true));
875
876        // Test array conversion
877        let toml_array =
878            toml::Value::Array(vec![toml::Value::String("a".to_string()), toml::Value::Integer(1)]);
879        let json_array = toml_value_to_json(&toml_array).unwrap();
880        assert_eq!(json_array, serde_json::json!(["a", 1]));
881
882        // Test table (object) conversion
883        let mut table = toml::value::Table::new();
884        table.insert("key".to_string(), toml::Value::String("value".to_string()));
885        table.insert("num".to_string(), toml::Value::Integer(123));
886        let toml_table = toml::Value::Table(table);
887        let json_table = toml_value_to_json(&toml_table).unwrap();
888        assert_eq!(json_table, serde_json::json!({"key": "value", "num": 123}));
889
890        // Test datetime conversion
891        let datetime_str = "2025-01-01T12:00:00Z";
892        let toml_datetime = toml::Value::Datetime(datetime_str.parse().unwrap());
893        let json_datetime = toml_value_to_json(&toml_datetime).unwrap();
894        assert_eq!(json_datetime, serde_json::Value::String(datetime_str.to_string()));
895    }
896
897    #[test]
898    fn test_get_for_resource_type() {
899        let mut patches = ManifestPatches::new();
900        let mut agent_patch = BTreeMap::new();
901        agent_patch.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
902        patches.agents.insert("test-agent".to_string(), agent_patch.clone());
903
904        let mut snippet_patch = BTreeMap::new();
905        snippet_patch.insert("lang".to_string(), toml::Value::String("rust".to_string()));
906        patches.snippets.insert("test-snippet".to_string(), snippet_patch.clone());
907
908        // Test getting valid resource types
909        assert_eq!(patches.get_for_resource_type("agents").unwrap().len(), 1);
910        assert_eq!(patches.get_for_resource_type("snippets").unwrap().len(), 1);
911        assert_eq!(patches.get_for_resource_type("commands").unwrap().len(), 0);
912
913        // Test invalid resource type
914        assert!(patches.get_for_resource_type("invalid").is_none());
915    }
916
917    #[test]
918    fn test_patch_origin_serialization() {
919        let project = PatchOrigin::Project;
920        let private = PatchOrigin::Private;
921
922        // Test serialization
923        let project_str = serde_json::to_string(&project).unwrap();
924        let private_str = serde_json::to_string(&private).unwrap();
925
926        assert_eq!(project_str, r#""project""#);
927        assert_eq!(private_str, r#""private""#);
928
929        // Test deserialization
930        let project_de: PatchOrigin = serde_json::from_str(&project_str).unwrap();
931        let private_de: PatchOrigin = serde_json::from_str(&private_str).unwrap();
932
933        assert_eq!(project_de, PatchOrigin::Project);
934        assert_eq!(private_de, PatchOrigin::Private);
935    }
936
937    #[test]
938    fn test_merge_different_resource_types() {
939        let mut base = ManifestPatches::new();
940        let mut base_agent_patch = BTreeMap::new();
941        base_agent_patch
942            .insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
943        base.agents.insert("test".to_string(), base_agent_patch);
944
945        let mut overlay = ManifestPatches::new();
946        let mut overlay_snippet_patch = BTreeMap::new();
947        overlay_snippet_patch.insert("lang".to_string(), toml::Value::String("rust".to_string()));
948        overlay.snippets.insert("test".to_string(), overlay_snippet_patch);
949
950        let (merged, conflicts) = base.merge_with(&overlay);
951
952        // No conflicts since they're different resource types
953        assert!(conflicts.is_empty());
954        assert_eq!(merged.agents.len(), 1);
955        assert_eq!(merged.snippets.len(), 1);
956    }
957
958    #[test]
959    fn test_merge_adds_new_aliases() {
960        let mut base = ManifestPatches::new();
961        let mut base_patch = BTreeMap::new();
962        base_patch.insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
963        base.agents.insert("agent1".to_string(), base_patch);
964
965        let mut overlay = ManifestPatches::new();
966        let mut overlay_patch = BTreeMap::new();
967        overlay_patch
968            .insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
969        overlay.agents.insert("agent2".to_string(), overlay_patch);
970
971        let (merged, conflicts) = base.merge_with(&overlay);
972
973        // No conflicts since they're different aliases
974        assert!(conflicts.is_empty());
975        assert_eq!(merged.agents.len(), 2);
976        assert!(merged.agents.contains_key("agent1"));
977        assert!(merged.agents.contains_key("agent2"));
978    }
979
980    #[test]
981    fn test_apply_patches_preserves_markdown_body() {
982        let content = r#"---
983model: claude-3-opus
984---
985# Test Agent
986
987This is the agent body with **markdown** formatting.
988
989- Item 1
990- Item 2
991
992```rust
993fn main() {
994    println!("Hello");
995}
996```
997"#;
998
999        let mut patches = BTreeMap::new();
1000        patches.insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
1001
1002        let (new_content, _) = apply_patches_to_content(content, "agent.md", &patches).unwrap();
1003
1004        // Verify body is preserved
1005        assert!(new_content.contains("# Test Agent"));
1006        assert!(new_content.contains("This is the agent body"));
1007        assert!(new_content.contains("**markdown**"));
1008        assert!(new_content.contains("- Item 1"));
1009        assert!(new_content.contains("```rust"));
1010        assert!(new_content.contains("fn main()"));
1011    }
1012
1013    #[test]
1014    fn test_json_patch_requires_object() {
1015        let content = r#"["array", "of", "strings"]"#;
1016
1017        let mut patches = BTreeMap::new();
1018        patches.insert("field".to_string(), toml::Value::String("value".to_string()));
1019
1020        let result = apply_patches_to_json(content, &patches);
1021
1022        // Should fail because JSON is not an object
1023        assert!(result.is_err());
1024        assert!(result.unwrap_err().to_string().contains("top-level object"));
1025    }
1026
1027    #[test]
1028    fn test_merge_multiple_conflicts() {
1029        let mut base = ManifestPatches::new();
1030        let mut base_patch = BTreeMap::new();
1031        base_patch.insert("model".to_string(), toml::Value::String("claude-3-opus".to_string()));
1032        base_patch.insert("temperature".to_string(), toml::Value::String("0.5".to_string()));
1033        base.agents.insert("test".to_string(), base_patch);
1034
1035        let mut overlay = ManifestPatches::new();
1036        let mut overlay_patch = BTreeMap::new();
1037        overlay_patch
1038            .insert("model".to_string(), toml::Value::String("claude-3-haiku".to_string()));
1039        overlay_patch.insert("temperature".to_string(), toml::Value::String("0.7".to_string()));
1040        overlay.agents.insert("test".to_string(), overlay_patch);
1041
1042        let (merged, conflicts) = base.merge_with(&overlay);
1043
1044        // Should have 2 conflicts
1045        assert_eq!(conflicts.len(), 2);
1046
1047        // Check both conflicts are present
1048        let model_conflict = conflicts.iter().find(|c| c.field == "model").unwrap();
1049        assert_eq!(model_conflict.project_value, toml::Value::String("claude-3-opus".to_string()));
1050        assert_eq!(model_conflict.private_value, toml::Value::String("claude-3-haiku".to_string()));
1051
1052        let temp_conflict = conflicts.iter().find(|c| c.field == "temperature").unwrap();
1053        assert_eq!(temp_conflict.project_value, toml::Value::String("0.5".to_string()));
1054        assert_eq!(temp_conflict.private_value, toml::Value::String("0.7".to_string()));
1055
1056        // Private values should win
1057        assert_eq!(
1058            merged.agents.get("test").unwrap().get("model").unwrap(),
1059            &toml::Value::String("claude-3-haiku".to_string())
1060        );
1061        assert_eq!(
1062            merged.agents.get("test").unwrap().get("temperature").unwrap(),
1063            &toml::Value::String("0.7".to_string())
1064        );
1065    }
1066
1067    #[test]
1068    fn test_applied_patches_struct() {
1069        let applied = AppliedPatches {
1070            project: BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]),
1071            private: BTreeMap::from([(
1072                "temperature".to_string(),
1073                toml::Value::String("0.9".into()),
1074            )]),
1075        };
1076
1077        assert!(!applied.is_empty());
1078        assert_eq!(applied.total_count(), 2);
1079        assert_eq!(applied.project.len(), 1);
1080        assert_eq!(applied.private.len(), 1);
1081
1082        let empty = AppliedPatches::new();
1083        assert!(empty.is_empty());
1084        assert_eq!(empty.total_count(), 0);
1085    }
1086
1087    #[test]
1088    fn test_apply_patches_with_origin_separates_project_and_private() {
1089        let content = "---\nmodel: gpt-4\n---\n# Test\n";
1090        let project = BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
1091        let private =
1092            BTreeMap::from([("temperature".to_string(), toml::Value::String("0.9".into()))]);
1093
1094        let (result, applied) =
1095            apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1096
1097        assert_eq!(applied.project.len(), 1);
1098        assert_eq!(applied.private.len(), 1);
1099        assert!(result.contains("model: haiku"));
1100        assert!(result.contains("temperature:"));
1101        assert!(result.contains("0.9"));
1102    }
1103
1104    #[test]
1105    fn test_apply_patches_with_origin_empty_patches() {
1106        let content = "---\nmodel: gpt-4\n---\n# Test\n";
1107        let project = BTreeMap::new();
1108        let private = BTreeMap::new();
1109
1110        let (result, applied) =
1111            apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1112
1113        assert!(applied.is_empty());
1114        assert_eq!(result, content);
1115    }
1116
1117    #[test]
1118    fn test_apply_patches_with_origin_only_project() {
1119        let content = "---\nmodel: gpt-4\n---\n# Test\n";
1120        let project = BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
1121        let private = BTreeMap::new();
1122
1123        let (result, applied) =
1124            apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1125
1126        assert_eq!(applied.project.len(), 1);
1127        assert_eq!(applied.private.len(), 0);
1128        assert!(result.contains("model: haiku"));
1129    }
1130
1131    #[test]
1132    fn test_apply_patches_with_origin_only_private() {
1133        let content = "---\nmodel: gpt-4\n---\n# Test\n";
1134        let project = BTreeMap::new();
1135        let private =
1136            BTreeMap::from([("temperature".to_string(), toml::Value::String("0.9".into()))]);
1137
1138        let (result, applied) =
1139            apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1140
1141        assert_eq!(applied.project.len(), 0);
1142        assert_eq!(applied.private.len(), 1);
1143        assert!(result.contains("temperature:"));
1144        assert!(result.contains("0.9"));
1145    }
1146
1147    #[test]
1148    fn test_apply_patches_with_origin_private_overrides_project() {
1149        let content = "---\nmodel: gpt-4\n---\n# Test\n";
1150        let project = BTreeMap::from([("model".to_string(), toml::Value::String("haiku".into()))]);
1151        let private = BTreeMap::from([("model".to_string(), toml::Value::String("sonnet".into()))]);
1152
1153        let (result, applied) =
1154            apply_patches_to_content_with_origin(content, "test.md", &project, &private).unwrap();
1155
1156        // Both patches are tracked separately
1157        assert_eq!(applied.project.len(), 1);
1158        assert_eq!(applied.private.len(), 1);
1159
1160        // But private wins in the final content
1161        assert!(result.contains("model: sonnet"));
1162        assert!(!result.contains("model: haiku"));
1163    }
1164
1165    #[test]
1166    fn test_manifest_patches_deserialization() {
1167        // Test that patches can be deserialized from TOML correctly
1168        let toml_str = r#"
1169[agents.all-helpers]
1170model = "claude-3-haiku"
1171max_tokens = "4096"
1172category = "utility"
1173"#;
1174
1175        let patches: ManifestPatches = toml::from_str(toml_str).unwrap();
1176        println!("Deserialized patches: {:?}", patches);
1177        println!("Agents: {:?}", patches.agents);
1178
1179        // Verify we can get the patches
1180        let agent_patches = patches.get("agents", "all-helpers");
1181        println!("Got patches: {:?}", agent_patches);
1182        assert!(agent_patches.is_some(), "Should find patches for 'all-helpers'");
1183
1184        let patch_data = agent_patches.unwrap();
1185        assert_eq!(patch_data.len(), 3, "Should have 3 patch fields");
1186        assert_eq!(patch_data.get("model").unwrap().as_str().unwrap(), "claude-3-haiku");
1187        assert_eq!(patch_data.get("max_tokens").unwrap().as_str().unwrap(), "4096");
1188        assert_eq!(patch_data.get("category").unwrap().as_str().unwrap(), "utility");
1189    }
1190
1191    #[test]
1192    fn test_full_manifest_with_patches() {
1193        // Test that patches work correctly when embedded in a full Manifest
1194        let toml_str = r#"
1195[sources]
1196test = "https://example.com/repo.git"
1197
1198[agents]
1199all-helpers = { source = "test", path = "agents/helpers/*.md", version = "v1.0.0" }
1200
1201[patch.agents.all-helpers]
1202model = "claude-3-haiku"
1203max_tokens = "4096"
1204"#;
1205
1206        let manifest: crate::manifest::Manifest = toml::from_str(toml_str).unwrap();
1207        println!("Manifest patches: {:?}", manifest.patches);
1208        println!("Agents patches: {:?}", manifest.patches.agents);
1209
1210        // Verify we can get the patches using the get method
1211        let agent_patches = manifest.patches.get("agents", "all-helpers");
1212        println!("Got patches: {:?}", agent_patches);
1213        assert!(agent_patches.is_some(), "Should find patches for 'all-helpers' in full manifest");
1214
1215        let patch_data = agent_patches.unwrap();
1216        assert_eq!(patch_data.len(), 2, "Should have 2 patch fields");
1217        assert_eq!(patch_data.get("model").unwrap().as_str().unwrap(), "claude-3-haiku");
1218    }
1219}