agpm_cli/lockfile/
patch_display.rs

1//! Helper functions for displaying patch information with original and overridden values.
2//!
3//! This module provides utilities to extract original field values from resource files
4//! in source repositories and format them alongside patched values for display in
5//! commands like `agpm list --detailed` and `agpm tree --detailed`.
6
7use anyhow::{Context, Result};
8use colored::Colorize;
9use std::collections::HashMap;
10
11use crate::cache::Cache;
12use crate::lockfile::LockedResource;
13use crate::markdown::MarkdownDocument;
14
15/// Represents a patch with both original and overridden values for display.
16#[derive(Debug, Clone)]
17pub struct PatchDisplay {
18    /// Field name being patched.
19    pub field_name: String,
20    /// Original value from the source file (if available).
21    pub original_value: Option<toml::Value>,
22    /// Overridden value from the patch.
23    pub overridden_value: toml::Value,
24}
25
26impl PatchDisplay {
27    /// Format the patch for display as a diff.
28    ///
29    /// Always uses multi-line diff format with color coding:
30    /// - Red `-` line for original value (omitted if no original)
31    /// - Green `+` line for overridden value
32    ///
33    /// ```text
34    /// field:
35    ///   - "original value"  (red)
36    ///   + "overridden value"  (green)
37    /// ```
38    ///
39    /// If there's no original value, only the green `+` line is shown:
40    /// ```text
41    /// field:
42    ///   + "new value"  (green)
43    /// ```
44    ///
45    /// # Examples
46    ///
47    /// ```
48    /// # use agpm_cli::lockfile::patch_display::PatchDisplay;
49    /// let display = PatchDisplay {
50    ///     field_name: "model".to_string(),
51    ///     original_value: Some(toml::Value::String("opus".to_string())),
52    ///     overridden_value: toml::Value::String("haiku".to_string()),
53    /// };
54    /// let formatted = display.format();
55    /// assert!(formatted.contains("model:"));
56    /// ```
57    pub fn format(&self) -> String {
58        let overridden_str = format_toml_value(&self.overridden_value);
59        let add_line = format!("  + {}", overridden_str).green().to_string();
60        let field_name_colored = self.field_name.blue().to_string();
61
62        if let Some(ref original) = self.original_value {
63            let original_str = format_toml_value(original);
64            let remove_line = format!("  - {}", original_str).red().to_string();
65            format!("{}:\n{}\n{}", field_name_colored, remove_line, add_line)
66        } else {
67            // No original value - only show the addition (green)
68            format!("{}:\n{}", field_name_colored, add_line)
69        }
70    }
71}
72
73/// Extract patch display information for a locked resource.
74///
75/// This function:
76/// 1. Reads the original file from the source worktree
77/// 2. Parses it to extract original field values
78/// 3. Combines with the overridden values from the applied patches
79/// 4. Returns formatted patch display information
80///
81/// # Arguments
82///
83/// * `resource` - The locked resource with patch information
84/// * `cache` - Repository cache to access source worktrees
85///
86/// # Returns
87///
88/// A vector of `PatchDisplay` entries with original and overridden values.
89/// If the source file cannot be read or parsed, displays show only overridden values
90/// with "(none)" for the original value.
91///
92/// # Examples
93///
94/// ```rust,no_run
95/// use agpm_cli::lockfile::patch_display::extract_patch_displays;
96/// use agpm_cli::lockfile::LockedResource;
97/// use agpm_cli::cache::Cache;
98///
99/// # async fn example() -> anyhow::Result<()> {
100/// let cache = Cache::new()?;
101/// let resource = LockedResource {
102///     // ... resource fields
103/// #   name: "test".to_string(),
104/// #   source: Some("community".to_string()),
105/// #   url: Some("https://example.com/repo.git".to_string()),
106/// #   path: "agents/test.md".to_string(),
107/// #   version: Some("v1.0.0".to_string()),
108/// #   resolved_commit: Some("abc123".to_string()),
109/// #   checksum: "sha256:def456".to_string(),
110/// #   installed_at: "agents/test.md".to_string(),
111/// #   dependencies: vec![],
112/// #   resource_type: agpm_cli::core::ResourceType::Agent,
113/// #   tool: Some("claude-code".to_string()),
114/// #   manifest_alias: None,
115/// #   applied_patches: std::collections::HashMap::new(),
116/// #   install: None,
117/// };
118///
119/// let displays = extract_patch_displays(&resource, &cache).await;
120/// for display in displays {
121///     println!("{}", display.format());
122/// }
123/// # Ok(())
124/// # }
125/// ```
126pub async fn extract_patch_displays(resource: &LockedResource, cache: &Cache) -> Vec<PatchDisplay> {
127    // If no patches were applied, return empty
128    if resource.applied_patches.is_empty() {
129        return Vec::new();
130    }
131
132    // Try to extract original values
133    let original_values = match extract_original_values(resource, cache).await {
134        Ok(values) => {
135            tracing::debug!(
136                "Successfully extracted {} original values for {}",
137                values.len(),
138                resource.name
139            );
140            values
141        }
142        Err(e) => {
143            tracing::warn!(
144                "Failed to extract original values for {}: {}. Showing patches without original values.",
145                resource.name,
146                e
147            );
148            HashMap::new()
149        }
150    };
151
152    // Build display entries
153    let mut displays = Vec::new();
154    for (field_name, overridden_value) in &resource.applied_patches {
155        let original_value = original_values.get(field_name).cloned();
156
157        displays.push(PatchDisplay {
158            field_name: field_name.clone(),
159            original_value,
160            overridden_value: overridden_value.clone(),
161        });
162    }
163
164    // Sort by field name for consistent display
165    displays.sort_by(|a, b| a.field_name.cmp(&b.field_name));
166
167    displays
168}
169
170/// Extract original field values from the source file.
171///
172/// Reads the file from the source worktree and extracts values for the fields
173/// that have patches applied.
174async fn extract_original_values(
175    resource: &LockedResource,
176    cache: &Cache,
177) -> Result<HashMap<String, toml::Value>> {
178    use std::path::Path;
179
180    // Get source and commit information
181    let source = resource.source.as_ref().context("Resource has no source")?;
182    let commit = resource.resolved_commit.as_ref().context("Resource has no resolved commit")?;
183    let url = resource.url.as_ref().context("Resource has no URL")?;
184
185    tracing::debug!("Attempting to extract original values for resource: {}", resource.name);
186    tracing::debug!("Source: {:?}, Commit: {:?}", source, commit);
187
188    // Get worktree path for this SHA
189    let worktree_path = cache
190        .get_or_create_worktree_for_sha(source, url, commit, Some("patch-display"))
191        .await
192        .with_context(|| format!("Failed to get worktree for {source}"))?;
193
194    tracing::debug!("Got worktree at: {}", worktree_path.display());
195
196    // Read the file from the worktree
197    let file_path = worktree_path.join(&resource.path);
198    let content = tokio::fs::read_to_string(&file_path)
199        .await
200        .with_context(|| format!("Failed to read file: {}", file_path.display()))?;
201
202    tracing::debug!("Read {} bytes from {}", content.len(), file_path.display());
203
204    // Extract values based on file type
205    let extension = Path::new(&resource.path).extension().and_then(|s| s.to_str()).unwrap_or("");
206
207    let values = match extension {
208        "md" => extract_from_markdown(&content)?,
209        "json" => extract_from_json(&content)?,
210        _ => HashMap::new(),
211    };
212
213    tracing::debug!("Extracted {} original values", values.len());
214
215    Ok(values)
216}
217
218/// Extract field values from Markdown file with YAML frontmatter.
219fn extract_from_markdown(content: &str) -> Result<HashMap<String, toml::Value>> {
220    let doc = MarkdownDocument::parse(content)?;
221
222    let mut values = HashMap::new();
223
224    if let Some(metadata) = &doc.metadata {
225        // Extract standard fields
226        if let Some(ref title) = metadata.title {
227            values.insert("title".to_string(), toml::Value::String(title.clone()));
228        }
229        if let Some(ref description) = metadata.description {
230            values.insert("description".to_string(), toml::Value::String(description.clone()));
231        }
232        if let Some(ref version) = metadata.version {
233            values.insert("version".to_string(), toml::Value::String(version.clone()));
234        }
235        if let Some(ref author) = metadata.author {
236            values.insert("author".to_string(), toml::Value::String(author.clone()));
237        }
238        if let Some(ref resource_type) = metadata.resource_type {
239            values.insert("type".to_string(), toml::Value::String(resource_type.clone()));
240        }
241        if !metadata.tags.is_empty() {
242            let tags: Vec<toml::Value> =
243                metadata.tags.iter().map(|s| toml::Value::String(s.clone())).collect();
244            values.insert("tags".to_string(), toml::Value::Array(tags));
245        }
246
247        // Convert extra fields from JSON value to TOML value
248        for (key, json_value) in &metadata.extra {
249            if let Ok(toml_value) = json_to_toml_value(json_value) {
250                values.insert(key.clone(), toml_value);
251            }
252        }
253    }
254
255    Ok(values)
256}
257
258/// Extract field values from JSON file.
259fn extract_from_json(content: &str) -> Result<HashMap<String, toml::Value>> {
260    let json_value: serde_json::Value = serde_json::from_str(content)?;
261
262    let mut values = HashMap::new();
263
264    if let serde_json::Value::Object(map) = json_value {
265        // Skip "dependencies" field as it's not patchable
266        for (key, json_val) in map {
267            if key == "dependencies" {
268                continue;
269            }
270
271            if let Ok(toml_value) = json_to_toml_value(&json_val) {
272                values.insert(key, toml_value);
273            }
274        }
275    }
276
277    Ok(values)
278}
279
280/// Convert serde_json::Value to toml::Value.
281fn json_to_toml_value(json: &serde_json::Value) -> Result<toml::Value> {
282    match json {
283        serde_json::Value::String(s) => Ok(toml::Value::String(s.clone())),
284        serde_json::Value::Number(n) => {
285            if let Some(i) = n.as_i64() {
286                Ok(toml::Value::Integer(i))
287            } else if let Some(f) = n.as_f64() {
288                Ok(toml::Value::Float(f))
289            } else {
290                anyhow::bail!("Unsupported number type")
291            }
292        }
293        serde_json::Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
294        serde_json::Value::Array(arr) => {
295            let toml_arr: Result<Vec<_>> = arr.iter().map(json_to_toml_value).collect();
296            Ok(toml::Value::Array(toml_arr?))
297        }
298        serde_json::Value::Object(map) => {
299            let mut toml_map = toml::value::Table::new();
300            for (k, v) in map {
301                toml_map.insert(k.clone(), json_to_toml_value(v)?);
302            }
303            Ok(toml::Value::Table(toml_map))
304        }
305        serde_json::Value::Null => {
306            // TOML doesn't have a null type, represent as empty string
307            Ok(toml::Value::String(String::new()))
308        }
309    }
310}
311
312/// Format a toml::Value for display.
313///
314/// Produces clean, readable output:
315/// - Strings: wrapped in quotes `"value"`
316/// - Numbers/Booleans: plain text
317/// - Arrays/Tables: formatted as TOML syntax
318pub fn format_toml_value(value: &toml::Value) -> String {
319    match value {
320        toml::Value::String(s) => format!("\"{}\"", s),
321        toml::Value::Integer(i) => i.to_string(),
322        toml::Value::Float(f) => f.to_string(),
323        toml::Value::Boolean(b) => b.to_string(),
324        toml::Value::Array(arr) => {
325            let elements: Vec<String> = arr.iter().map(format_toml_value).collect();
326            format!("[{}]", elements.join(", "))
327        }
328        toml::Value::Table(_) | toml::Value::Datetime(_) => {
329            // For complex types, use to_string() as fallback
330            value.to_string()
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_format_diff() {
341        let display = PatchDisplay {
342            field_name: "model".to_string(),
343            original_value: Some(toml::Value::String("opus".to_string())),
344            overridden_value: toml::Value::String("haiku".to_string()),
345        };
346
347        let formatted = display.format();
348        // Check structure (contains field name and both lines with color codes)
349        assert!(formatted.starts_with("model:"));
350        assert!(formatted.contains("  - \"opus\""));
351        assert!(formatted.contains("  + \"haiku\""));
352    }
353
354    #[test]
355    fn test_format_long_values() {
356        let long_text = "a".repeat(100);
357        let display = PatchDisplay {
358            field_name: "description".to_string(),
359            original_value: Some(toml::Value::String(long_text.clone())),
360            overridden_value: toml::Value::String(long_text.clone()),
361        };
362
363        let formatted = display.format();
364        assert!(formatted.starts_with("description:"));
365        assert!(formatted.contains("  -"));
366        assert!(formatted.contains("  +"));
367        // Verify the long text is preserved
368        assert!(formatted.contains(&format!("\"{}\"", long_text)));
369    }
370
371    #[test]
372    fn test_format_none_original() {
373        let display = PatchDisplay {
374            field_name: "new_field".to_string(),
375            original_value: None,
376            overridden_value: toml::Value::String("value".to_string()),
377        };
378
379        let formatted = display.format();
380        // Should NOT have a - line when there's no original
381        assert!(!formatted.contains("  -"));
382        // Should have + line
383        assert!(formatted.contains("  + \"value\""));
384        assert!(formatted.starts_with("new_field:"));
385    }
386
387    #[test]
388    fn test_format_toml_value_types() {
389        // Test various TOML value types
390        assert_eq!(format_toml_value(&toml::Value::String("test".into())), r#""test""#);
391        assert_eq!(format_toml_value(&toml::Value::Integer(42)), "42");
392        assert_eq!(format_toml_value(&toml::Value::Float(2.5)), "2.5");
393        assert_eq!(format_toml_value(&toml::Value::Boolean(true)), "true");
394
395        // Array
396        let arr = toml::Value::Array(vec![
397            toml::Value::String("a".into()),
398            toml::Value::String("b".into()),
399        ]);
400        assert_eq!(format_toml_value(&arr), r#"["a", "b"]"#);
401    }
402
403    #[test]
404    fn test_format_toml_value_string() {
405        let value = toml::Value::String("claude-3-opus".to_string());
406        assert_eq!(format_toml_value(&value), "\"claude-3-opus\"");
407    }
408
409    #[test]
410    fn test_format_toml_value_integer() {
411        let value = toml::Value::Integer(42);
412        assert_eq!(format_toml_value(&value), "42");
413    }
414
415    #[test]
416    fn test_format_toml_value_float() {
417        let value = toml::Value::Float(0.75);
418        assert_eq!(format_toml_value(&value), "0.75");
419    }
420
421    #[test]
422    fn test_format_toml_value_boolean() {
423        let value = toml::Value::Boolean(true);
424        assert_eq!(format_toml_value(&value), "true");
425
426        let value = toml::Value::Boolean(false);
427        assert_eq!(format_toml_value(&value), "false");
428    }
429
430    #[test]
431    fn test_format_toml_value_array() {
432        let value =
433            toml::Value::Array(vec![toml::Value::String("a".to_string()), toml::Value::Integer(1)]);
434        assert_eq!(format_toml_value(&value), "[\"a\", 1]");
435    }
436
437    #[test]
438    fn test_patch_display_format_with_original() {
439        let display = PatchDisplay {
440            field_name: "model".to_string(),
441            original_value: Some(toml::Value::String("claude-3-opus".to_string())),
442            overridden_value: toml::Value::String("claude-3-haiku".to_string()),
443        };
444        let formatted = display.format();
445        assert!(formatted.starts_with("model:"));
446        assert!(formatted.contains("  - \"claude-3-opus\""));
447        assert!(formatted.contains("  + \"claude-3-haiku\""));
448    }
449
450    #[test]
451    fn test_patch_display_format_without_original() {
452        let display = PatchDisplay {
453            field_name: "temperature".to_string(),
454            original_value: None,
455            overridden_value: toml::Value::String("0.8".to_string()),
456        };
457        let formatted = display.format();
458        assert!(formatted.starts_with("temperature:"));
459        // Should NOT have a - line
460        assert!(!formatted.contains("  -"));
461        assert!(formatted.contains("  + \"0.8\""));
462    }
463
464    #[test]
465    fn test_extract_from_markdown_with_frontmatter() {
466        let content = r#"---
467model: claude-3-opus
468temperature: "0.5"
469max_tokens: 4096
470custom_field: "custom_value"
471---
472
473# Test Agent
474
475Content here."#;
476
477        let values = extract_from_markdown(content).unwrap();
478
479        // Check that we extracted all fields
480        assert!(values.contains_key("model"));
481        assert!(values.contains_key("temperature"));
482        assert!(values.contains_key("max_tokens"));
483        assert!(values.contains_key("custom_field"));
484
485        // Check extracted values
486        if let Some(toml::Value::String(model)) = values.get("model") {
487            assert_eq!(model, "claude-3-opus");
488        } else {
489            panic!("Expected model to be a string");
490        }
491
492        if let Some(toml::Value::String(temp)) = values.get("temperature") {
493            assert_eq!(temp, "0.5");
494        } else {
495            panic!("Expected temperature to be a string");
496        }
497
498        if let Some(toml::Value::Integer(tokens)) = values.get("max_tokens") {
499            assert_eq!(*tokens, 4096);
500        } else {
501            panic!("Expected max_tokens to be an integer");
502        }
503
504        if let Some(toml::Value::String(custom)) = values.get("custom_field") {
505            assert_eq!(custom, "custom_value");
506        } else {
507            panic!("Expected custom_field to be a string");
508        }
509    }
510
511    #[test]
512    fn test_extract_from_markdown_no_frontmatter() {
513        let content = "# Test Agent\n\nNo frontmatter here.";
514        let values = extract_from_markdown(content).unwrap();
515        assert_eq!(values.len(), 0);
516    }
517
518    #[test]
519    fn test_extract_from_json() {
520        let content = r#"{
521  "name": "test-server",
522  "command": "npx",
523  "timeout": 300,
524  "enabled": true
525}"#;
526
527        let values = extract_from_json(content).unwrap();
528        assert_eq!(values.len(), 4);
529
530        // Check extracted values
531        assert!(matches!(
532            values.get("name"),
533            Some(toml::Value::String(s)) if s == "test-server"
534        ));
535        assert!(matches!(
536            values.get("command"),
537            Some(toml::Value::String(s)) if s == "npx"
538        ));
539        assert!(matches!(values.get("timeout"), Some(toml::Value::Integer(300))));
540        assert!(matches!(values.get("enabled"), Some(toml::Value::Boolean(true))));
541    }
542
543    #[test]
544    fn test_json_to_toml_value_conversions() {
545        // String
546        let json = serde_json::Value::String("test".to_string());
547        let toml = json_to_toml_value(&json).unwrap();
548        assert!(matches!(toml, toml::Value::String(s) if s == "test"));
549
550        // Integer
551        let json = serde_json::json!(42);
552        let toml = json_to_toml_value(&json).unwrap();
553        assert!(matches!(toml, toml::Value::Integer(42)));
554
555        // Float
556        let json = serde_json::json!(2.5);
557        let toml = json_to_toml_value(&json).unwrap();
558        assert!(matches!(toml, toml::Value::Float(f) if (f - 2.5).abs() < 0.001));
559
560        // Boolean
561        let json = serde_json::Value::Bool(true);
562        let toml = json_to_toml_value(&json).unwrap();
563        assert!(matches!(toml, toml::Value::Boolean(true)));
564
565        // Array
566        let json = serde_json::json!(["a", "b"]);
567        let toml = json_to_toml_value(&json).unwrap();
568        assert!(matches!(toml, toml::Value::Array(_)));
569
570        // Object
571        let json = serde_json::json!({"key": "value"});
572        let toml = json_to_toml_value(&json).unwrap();
573        assert!(matches!(toml, toml::Value::Table(_)));
574    }
575
576    #[test]
577    fn test_extract_from_markdown_standard_fields() {
578        let content = r#"---
579title: "Test Agent"
580description: "A test agent for testing"
581version: "1.0.0"
582author: "Test Author"
583type: "agent"
584tags:
585  - test
586  - example
587model: "claude-3-opus"
588temperature: "0.7"
589---
590
591# Test Agent
592
593Content here."#;
594
595        let values = extract_from_markdown(content).unwrap();
596
597        // Check standard metadata fields
598        assert!(matches!(
599            values.get("title"),
600            Some(toml::Value::String(s)) if s == "Test Agent"
601        ));
602        assert!(matches!(
603            values.get("description"),
604            Some(toml::Value::String(s)) if s == "A test agent for testing"
605        ));
606        assert!(matches!(
607            values.get("version"),
608            Some(toml::Value::String(s)) if s == "1.0.0"
609        ));
610        assert!(matches!(
611            values.get("author"),
612            Some(toml::Value::String(s)) if s == "Test Author"
613        ));
614        assert!(matches!(
615            values.get("type"),
616            Some(toml::Value::String(s)) if s == "agent"
617        ));
618
619        // Check tags array
620        if let Some(toml::Value::Array(tags)) = values.get("tags") {
621            assert_eq!(tags.len(), 2);
622        } else {
623            panic!("Expected tags to be an array");
624        }
625
626        // Check custom fields
627        assert!(matches!(
628            values.get("model"),
629            Some(toml::Value::String(s)) if s == "claude-3-opus"
630        ));
631        assert!(matches!(
632            values.get("temperature"),
633            Some(toml::Value::String(s)) if s == "0.7"
634        ));
635    }
636}