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> {
127 if resource.applied_patches.is_empty() {
129 return Vec::new();
130 }
131
132 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 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 displays.sort_by(|a, b| a.field_name.cmp(&b.field_name));
166
167 displays
168}
169
170async fn extract_original_values(
175 resource: &LockedResource,
176 cache: &Cache,
177) -> Result<HashMap<String, toml::Value>> {
178 use std::path::Path;
179
180 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 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 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 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
218fn 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 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 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
258fn 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 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
280fn 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 Ok(toml::Value::String(String::new()))
308 }
309 }
310}
311
312pub 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 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 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 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 assert!(!formatted.contains(" -"));
382 assert!(formatted.contains(" + \"value\""));
384 assert!(formatted.starts_with("new_field:"));
385 }
386
387 #[test]
388 fn test_format_toml_value_types() {
389 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}