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