1use 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#[derive(Debug, Clone)]
17pub struct PatchDisplay {
18 pub field_name: String,
20 pub original_value: Option<toml::Value>,
22 pub overridden_value: toml::Value,
24}
25
26impl PatchDisplay {
27 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 format!("{}:\n{}", field_name_colored, add_line)
69 }
70 }
71}
72
73pub async fn extract_patch_displays(resource: &LockedResource, cache: &Cache) -> Vec<PatchDisplay> {
126 if resource.applied_patches.is_empty() {
128 return Vec::new();
129 }
130
131 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 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 displays.sort_by(|a, b| a.field_name.cmp(&b.field_name));
165
166 displays
167}
168
169async fn extract_original_values(
174 resource: &LockedResource,
175 cache: &Cache,
176) -> Result<HashMap<String, toml::Value>> {
177 use std::path::Path;
178
179 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 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 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 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
217fn 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 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 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
257fn 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 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
279fn 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 Ok(toml::Value::String(String::new()))
307 }
308 }
309}
310
311pub 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 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 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 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 assert!(!formatted.contains(" -"));
381 assert!(formatted.contains(" + \"value\""));
383 assert!(formatted.starts_with("new_field:"));
384 }
385
386 #[test]
387 fn test_format_toml_value_types() {
388 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}