Skip to main content

ta_changeset/
asset_diff.rs

1// asset_diff.rs — Agent-run contextual diff for image and video artifacts (v0.15.4).
2//
3// Provides `run_asset_diff`, which spawns a DiffSummaryAgent and an optional
4// SupervisorAgent to describe what changed between before/after asset files,
5// and cross-checks whether the change aligns with the stated goal intent.
6//
7// The implementation follows the same spawn_with_timeout / extract_claude_stream_json_text
8// patterns used in `supervisor_review.rs`. No new Cargo dependencies are added.
9
10use std::io::Read as _;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15use crate::artifact_kind::ArtifactKind;
16
17// ---------------------------------------------------------------------------
18// Public types
19// ---------------------------------------------------------------------------
20
21/// How the image/video changed between before and after.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ChangeType {
25    /// Change is spatially confined to a region of the image/frame.
26    Localized,
27    /// Color tone, brightness, or contrast shifted globally.
28    Tonal,
29    /// Major structural rearrangement of scene elements.
30    Structural,
31    /// Small, difficult-to-notice change.
32    Minor,
33    /// Files are visually identical.
34    Identical,
35}
36
37impl std::fmt::Display for ChangeType {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        let s = match self {
40            ChangeType::Localized => "localized",
41            ChangeType::Tonal => "tonal",
42            ChangeType::Structural => "structural",
43            ChangeType::Minor => "minor",
44            ChangeType::Identical => "identical",
45        };
46        write!(f, "{}", s)
47    }
48}
49
50/// Text description of what changed between before and after.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AssetDiffSummary {
53    /// Human-readable description of what visually changed.
54    pub text: String,
55    /// Categorisation of the type of change.
56    pub change_type: ChangeType,
57}
58
59/// Supervisor assessment of whether the diff matches the goal intent.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct AssetSupervisorVerdict {
62    /// Alignment confidence: 0.0 (unrelated) – 1.0 (perfect match).
63    pub confidence: f32,
64    /// One-sentence assessment.
65    pub match_assessment: String,
66    /// Specific concerns or flags. Empty when confident.
67    pub flags: Vec<String>,
68}
69
70/// Configuration for asset diff behaviour (from `[draft.asset_diff]` in workflow.toml).
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct AssetDiffConfig {
73    /// Whether to run agent diff summaries at all.
74    pub enabled: bool,
75    /// Whether to also write a visual diff file alongside the review.
76    pub visual_diff: bool,
77    /// Threshold (0–1) for classifying localized vs. global change when rendering.
78    pub visual_diff_threshold: f32,
79    /// Whether to run the supervisor confidence check.
80    pub supervisor: bool,
81    /// Which agent binary to use. "builtin"/"claude-code" → `claude` CLI, others by name.
82    pub agent: String,
83    /// Timeout for each agent call in seconds.
84    pub timeout_secs: u64,
85}
86
87impl Default for AssetDiffConfig {
88    fn default() -> Self {
89        Self {
90            enabled: true,
91            visual_diff: false,
92            visual_diff_threshold: 0.3,
93            supervisor: true,
94            agent: "builtin".to_string(),
95            timeout_secs: 60,
96        }
97    }
98}
99
100/// Path and type of a visual diff output file.
101#[derive(Debug, Clone)]
102pub struct VisualDiffOutput {
103    /// Path to the written diff file.
104    pub diff_path: PathBuf,
105    /// Category of visual diff that was rendered.
106    pub diff_type: VisualDiffType,
107}
108
109/// Category of visual diff file produced.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum VisualDiffType {
112    /// Side-by-side crop comparison for `ChangeType::Localized`.
113    CropComparison,
114    /// Horizontal color bar for `ChangeType::Tonal`.
115    ColorBar,
116    /// Video keyframe summary for `ArtifactKind::Video`.
117    KeyframeSummary,
118}
119
120/// Combined result of running all configured diff stages for a single artifact.
121#[derive(Debug)]
122pub struct AssetDiffResult {
123    /// Text summary from the DiffSummaryAgent (None when skipped).
124    pub summary: Option<AssetDiffSummary>,
125    /// Supervisor confidence verdict (None when skipped).
126    pub supervisor: Option<AssetSupervisorVerdict>,
127    /// Visual diff output file (None when `visual_diff = false`).
128    pub visual_diff: Option<VisualDiffOutput>,
129    /// If set, the diff was skipped and this describes why.
130    pub skipped_reason: Option<String>,
131}
132
133// ---------------------------------------------------------------------------
134// Raw LLM response types (internal)
135// ---------------------------------------------------------------------------
136
137#[derive(Deserialize, Debug)]
138struct RawDiffResponse {
139    text: Option<String>,
140    change_type: Option<String>,
141}
142
143#[derive(Deserialize, Debug)]
144struct RawSupervisorResponse {
145    confidence: Option<f64>,
146    match_assessment: Option<String>,
147    flags: Option<Vec<String>>,
148}
149
150// ---------------------------------------------------------------------------
151// Public entry point
152// ---------------------------------------------------------------------------
153
154/// Run the full asset diff pipeline for a single before/after artifact pair.
155///
156/// The function is infallible — on any error it returns a result with
157/// `skipped_reason` set and all optional fields as `None`.
158pub fn run_asset_diff(
159    before_path: &Path,
160    after_path: &Path,
161    kind: &ArtifactKind,
162    goal_intent: &str,
163    config: &AssetDiffConfig,
164    staging_dir: &Path,
165) -> AssetDiffResult {
166    if !config.enabled {
167        return AssetDiffResult {
168            summary: None,
169            supervisor: None,
170            visual_diff: None,
171            skipped_reason: Some("asset diff disabled in config".to_string()),
172        };
173    }
174
175    // -- Stage 1: DiffSummaryAgent --
176    let summary_result = run_diff_summary_agent(before_path, after_path, kind, config);
177    let summary = match summary_result {
178        Ok(s) => s,
179        Err(e) => {
180            tracing::warn!(error = %e, "DiffSummaryAgent failed — skipping asset diff");
181            return AssetDiffResult {
182                summary: None,
183                supervisor: None,
184                visual_diff: None,
185                skipped_reason: Some(format!("diff summary agent failed: {}", e)),
186            };
187        }
188    };
189
190    // -- Stage 2: SupervisorAgent (optional) --
191    let supervisor_verdict = if config.supervisor {
192        match run_supervisor_agent(goal_intent, &summary, config) {
193            Ok(v) => Some(v),
194            Err(e) => {
195                tracing::warn!(error = %e, "AssetSupervisorAgent failed — skipping supervisor verdict");
196                None
197            }
198        }
199    } else {
200        None
201    };
202
203    // -- Stage 3: VisualDiffRenderer (optional) --
204    let visual_diff_output = if config.visual_diff {
205        match VisualDiffRenderer::render(
206            before_path,
207            after_path,
208            &summary.change_type,
209            kind,
210            staging_dir,
211        ) {
212            Ok(v) => Some(v),
213            Err(e) => {
214                tracing::warn!(error = %e, "VisualDiffRenderer failed");
215                None
216            }
217        }
218    } else {
219        None
220    };
221
222    AssetDiffResult {
223        summary: Some(summary),
224        supervisor: supervisor_verdict,
225        visual_diff: visual_diff_output,
226        skipped_reason: None,
227    }
228}
229
230// ---------------------------------------------------------------------------
231// DiffSummaryAgent
232// ---------------------------------------------------------------------------
233
234fn build_diff_summary_prompt(before_path: &Path, after_path: &Path, kind: &ArtifactKind) -> String {
235    let kind_label = kind.display_label();
236    format!(
237        r#"You are reviewing a visual asset change. Describe ONLY what you observe visually — do not speculate about intent.
238
239Artifact: {kind_label} at {after_path}
240Before: {before}
241After: {after}
242
243Respond with JSON:
244{{
245  "text": "one or two sentence description of what visually changed",
246  "change_type": "localized|tonal|structural|minor|identical"
247}}
248
249Use:
250- "localized": change is confined to a region of the image/frame
251- "tonal": color, brightness, or contrast shifted globally
252- "structural": major rearrangement of scene elements
253- "minor": small, difficult-to-notice change
254- "identical": files appear visually identical
255
256Respond with ONLY the JSON object."#,
257        kind_label = kind_label,
258        after_path = after_path.display(),
259        before = before_path.display(),
260        after = after_path.display(),
261    )
262}
263
264fn run_diff_summary_agent(
265    before_path: &Path,
266    after_path: &Path,
267    kind: &ArtifactKind,
268    config: &AssetDiffConfig,
269) -> anyhow::Result<AssetDiffSummary> {
270    let prompt = build_diff_summary_prompt(before_path, after_path, kind);
271    let stdout = invoke_agent_cli(&prompt, config)?;
272    let text = extract_claude_stream_json_text(&stdout);
273    Ok(parse_diff_summary_from_json(&text))
274}
275
276/// Parse a DiffSummaryAgent JSON response into an `AssetDiffSummary`.
277///
278/// Falls back gracefully: unknown `change_type` values become `Minor`,
279/// missing `text` becomes a generic placeholder.
280pub fn build_diff_summary_from_json(json_str: &str) -> AssetDiffSummary {
281    parse_diff_summary_from_json(json_str)
282}
283
284fn parse_diff_summary_from_json(text: &str) -> AssetDiffSummary {
285    let json_str = extract_json(text);
286    if let Ok(raw) = serde_json::from_str::<RawDiffResponse>(json_str) {
287        let change_type = match raw.change_type.as_deref() {
288            Some("localized") => ChangeType::Localized,
289            Some("tonal") => ChangeType::Tonal,
290            Some("structural") => ChangeType::Structural,
291            Some("identical") => ChangeType::Identical,
292            _ => ChangeType::Minor,
293        };
294        let summary_text = raw
295            .text
296            .unwrap_or_else(|| "Agent did not describe the change.".to_string());
297        return AssetDiffSummary {
298            text: summary_text,
299            change_type,
300        };
301    }
302    // Non-JSON or parse failure — wrap as minor change with the raw text.
303    let summary_text = if text.trim().is_empty() {
304        "Agent returned empty response.".to_string()
305    } else if text.len() > 300 {
306        format!("{}…", &text[..300])
307    } else {
308        text.trim().to_string()
309    };
310    AssetDiffSummary {
311        text: summary_text,
312        change_type: ChangeType::Minor,
313    }
314}
315
316// ---------------------------------------------------------------------------
317// SupervisorAgent
318// ---------------------------------------------------------------------------
319
320fn build_supervisor_prompt(goal_intent: &str, diff_summary: &AssetDiffSummary) -> String {
321    format!(
322        r#"Goal intent: {goal_intent}
323
324Asset diff summary: {summary}
325Change type: {change_type}
326
327Cross-check whether this diff is consistent with the stated goal intent.
328
329Respond with JSON:
330{{
331  "confidence": 0.0-1.0,
332  "match_assessment": "one sentence assessment",
333  "flags": ["concern 1", ...]
334}}
335
336Use:
337- confidence 1.0: diff is fully consistent with the goal intent
338- confidence 0.7-0.99: mostly consistent with minor uncertainty
339- confidence below 0.7: significant mismatch or concern
340
341Respond with ONLY the JSON object."#,
342        goal_intent = goal_intent,
343        summary = diff_summary.text,
344        change_type = diff_summary.change_type,
345    )
346}
347
348fn run_supervisor_agent(
349    goal_intent: &str,
350    diff_summary: &AssetDiffSummary,
351    config: &AssetDiffConfig,
352) -> anyhow::Result<AssetSupervisorVerdict> {
353    let prompt = build_supervisor_prompt(goal_intent, diff_summary);
354    let stdout = invoke_agent_cli(&prompt, config)?;
355    let text = extract_claude_stream_json_text(&stdout);
356    Ok(parse_supervisor_verdict_from_json(&text))
357}
358
359/// Parse a supervisor JSON response into an `AssetSupervisorVerdict`.
360pub fn parse_supervisor_verdict_from_json(text: &str) -> AssetSupervisorVerdict {
361    let json_str = extract_json(text);
362    if let Ok(raw) = serde_json::from_str::<RawSupervisorResponse>(json_str) {
363        let confidence = raw
364            .confidence
365            .map(|v| v.clamp(0.0, 1.0) as f32)
366            .unwrap_or(0.5);
367        let match_assessment = raw
368            .match_assessment
369            .unwrap_or_else(|| "No assessment provided.".to_string());
370        let flags = raw.flags.unwrap_or_default();
371        return AssetSupervisorVerdict {
372            confidence,
373            match_assessment,
374            flags,
375        };
376    }
377    // Fallback: unknown response.
378    AssetSupervisorVerdict {
379        confidence: 0.5,
380        match_assessment: "Could not parse supervisor response.".to_string(),
381        flags: vec![],
382    }
383}
384
385// ---------------------------------------------------------------------------
386// VisualDiffRenderer
387// ---------------------------------------------------------------------------
388
389pub struct VisualDiffRenderer;
390
391impl VisualDiffRenderer {
392    /// Render a visual diff placeholder file in `staging_dir/diffs/`.
393    ///
394    /// This writes a text placeholder since image processing requires external
395    /// dependencies not present in the workspace. The file path is returned so
396    /// `ta draft view` can display it for the reviewer.
397    pub fn render(
398        before_path: &Path,
399        after_path: &Path,
400        change_type: &ChangeType,
401        kind: &ArtifactKind,
402        staging_dir: &Path,
403    ) -> anyhow::Result<VisualDiffOutput> {
404        let diffs_dir = staging_dir.join("diffs");
405        std::fs::create_dir_all(&diffs_dir)?;
406
407        let stem = after_path
408            .file_stem()
409            .and_then(|s| s.to_str())
410            .unwrap_or("asset");
411
412        let (diff_type, suffix) = if kind.is_video() {
413            (VisualDiffType::KeyframeSummary, "_keyframes.txt")
414        } else {
415            match change_type {
416                ChangeType::Localized => (VisualDiffType::CropComparison, "_crop.txt"),
417                ChangeType::Tonal => (VisualDiffType::ColorBar, "_colordiff.txt"),
418                _ => (VisualDiffType::CropComparison, "_diff.txt"),
419            }
420        };
421
422        let diff_filename = format!("{}{}", stem, suffix);
423        let diff_path = diffs_dir.join(&diff_filename);
424
425        let content = format!(
426            "Visual diff placeholder (v0.15.4)\n\
427             Before: {}\n\
428             After:  {}\n\
429             Type:   {:?}\n\
430             \n\
431             Note: Full image/video diff rendering requires a vision-capable viewer.\n\
432             Review the before/after files directly to verify the change.\n",
433            before_path.display(),
434            after_path.display(),
435            diff_type,
436        );
437
438        std::fs::write(&diff_path, content)?;
439
440        Ok(VisualDiffOutput {
441            diff_path,
442            diff_type,
443        })
444    }
445}
446
447// ---------------------------------------------------------------------------
448// Shared helpers (follow supervisor_review.rs patterns)
449// ---------------------------------------------------------------------------
450
451/// Invoke the configured agent CLI and return its stdout.
452fn invoke_agent_cli(prompt: &str, config: &AssetDiffConfig) -> anyhow::Result<String> {
453    let binary = match config.agent.as_str() {
454        "builtin" | "claude-code" => "claude",
455        other => other,
456    };
457    spawn_with_timeout(
458        binary,
459        &[
460            "--print",
461            "--verbose",
462            "--output-format",
463            "stream-json",
464            prompt,
465        ],
466        config.timeout_secs,
467        &format!("{} CLI", config.agent),
468    )
469}
470
471/// Spawn a process, collect stdout, kill it if it exceeds the timeout.
472/// Mirrors the function in `supervisor_review.rs`.
473fn spawn_with_timeout(
474    program: &str,
475    args: &[&str],
476    timeout_secs: u64,
477    label: &str,
478) -> anyhow::Result<String> {
479    let mut child = std::process::Command::new(program)
480        .args(args)
481        .stdout(std::process::Stdio::piped())
482        .stderr(std::process::Stdio::piped())
483        .spawn()
484        .map_err(|e| {
485            anyhow::anyhow!(
486                "Failed to spawn '{}': {} — is {} installed and on PATH?",
487                program,
488                e,
489                label
490            )
491        })?;
492
493    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
494
495    loop {
496        match child.try_wait() {
497            Ok(Some(status)) => {
498                let mut stdout = String::new();
499                if let Some(mut out) = child.stdout.take() {
500                    let _ = out.read_to_string(&mut stdout);
501                }
502                if !status.success() && stdout.trim().is_empty() {
503                    let mut stderr = String::new();
504                    if let Some(mut err) = child.stderr.take() {
505                        let _ = err.read_to_string(&mut stderr);
506                    }
507                    anyhow::bail!(
508                        "{} exited with status {}: {}",
509                        label,
510                        status,
511                        &stderr[..stderr.len().min(200)]
512                    );
513                }
514                return Ok(stdout);
515            }
516            Ok(None) => {
517                if std::time::Instant::now() >= deadline {
518                    let _ = child.kill();
519                    anyhow::bail!(
520                        "{} timed out after {}s — increase [draft.asset_diff] timeout_secs in workflow.toml",
521                        label,
522                        timeout_secs
523                    );
524                }
525                std::thread::sleep(std::time::Duration::from_millis(200));
526            }
527            Err(e) => {
528                anyhow::bail!("Error waiting for {}: {}", label, e);
529            }
530        }
531    }
532}
533
534/// Extract the final text content from Claude CLI's stream-json output.
535/// Mirrors `extract_claude_stream_json_text` in `supervisor_review.rs`.
536fn extract_claude_stream_json_text(stdout: &str) -> String {
537    for line in stdout.lines().rev() {
538        let line = line.trim();
539        if line.is_empty() {
540            continue;
541        }
542        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
543            continue;
544        };
545        if val.get("type").and_then(|t| t.as_str()) == Some("result") {
546            if let Some(text) = val.get("result").and_then(|r| r.as_str()) {
547                if !text.trim().is_empty() {
548                    return text.to_string();
549                }
550            }
551            if let Some(content) = val.get("content") {
552                let text = extract_content_text(content);
553                if !text.is_empty() {
554                    return text;
555                }
556            }
557        }
558    }
559    for line in stdout.lines().rev() {
560        let line = line.trim();
561        if line.is_empty() {
562            continue;
563        }
564        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
565            continue;
566        };
567        if val.get("type").and_then(|t| t.as_str()) == Some("assistant") {
568            if let Some(content) = val.get("message").and_then(|m| m.get("content")) {
569                let text = extract_content_text(content);
570                if !text.is_empty() {
571                    return text;
572                }
573            }
574        }
575    }
576    stdout.to_string()
577}
578
579fn extract_content_text(content: &serde_json::Value) -> String {
580    if let Some(arr) = content.as_array() {
581        arr.iter()
582            .filter_map(|item| {
583                if item.get("type").and_then(|t| t.as_str()) == Some("text") {
584                    item.get("text")
585                        .and_then(|t| t.as_str())
586                        .map(|s| s.to_string())
587                } else {
588                    None
589                }
590            })
591            .collect::<Vec<_>>()
592            .join("")
593    } else {
594        content.as_str().unwrap_or("").to_string()
595    }
596}
597
598/// Extract a JSON object from text that might be wrapped in markdown fences or prose.
599/// Mirrors `extract_json` in `supervisor_review.rs`.
600fn extract_json(text: &str) -> &str {
601    if let Some(start) = text.find("```json") {
602        let after = &text[start + 7..];
603        if let Some(end) = after.find("```") {
604            return after[..end].trim();
605        }
606    }
607    if let Some(start) = text.find("```") {
608        let after = &text[start + 3..];
609        if let Some(end) = after.find("```") {
610            return after[..end].trim();
611        }
612    }
613    // Find the first { and the last } to extract a raw JSON object.
614    if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
615        if start < end {
616            return &text[start..=end];
617        }
618    }
619    text.trim()
620}
621
622// ---------------------------------------------------------------------------
623// Tests
624// ---------------------------------------------------------------------------
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use tempfile::tempdir;
630
631    // Test 1: DiffSummaryAgent JSON parsing — tonal change.
632    #[test]
633    fn diff_summary_from_json_tonal() {
634        let json = r#"{"text": "Lighting shifted from warm to cool.", "change_type": "tonal"}"#;
635        let summary = build_diff_summary_from_json(json);
636        assert_eq!(summary.change_type, ChangeType::Tonal);
637        assert!(summary.text.contains("warm"));
638    }
639
640    // Test 1b: DiffSummaryAgent JSON parsing — localized change.
641    #[test]
642    fn diff_summary_from_json_localized() {
643        let json = r#"{"text": "Shadow deepened in the left corner.", "change_type": "localized"}"#;
644        let summary = build_diff_summary_from_json(json);
645        assert_eq!(summary.change_type, ChangeType::Localized);
646        assert!(summary.text.contains("corner"));
647    }
648
649    // Test 1c: Unknown change_type falls back to Minor.
650    #[test]
651    fn diff_summary_unknown_change_type_falls_back_to_minor() {
652        let json = r#"{"text": "Something changed.", "change_type": "cosmic"}"#;
653        let summary = build_diff_summary_from_json(json);
654        assert_eq!(summary.change_type, ChangeType::Minor);
655    }
656
657    // Test 1d: Non-JSON response is wrapped gracefully.
658    #[test]
659    fn diff_summary_non_json_fallback() {
660        let text = "The image changed.";
661        let summary = build_diff_summary_from_json(text);
662        assert_eq!(summary.change_type, ChangeType::Minor);
663        assert!(!summary.text.is_empty());
664    }
665
666    // Test 2: Supervisor scores high for matching intent/summary.
667    #[test]
668    fn supervisor_high_confidence_for_match() {
669        let json = r#"{
670            "confidence": 0.95,
671            "match_assessment": "Consistent with goal to adjust day/night lighting.",
672            "flags": []
673        }"#;
674        let verdict = parse_supervisor_verdict_from_json(json);
675        assert!(
676            verdict.confidence > 0.7,
677            "confidence: {}",
678            verdict.confidence
679        );
680        assert!(verdict.flags.is_empty());
681    }
682
683    // Test 3: Supervisor scores low for mismatch.
684    #[test]
685    fn supervisor_low_confidence_for_mismatch() {
686        let json = r#"{
687            "confidence": 0.42,
688            "match_assessment": "The change affects character position, not lighting.",
689            "flags": ["Character moved left — goal only mentioned color adjustment."]
690        }"#;
691        let verdict = parse_supervisor_verdict_from_json(json);
692        assert!(
693            verdict.confidence < 0.7,
694            "confidence: {}",
695            verdict.confidence
696        );
697        assert!(!verdict.flags.is_empty());
698    }
699
700    // Test 3b: Supervisor confidence is clamped to [0, 1].
701    #[test]
702    fn supervisor_confidence_clamped() {
703        let json = r#"{"confidence": 1.5, "match_assessment": "ok", "flags": []}"#;
704        let verdict = parse_supervisor_verdict_from_json(json);
705        assert!(verdict.confidence <= 1.0);
706
707        let json2 = r#"{"confidence": -0.5, "match_assessment": "bad", "flags": []}"#;
708        let verdict2 = parse_supervisor_verdict_from_json(json2);
709        assert!(verdict2.confidence >= 0.0);
710    }
711
712    // Test 4: VisualDiffRenderer produces expected output path.
713    #[test]
714    fn visual_diff_renderer_produces_path() {
715        let dir = tempdir().unwrap();
716        let before = dir.path().join("frame_before.png");
717        let after = dir.path().join("frame_after.png");
718        std::fs::write(&before, b"before").unwrap();
719        std::fs::write(&after, b"after").unwrap();
720
721        let kind = ArtifactKind::Image {
722            width: Some(1024),
723            height: Some(1024),
724            format: Some("PNG".to_string()),
725            frame_index: None,
726        };
727
728        let result =
729            VisualDiffRenderer::render(&before, &after, &ChangeType::Tonal, &kind, dir.path())
730                .unwrap();
731
732        assert!(
733            result.diff_path.to_str().unwrap().contains("_colordiff"),
734            "expected colordiff suffix, got: {}",
735            result.diff_path.display()
736        );
737        assert_eq!(result.diff_type, VisualDiffType::ColorBar);
738        assert!(result.diff_path.exists(), "diff file should be written");
739    }
740
741    // Test 4b: VisualDiffRenderer produces crop comparison for Localized change.
742    #[test]
743    fn visual_diff_renderer_localized_produces_crop() {
744        let dir = tempdir().unwrap();
745        let before = dir.path().join("img.png");
746        let after = dir.path().join("img.png");
747        std::fs::write(&before, b"x").unwrap();
748
749        let kind = ArtifactKind::Image {
750            width: None,
751            height: None,
752            format: None,
753            frame_index: None,
754        };
755
756        let result =
757            VisualDiffRenderer::render(&before, &after, &ChangeType::Localized, &kind, dir.path())
758                .unwrap();
759
760        assert_eq!(result.diff_type, VisualDiffType::CropComparison);
761        assert!(
762            result.diff_path.to_str().unwrap().contains("_crop"),
763            "got: {}",
764            result.diff_path.display()
765        );
766    }
767
768    // Test 4c: VisualDiffRenderer uses KeyframeSummary for video.
769    #[test]
770    fn visual_diff_renderer_video_keyframe() {
771        let dir = tempdir().unwrap();
772        let before = dir.path().join("clip.mp4");
773        let after = dir.path().join("clip.mp4");
774        std::fs::write(&before, b"x").unwrap();
775
776        let kind = ArtifactKind::Video {
777            width: None,
778            height: None,
779            fps: None,
780            duration_secs: None,
781            format: Some("MP4".to_string()),
782            frame_count: None,
783        };
784
785        let result =
786            VisualDiffRenderer::render(&before, &after, &ChangeType::Structural, &kind, dir.path())
787                .unwrap();
788
789        assert_eq!(result.diff_type, VisualDiffType::KeyframeSummary);
790        assert!(
791            result.diff_path.to_str().unwrap().contains("_keyframes"),
792            "got: {}",
793            result.diff_path.display()
794        );
795    }
796
797    // Test 5: Config parsing defaults.
798    #[test]
799    fn asset_diff_config_defaults() {
800        let cfg = AssetDiffConfig::default();
801        assert!(cfg.enabled);
802        assert!(!cfg.visual_diff);
803        assert!((cfg.visual_diff_threshold - 0.3).abs() < f32::EPSILON);
804        assert!(cfg.supervisor);
805        assert_eq!(cfg.agent, "builtin");
806        assert_eq!(cfg.timeout_secs, 60);
807    }
808
809    // Test 5b: Config serializes and deserializes with serde_json.
810    #[test]
811    fn asset_diff_config_serde_roundtrip() {
812        let cfg = AssetDiffConfig {
813            enabled: false,
814            visual_diff: true,
815            visual_diff_threshold: 0.5,
816            supervisor: false,
817            agent: "codex".to_string(),
818            timeout_secs: 120,
819        };
820        let json = serde_json::to_string(&cfg).unwrap();
821        let back: AssetDiffConfig = serde_json::from_str(&json).unwrap();
822        assert!(!back.enabled);
823        assert!(back.visual_diff);
824        assert!(!back.supervisor);
825        assert_eq!(back.agent, "codex");
826        assert_eq!(back.timeout_secs, 120);
827    }
828
829    // Test 6: run_asset_diff returns skipped_reason when disabled.
830    #[test]
831    fn run_asset_diff_skipped_when_disabled() {
832        let dir = tempdir().unwrap();
833        let before = dir.path().join("img.png");
834        let after = dir.path().join("img.png");
835        std::fs::write(&before, b"x").unwrap();
836
837        let kind = ArtifactKind::Image {
838            width: None,
839            height: None,
840            format: None,
841            frame_index: None,
842        };
843        let cfg = AssetDiffConfig {
844            enabled: false,
845            ..AssetDiffConfig::default()
846        };
847
848        let result = run_asset_diff(&before, &after, &kind, "adjust lighting", &cfg, dir.path());
849
850        assert!(result.summary.is_none());
851        assert!(result.supervisor.is_none());
852        assert!(result.visual_diff.is_none());
853        assert_eq!(
854            result.skipped_reason.as_deref(),
855            Some("asset diff disabled in config")
856        );
857    }
858
859    // Test 6b: JSON with markdown fences is extracted correctly.
860    #[test]
861    fn extract_json_from_markdown_fence() {
862        let text = "Sure, here is the result:\n```json\n{\"text\": \"changed\", \"change_type\": \"tonal\"}\n```";
863        let summary = build_diff_summary_from_json(text);
864        assert_eq!(summary.change_type, ChangeType::Tonal);
865        assert_eq!(summary.text, "changed");
866    }
867}