Skip to main content

ta_changeset/output_adapters/
terminal.rs

1//! terminal.rs — Terminal output adapter with configurable color support.
2//!
3//! Color is off by default. Enable with `TerminalAdapter::with_color()` or `--color` CLI flag.
4
5use crate::artifact_kind::ArtifactKind;
6use crate::error::ChangeSetError;
7use crate::output_adapters::{
8    default_summary, matches_file_filters, DetailLevel, OutputAdapter, RenderContext,
9};
10use crate::pr_package::{Artifact, ChangeType};
11
12/// Format a byte count as a human-readable size string (e.g. "1.0 MB", "512 B").
13fn format_byte_size(bytes: u64) -> String {
14    const KB: u64 = 1_024;
15    const MB: u64 = KB * 1_024;
16    const GB: u64 = MB * 1_024;
17    if bytes >= GB {
18        format!("{:.1} GB", bytes as f64 / GB as f64)
19    } else if bytes >= MB {
20        format!("{:.1} MB", bytes as f64 / MB as f64)
21    } else if bytes >= KB {
22        format!("{:.1} KB", bytes as f64 / KB as f64)
23    } else {
24        format!("{} B", bytes)
25    }
26}
27
28#[derive(Default)]
29pub struct TerminalAdapter {
30    color: bool,
31}
32
33impl TerminalAdapter {
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    pub fn with_color(color: bool) -> Self {
39        Self { color }
40    }
41
42    /// Strip HTML tags from a string to prevent HTML-rendered content
43    /// from leaking into terminal output.
44    ///
45    /// Only strips sequences that look like real HTML tags (contain attributes,
46    /// slashes, or known tag names). Preserves angle-bracket text that looks
47    /// like code placeholders: `<id>`, `<path>`, `<T>`, etc.
48    fn strip_html(s: &str) -> std::borrow::Cow<'_, str> {
49        if !s.contains('<') {
50            return std::borrow::Cow::Borrowed(s);
51        }
52        // Only strip if it looks like actual HTML (contains known tags or attributes).
53        // Pattern: <tag ...>, </tag>, <tag/>, or tags with class/style attributes.
54        let has_html = s.contains("</")
55            || s.contains("class=")
56            || s.contains("style=")
57            || s.contains("<span")
58            || s.contains("<div")
59            || s.contains("<br")
60            || s.contains("<p>")
61            || s.contains("<p ")
62            || s.contains("<a ")
63            || s.contains("<img");
64        if !has_html {
65            return std::borrow::Cow::Borrowed(s);
66        }
67        let mut out = String::with_capacity(s.len());
68        let mut in_tag = false;
69        for c in s.chars() {
70            match c {
71                '<' => in_tag = true,
72                '>' if in_tag => in_tag = false,
73                _ if !in_tag => out.push(c),
74                _ => {}
75            }
76        }
77        std::borrow::Cow::Owned(out)
78    }
79
80    // -- ANSI helpers (return empty strings when color is off) --
81
82    fn bold(&self) -> &str {
83        if self.color {
84            "\x1b[1m"
85        } else {
86            ""
87        }
88    }
89
90    fn dim(&self) -> &str {
91        if self.color {
92            "\x1b[2m"
93        } else {
94            ""
95        }
96    }
97
98    fn reset(&self) -> &str {
99        if self.color {
100            "\x1b[0m"
101        } else {
102            ""
103        }
104    }
105
106    fn color_code<'a>(&self, code: &'a str) -> &'a str {
107        if self.color {
108            code
109        } else {
110            ""
111        }
112    }
113
114    fn render_header(&self, ctx: &RenderContext) -> String {
115        let pkg = ctx.package;
116        let status_color = if self.color {
117            match pkg.status {
118                crate::pr_package::PRStatus::Draft => "\x1b[33m",
119                crate::pr_package::PRStatus::PendingReview => "\x1b[36m",
120                crate::pr_package::PRStatus::Approved { .. } => "\x1b[32m",
121                crate::pr_package::PRStatus::Denied { .. } => "\x1b[31m",
122                crate::pr_package::PRStatus::Applied { .. } => "\x1b[32m",
123                crate::pr_package::PRStatus::Superseded { .. } => "\x1b[90m",
124                crate::pr_package::PRStatus::Closed { .. } => "\x1b[90m",
125            }
126        } else {
127            ""
128        };
129        let bold = self.bold();
130        let reset = self.reset();
131
132        // Build the draft identity string: prefer <shortref>/<seq> · <tag> (v0.14.7.3).
133        let draft_identity = match (&pkg.goal_shortref, pkg.draft_seq, &pkg.tag) {
134            (Some(shortref), seq, Some(tag)) if seq > 0 => {
135                format!("{}/{} · {}", shortref, seq, tag)
136            }
137            (Some(shortref), seq, None) if seq > 0 => {
138                format!("{}/{}", shortref, seq)
139            }
140            _ => pkg.package_id.to_string(),
141        };
142
143        format!(
144            "{bold}Draft: {}{reset}\n\
145            Status: {}{}{reset}\n\
146            Goal: {}\n\
147            Created: {}\n\n\
148            {bold}Summary:{reset}\n\
149            {}\n\n\
150            {bold}Why:{reset}\n\
151            {}\n\n\
152            {bold}Impact:{reset}\n\
153            {}\n\n",
154            draft_identity,
155            status_color,
156            pkg.status,
157            Self::strip_html(&pkg.goal.title),
158            pkg.created_at.format("%Y-%m-%d %H:%M:%S"),
159            Self::strip_html(&pkg.summary.what_changed),
160            Self::strip_html(&pkg.summary.why),
161            Self::strip_html(&pkg.summary.impact),
162            bold = bold,
163            reset = reset
164        )
165    }
166
167    fn change_icon(&self, change_type: &ChangeType) -> String {
168        if self.color {
169            match change_type {
170                ChangeType::Add => "\x1b[32m+\x1b[0m".to_string(),
171                ChangeType::Modify => "\x1b[33m~\x1b[0m".to_string(),
172                ChangeType::Delete => "\x1b[31m-\x1b[0m".to_string(),
173                ChangeType::Rename => "\x1b[36m>\x1b[0m".to_string(),
174            }
175        } else {
176            match change_type {
177                ChangeType::Add => "+".to_string(),
178                ChangeType::Modify => "~".to_string(),
179                ChangeType::Delete => "-".to_string(),
180                ChangeType::Rename => ">".to_string(),
181            }
182        }
183    }
184
185    fn render_artifact_top(&self, artifact: &Artifact) -> String {
186        let icon = self.change_icon(&artifact.change_type);
187
188        let disposition_badge = match artifact.disposition {
189            crate::pr_package::ArtifactDisposition::Pending => "[pending]",
190            crate::pr_package::ArtifactDisposition::Approved => "[approved]",
191            crate::pr_package::ArtifactDisposition::Rejected => "[rejected]",
192            crate::pr_package::ArtifactDisposition::Discuss => "[discuss]",
193        };
194
195        let summary_raw = artifact
196            .explanation_tiers
197            .as_ref()
198            .map(|t| t.summary.as_str())
199            .or(artifact.rationale.as_deref())
200            .unwrap_or_else(|| default_summary(&artifact.resource_uri, &artifact.change_type));
201        let summary = Self::strip_html(summary_raw);
202
203        // File path on its own line, summary on next line indented to match.
204        format!(
205            "  {} {} {}\n    {}",
206            icon, disposition_badge, artifact.resource_uri, summary
207        )
208    }
209
210    fn render_artifact_medium(&self, artifact: &Artifact) -> String {
211        let mut output = self.render_artifact_top(artifact);
212        let dim = self.dim();
213        let reset = self.reset();
214        output.push('\n');
215
216        if let Some(tiers) = &artifact.explanation_tiers {
217            output.push_str(&format!(
218                "\n    {dim}Explanation:{reset} {}\n",
219                tiers.explanation
220            ));
221
222            if !tiers.tags.is_empty() {
223                output.push_str(&format!(
224                    "    {dim}Tags:{reset} {}\n",
225                    tiers.tags.join(", ")
226                ));
227            }
228
229            if !tiers.related_artifacts.is_empty() {
230                output.push_str(&format!("    {dim}Related:{reset}\n"));
231                for related in &tiers.related_artifacts {
232                    output.push_str(&format!("      - {}\n", related));
233                }
234            }
235        } else if let Some(rationale) = &artifact.rationale {
236            output.push_str(&format!("\n    {dim}Rationale:{reset} {}\n", rationale));
237        }
238
239        if !artifact.dependencies.is_empty() {
240            output.push_str(&format!("    {dim}Dependencies:{reset}\n"));
241            for dep in &artifact.dependencies {
242                output.push_str(&format!("      {:?}: {}\n", dep.kind, dep.target_uri));
243            }
244        }
245
246        output
247    }
248
249    fn render_artifact_full(&self, artifact: &Artifact, ctx: &RenderContext) -> String {
250        let mut output = self.render_artifact_medium(artifact);
251        let bold = self.bold();
252        let reset = self.reset();
253        let dim = self.dim();
254
255        // Image artifacts: suppress binary diff; show human-readable summary instead.
256        if let Some(ArtifactKind::Image {
257            width,
258            height,
259            format,
260            frame_index,
261        }) = &artifact.kind
262        {
263            output.push_str(&format!("\n    {bold}Image artifact:{reset}\n"));
264            let fmt_str = format.as_deref().unwrap_or("unknown format");
265            output.push_str(&format!("    {dim}Format:{reset} {}\n", fmt_str));
266            if let (Some(w), Some(h)) = (width, height) {
267                output.push_str(&format!("    {dim}Resolution:{reset} {}×{}\n", w, h));
268            }
269            if let Some(fi) = frame_index {
270                output.push_str(&format!("    {dim}Frame index:{reset} {}\n", fi));
271            }
272            output.push_str(&format!(
273                "    {dim}[Binary image — text diff suppressed]{reset}\n"
274            ));
275            return output;
276        }
277
278        // Memory summary artifacts: render entry list with [memory] tag prefix.
279        if let Some(ArtifactKind::MemorySummary { entry_count, .. }) = &artifact.kind {
280            output.push_str(&format!(
281                "\n    {bold}[memory] Memory entries stored:{reset} {}\n",
282                entry_count
283            ));
284            // The changeset holds the rendered entry list as text.
285            if let Some(provider) = ctx.diff_provider {
286                match provider.get_diff(&artifact.diff_ref) {
287                    Ok(content) => {
288                        for line in content.lines() {
289                            output.push_str(&format!("    {dim}{}{reset}\n", line));
290                        }
291                    }
292                    Err(e) => {
293                        output.push_str(&format!(
294                            "    {red}[Error loading memory summary: {}]{reset}\n",
295                            e,
296                            red = self.color_code("\x1b[31m"),
297                            reset = reset
298                        ));
299                    }
300                }
301            }
302            output.push_str(&format!(
303                "    {dim}[Approve to keep entries · Deny to remove them from the store]{reset}\n"
304            ));
305            return output;
306        }
307
308        // Video artifacts: suppress binary diff; show metadata summary instead.
309        if let Some(kind @ ArtifactKind::Video { .. }) = &artifact.kind {
310            output.push_str(&format!("\n    {bold}Video artifact:{reset}\n"));
311            let summary = kind.video_metadata_summary();
312            output.push_str(&format!("    {dim}{summary}{reset}\n"));
313            output.push_str(&format!(
314                "    {dim}[Binary video — text diff suppressed]{reset}\n"
315            ));
316            return output;
317        }
318
319        // Binary artifacts: suppress diff; show size summary.
320        if let Some(ArtifactKind::Binary {
321            mime_type,
322            byte_size,
323        }) = &artifact.kind
324        {
325            output.push_str(&format!("\n    {bold}Binary artifact:{reset}\n"));
326            if let Some(mime) = mime_type {
327                output.push_str(&format!("    {dim}MIME type:{reset} {}\n", mime));
328            }
329            let size_str = byte_size
330                .map(format_byte_size)
331                .unwrap_or_else(|| "unknown size".to_string());
332            output.push_str(&format!(
333                "    {dim}[Binary file, {size_str} — text diff suppressed]{reset}\n"
334            ));
335            return output;
336        }
337
338        // Text artifacts: show kind label then render diff normally below.
339        if let Some(ArtifactKind::Text {
340            encoding,
341            line_count,
342        }) = &artifact.kind
343        {
344            output.push_str(&format!("\n    {bold}Text artifact:{reset}\n"));
345            if let Some(enc) = encoding {
346                output.push_str(&format!("    {dim}Encoding:{reset} {}\n", enc));
347            }
348            if let Some(lc) = line_count {
349                output.push_str(&format!("    {dim}Lines:{reset} {}\n", lc));
350            }
351            // Fall through to diff rendering below.
352        }
353
354        // Fetch and display full diff if provider is available
355        if let Some(provider) = ctx.diff_provider {
356            match provider.get_diff(&artifact.diff_ref) {
357                Ok(diff) => {
358                    output.push_str(&format!("\n    {bold}Diff:{reset}\n"));
359                    let green = self.color_code("\x1b[32m");
360                    let red = self.color_code("\x1b[31m");
361                    let cyan = self.color_code("\x1b[36m");
362                    for line in diff.lines() {
363                        if line.starts_with('+') && !line.starts_with("+++") {
364                            output.push_str(&format!("    {green}{}{reset}\n", line));
365                        } else if line.starts_with('-') && !line.starts_with("---") {
366                            output.push_str(&format!("    {red}{}{reset}\n", line));
367                        } else if line.starts_with("@@") {
368                            output.push_str(&format!("    {cyan}{}{reset}\n", line));
369                        } else {
370                            output.push_str(&format!("    {}\n", line));
371                        }
372                    }
373                }
374                Err(e) => {
375                    output.push_str(&format!(
376                        "    {red}[Error loading diff: {}]{reset}\n",
377                        e,
378                        red = self.color_code("\x1b[31m"),
379                        reset = reset
380                    ));
381                }
382            }
383        } else {
384            output.push_str(&format!(
385                "    {dim}[Diff available at: {}]{reset}\n",
386                artifact.diff_ref
387            ));
388        }
389
390        output
391    }
392
393    /// Build a human-readable summary for a set of image artifacts.
394    ///
395    /// Used by `ta draft view` to display a summary line like
396    /// "42 PNG frames, 1024×1024, 380 MB" when a draft contains image artifacts.
397    pub fn render_image_artifact_set_summary(artifacts: &[&Artifact]) -> String {
398        let image_artifacts: Vec<_> = artifacts
399            .iter()
400            .filter(|a| a.kind.as_ref().map(|k| k.is_image()).unwrap_or(false))
401            .collect();
402
403        if image_artifacts.is_empty() {
404            return String::new();
405        }
406
407        // Collect metadata from image kinds.
408        let frame_count = image_artifacts.len();
409        let format: Option<String> = image_artifacts.iter().find_map(|a| {
410            if let Some(ArtifactKind::Image { format, .. }) = &a.kind {
411                format.clone()
412            } else {
413                None
414            }
415        });
416        let resolution: Option<(u32, u32)> = image_artifacts.iter().find_map(|a| {
417            if let Some(ArtifactKind::Image {
418                width: Some(w),
419                height: Some(h),
420                ..
421            }) = &a.kind
422            {
423                Some((*w, *h))
424            } else {
425                None
426            }
427        });
428
429        let fmt_str = format.as_deref().unwrap_or("image");
430        let mut parts = vec![format!(
431            "{} {} frame{}",
432            frame_count,
433            fmt_str,
434            if frame_count == 1 { "" } else { "s" }
435        )];
436        if let Some((w, h)) = resolution {
437            parts.push(format!("{}×{}", w, h));
438        }
439        parts.join(", ")
440    }
441
442    /// Build a human-readable summary for a set of binary artifacts.
443    ///
444    /// Returns a line like `"3 binary files (12.4 MB total)"` or an empty string
445    /// if there are no `ArtifactKind::Binary` artifacts in the set.
446    pub fn render_binary_artifact_set_summary(artifacts: &[&Artifact]) -> String {
447        let binary_artifacts: Vec<_> = artifacts
448            .iter()
449            .filter(|a| a.kind.as_ref().map(|k| k.is_binary()).unwrap_or(false))
450            .collect();
451
452        if binary_artifacts.is_empty() {
453            return String::new();
454        }
455
456        let count = binary_artifacts.len();
457        let total_bytes: Option<u64> = binary_artifacts.iter().try_fold(0u64, |acc, a| {
458            if let Some(ArtifactKind::Binary {
459                byte_size: Some(b), ..
460            }) = &a.kind
461            {
462                Some(acc + b)
463            } else {
464                None // any unknown size → can't compute total
465            }
466        });
467
468        if let Some(total) = total_bytes {
469            format!(
470                "{} binary file{} ({} total)",
471                count,
472                if count == 1 { "" } else { "s" },
473                format_byte_size(total)
474            )
475        } else {
476            format!("{} binary file{}", count, if count == 1 { "" } else { "s" })
477        }
478    }
479
480    /// Build a human-readable summary for a set of text artifacts.
481    ///
482    /// Returns a line like `"2 text files"` or an empty string if there are no
483    /// `ArtifactKind::Text` artifacts in the set.
484    pub fn render_text_artifact_set_summary(artifacts: &[&Artifact]) -> String {
485        let count = artifacts
486            .iter()
487            .filter(|a| a.kind.as_ref().map(|k| k.is_text()).unwrap_or(false))
488            .count();
489
490        if count == 0 {
491            return String::new();
492        }
493
494        format!("{} text file{}", count, if count == 1 { "" } else { "s" })
495    }
496
497    /// Build a human-readable summary for a set of video artifacts.
498    ///
499    /// Returns a line like `"2 MP4 video files, 1920×1080, 24fps"` or an empty string
500    /// if there are no `ArtifactKind::Video` artifacts in the set.
501    pub fn render_video_artifact_set_summary(artifacts: &[&Artifact]) -> String {
502        let video_artifacts: Vec<_> = artifacts
503            .iter()
504            .filter(|a| a.kind.as_ref().map(|k| k.is_video()).unwrap_or(false))
505            .collect();
506
507        if video_artifacts.is_empty() {
508            return String::new();
509        }
510
511        let count = video_artifacts.len();
512        let format: Option<String> = video_artifacts.iter().find_map(|a| {
513            if let Some(ArtifactKind::Video { format, .. }) = &a.kind {
514                format.clone()
515            } else {
516                None
517            }
518        });
519        let resolution: Option<(u32, u32)> = video_artifacts.iter().find_map(|a| {
520            if let Some(ArtifactKind::Video {
521                width: Some(w),
522                height: Some(h),
523                ..
524            }) = &a.kind
525            {
526                Some((*w, *h))
527            } else {
528                None
529            }
530        });
531        let fps: Option<f32> = video_artifacts.iter().find_map(|a| {
532            if let Some(ArtifactKind::Video { fps, .. }) = &a.kind {
533                *fps
534            } else {
535                None
536            }
537        });
538
539        let label = match &format {
540            Some(fmt) => format!("{} video", fmt),
541            None => "video".to_string(),
542        };
543        let mut parts = vec![format!(
544            "{} {} file{}",
545            count,
546            label,
547            if count == 1 { "" } else { "s" }
548        )];
549        if let Some((w, h)) = resolution {
550            parts.push(format!("{}×{}", w, h));
551        }
552        if let Some(f) = fps {
553            parts.push(format!("{}fps", f));
554        }
555        parts.join(", ")
556    }
557
558    /// Group artifacts by module (top-level directory) for the "What Changed" section (v0.9.5).
559    fn render_grouped_changes(&self, artifacts: &[&Artifact]) -> String {
560        use std::collections::BTreeMap;
561        let bold = self.bold();
562        let reset = self.reset();
563        let dim = self.dim();
564
565        let mut output = format!("{bold}What Changed ({} files):{reset}\n", artifacts.len());
566
567        // Group by module (first path segment after fs://workspace/).
568        let mut groups: BTreeMap<String, Vec<&Artifact>> = BTreeMap::new();
569        for artifact in artifacts {
570            let path = artifact
571                .resource_uri
572                .strip_prefix("fs://workspace/")
573                .unwrap_or(&artifact.resource_uri);
574            let module = path.split('/').next().unwrap_or("root").to_string();
575            groups.entry(module).or_default().push(artifact);
576        }
577
578        for (module, arts) in &groups {
579            output.push_str(&format!("\n  {bold}{}/{reset}\n", module));
580            for artifact in arts {
581                let icon = self.change_icon(&artifact.change_type);
582                let path = artifact
583                    .resource_uri
584                    .strip_prefix("fs://workspace/")
585                    .unwrap_or(&artifact.resource_uri);
586                let short_path = path.strip_prefix(&format!("{}/", module)).unwrap_or(path);
587
588                let summary_raw = artifact
589                    .explanation_tiers
590                    .as_ref()
591                    .map(|t| t.summary.as_str())
592                    .or(artifact.rationale.as_deref())
593                    .unwrap_or_else(|| {
594                        default_summary(&artifact.resource_uri, &artifact.change_type)
595                    });
596                let summary = Self::strip_html(summary_raw);
597
598                let dep_marker = if !artifact.dependencies.is_empty() {
599                    let deps: Vec<&str> = artifact
600                        .dependencies
601                        .iter()
602                        .map(|d| {
603                            d.target_uri
604                                .strip_prefix("fs://workspace/")
605                                .unwrap_or(&d.target_uri)
606                        })
607                        .collect();
608                    format!(" {dim}[deps: {}]{reset}", deps.join(", "))
609                } else {
610                    String::new()
611                };
612
613                output.push_str(&format!(
614                    "    {} {} — {}{}\n",
615                    icon, short_path, summary, dep_marker
616                ));
617            }
618        }
619
620        output
621    }
622
623    /// Render the "Design Decisions" section from alternatives_considered (v0.9.5).
624    fn render_design_decisions(&self, ctx: &RenderContext) -> String {
625        let alts = &ctx.package.summary.alternatives_considered;
626        if alts.is_empty() {
627            return String::new();
628        }
629
630        let bold = self.bold();
631        let reset = self.reset();
632        let dim = self.dim();
633        let green = self.color_code("\x1b[32m");
634
635        let mut output = format!("\n{bold}Design Decisions:{reset}\n");
636        for alt in alts {
637            let marker = if alt.chosen {
638                format!("{green}[chosen]{reset}")
639            } else {
640                format!("{dim}[considered]{reset}")
641            };
642            output.push_str(&format!("  {} {}\n", marker, alt.option));
643            output.push_str(&format!("    {}\n", alt.rationale));
644        }
645
646        output
647    }
648
649    /// Render the "Agent Decision Log" section (v0.14.7).
650    ///
651    /// Shows decisions from `.ta-decisions.json` written by the agent,
652    /// with indented alternatives and rationale.
653    fn render_agent_decision_log(&self, ctx: &RenderContext) -> String {
654        let decisions = &ctx.package.agent_decision_log;
655        if decisions.is_empty() {
656            return String::new();
657        }
658
659        let bold = self.bold();
660        let reset = self.reset();
661        let dim = self.dim();
662
663        let mut output = format!(
664            "\n{bold}▸ Agent Decision Log ({} decision(s)):{reset}\n",
665            decisions.len()
666        );
667
668        for entry in decisions {
669            let confidence_str = entry
670                .confidence
671                .map(|c| format!(" {dim}[{:.0}% confidence]{reset}", c * 100.0))
672                .unwrap_or_default();
673
674            // Header line: if context is set, use "[context] → decision"; otherwise just "decision".
675            if let Some(ctx_str) = &entry.context {
676                output.push_str(&format!(
677                    "  ▸ {} → {}{}{}\n",
678                    ctx_str, entry.decision, confidence_str, reset
679                ));
680            } else {
681                output.push_str(&format!(
682                    "  ▸ {}{}{}\n",
683                    entry.decision, confidence_str, reset
684                ));
685            }
686
687            // List alternatives if any.
688            let alts: Vec<&str> = entry
689                .alternatives
690                .iter()
691                .map(String::as_str)
692                .chain(
693                    entry
694                        .alternatives_considered
695                        .iter()
696                        .map(|a| a.description.as_str()),
697                )
698                .collect();
699            if !alts.is_empty() {
700                output.push_str(&format!(
701                    "      {dim}Alternatives:{reset} {}\n",
702                    alts.join(", ")
703                ));
704            }
705
706            output.push_str(&format!(
707                "      {dim}Rationale:{reset} {}\n",
708                entry.rationale
709            ));
710        }
711
712        output
713    }
714}
715
716impl OutputAdapter for TerminalAdapter {
717    fn render(&self, ctx: &RenderContext) -> Result<String, ChangeSetError> {
718        use crate::output_adapters::SectionFilter;
719
720        let mut output = String::new();
721        let bold = self.bold();
722        let reset = self.reset();
723        let dim = self.dim();
724
725        // Filter artifacts
726        let artifacts = &ctx.package.changes.artifacts;
727        let filtered_artifacts: Vec<&Artifact> = artifacts
728            .iter()
729            .filter(|a| matches_file_filters(&a.resource_uri, &ctx.file_filters))
730            .collect();
731
732        if filtered_artifacts.is_empty() && !ctx.file_filters.is_empty() {
733            return Err(ChangeSetError::InvalidData(format!(
734                "No artifacts match filters: {}",
735                ctx.file_filters.join(", ")
736            )));
737        }
738
739        // ── Section filtering: emit only the requested section ──
740        match ctx.section_filter {
741            Some(SectionFilter::Summary) => {
742                output.push_str(&self.render_header(ctx));
743                return Ok(output);
744            }
745            Some(SectionFilter::Decisions) => {
746                output.push_str(&self.render_agent_decision_log(ctx));
747                output.push_str(&self.render_design_decisions(ctx));
748                if output.is_empty() {
749                    output.push_str(&format!(
750                        "{dim}No decisions recorded for this draft.{reset}\n"
751                    ));
752                }
753                return Ok(output);
754            }
755            Some(SectionFilter::Validation) => {
756                // Validation log is rendered in the calling layer (draft.rs) after adapter output.
757                // Return a hint so the user knows to look there.
758                output.push_str(&format!(
759                    "{dim}Validation output is shown after the main view.{reset}\n\
760                     {dim}Run `ta draft view <id>` (without --section) to see it inline.{reset}\n"
761                ));
762                return Ok(output);
763            }
764            Some(SectionFilter::Files) => {
765                output.push_str(&self.render_grouped_changes(&filtered_artifacts));
766                if ctx.detail_level != DetailLevel::Top {
767                    output.push_str(&format!(
768                        "\n{bold}Artifacts ({}):{reset}\n",
769                        filtered_artifacts.len()
770                    ));
771                    for artifact in &filtered_artifacts {
772                        match ctx.detail_level {
773                            DetailLevel::Top => unreachable!(),
774                            DetailLevel::Medium => {
775                                output.push_str(&self.render_artifact_medium(artifact));
776                                output.push('\n');
777                            }
778                            DetailLevel::Full => {
779                                output.push_str(&self.render_artifact_full(artifact, ctx));
780                                output.push('\n');
781                            }
782                        }
783                    }
784                }
785                return Ok(output);
786            }
787            None => {}
788        }
789
790        // ── Full hierarchical view ──
791
792        // ── Summary ──
793        output.push_str(&self.render_header(ctx));
794
795        // ── Agent Decision Log (v0.14.7) ──
796        output.push_str(&self.render_agent_decision_log(ctx));
797
798        // ── Design Decisions (legacy alternatives_considered) ──
799        output.push_str(&self.render_design_decisions(ctx));
800
801        // ── What Changed (module-grouped file list) ──
802        output.push_str(&self.render_grouped_changes(&filtered_artifacts));
803
804        // ── Artifacts (detailed per-artifact view) ──
805        if ctx.detail_level != DetailLevel::Top {
806            output.push_str(&format!(
807                "\n{bold}Artifacts ({}):{reset}\n",
808                filtered_artifacts.len()
809            ));
810
811            for artifact in &filtered_artifacts {
812                match ctx.detail_level {
813                    DetailLevel::Top => unreachable!(),
814                    DetailLevel::Medium => {
815                        output.push_str(&self.render_artifact_medium(artifact));
816                        output.push('\n');
817                    }
818                    DetailLevel::Full => {
819                        output.push_str(&self.render_artifact_full(artifact, ctx));
820                        output.push('\n');
821                    }
822                }
823            }
824        }
825
826        // Footer with review guidance
827        if ctx.detail_level == DetailLevel::Top || ctx.detail_level == DetailLevel::Medium {
828            output.push_str(&format!(
829                "\n{dim}Tip: Use --detail full to see diffs · --section <name> to filter · --section decisions for decision log{reset}\n"
830            ));
831        }
832
833        Ok(output)
834    }
835
836    fn name(&self) -> &str {
837        "terminal"
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844    use crate::pr_package::*;
845    use chrono::Utc;
846    use uuid::Uuid;
847
848    fn test_package() -> PRPackage {
849        PRPackage {
850            package_version: "1.0.0".to_string(),
851            package_id: Uuid::new_v4(),
852            created_at: Utc::now(),
853            goal: Goal {
854                goal_id: "goal-1".to_string(),
855                title: "Test Goal".to_string(),
856                objective: "Test objective".to_string(),
857                success_criteria: vec![],
858                constraints: vec![],
859                parent_goal_title: None,
860            },
861            iteration: Iteration {
862                iteration_id: "iter-1".to_string(),
863                sequence: 1,
864                workspace_ref: WorkspaceRef {
865                    ref_type: "staging".to_string(),
866                    ref_name: "staging/1".to_string(),
867                    base_ref: None,
868                },
869            },
870            agent_identity: AgentIdentity {
871                agent_id: "agent-1".to_string(),
872                agent_type: "coder".to_string(),
873                constitution_id: "default".to_string(),
874                capability_manifest_hash: "hash123".to_string(),
875                orchestrator_run_id: None,
876            },
877            summary: Summary {
878                what_changed: "Updated auth system".to_string(),
879                why: "To improve security".to_string(),
880                impact: "All users must re-login".to_string(),
881                rollback_plan: "Revert commit".to_string(),
882                open_questions: vec![],
883                alternatives_considered: vec![],
884            },
885            plan: Plan {
886                completed_steps: vec![],
887                next_steps: vec![],
888                decision_log: vec![],
889            },
890            changes: Changes {
891                artifacts: vec![Artifact {
892                    resource_uri: "fs://workspace/src/auth.rs".to_string(),
893                    change_type: ChangeType::Modify,
894                    diff_ref: "changeset:0".to_string(),
895                    tests_run: vec![],
896                    disposition: ArtifactDisposition::Pending,
897                    rationale: Some("JWT migration".to_string()),
898                    dependencies: vec![],
899                    explanation_tiers: Some(ExplanationTiers {
900                        summary: "Migrated to JWT auth".to_string(),
901                        explanation: "Full JWT implementation with validation".to_string(),
902                        tags: vec!["security".to_string()],
903                        related_artifacts: vec![],
904                    }),
905                    comments: None,
906                    amendment: None,
907                    kind: None,
908                }],
909                patch_sets: vec![],
910                pending_actions: vec![],
911            },
912            risk: Risk {
913                risk_score: 10,
914                findings: vec![],
915                policy_decisions: vec![],
916            },
917            provenance: Provenance {
918                inputs: vec![],
919                tool_trace_hash: "trace123".to_string(),
920            },
921            review_requests: ReviewRequests {
922                requested_actions: vec![],
923                reviewers: vec![],
924                required_approvals: 1,
925                notes_to_reviewer: None,
926            },
927            signatures: Signatures {
928                package_hash: "hash123".to_string(),
929                agent_signature: "sig123".to_string(),
930                gateway_attestation: None,
931            },
932            status: PRStatus::PendingReview,
933            verification_warnings: vec![],
934            validation_log: vec![],
935            display_id: None,
936            tag: None,
937            vcs_status: None,
938            parent_draft_id: None,
939            pending_approvals: vec![],
940            supervisor_review: None,
941            ignored_artifacts: vec![],
942            baseline_artifacts: vec![],
943            agent_decision_log: vec![],
944            goal_shortref: None,
945            draft_seq: 0,
946            plan_phase: None,
947        }
948    }
949
950    #[test]
951    fn render_top_level() {
952        let adapter = TerminalAdapter::new();
953        let package = test_package();
954        let ctx = RenderContext {
955            package: &package,
956            detail_level: DetailLevel::Top,
957            file_filters: vec![],
958            diff_provider: None,
959            section_filter: None,
960        };
961
962        let output = adapter.render(&ctx).unwrap();
963        assert!(output.contains("Draft"));
964        assert!(output.contains("pending_review"));
965        assert!(output.contains("src/"));
966        assert!(output.contains("auth.rs"));
967        assert!(output.contains("Migrated to JWT auth"));
968        // Default (no color) should not contain ANSI escape codes.
969        assert!(!output.contains("\x1b["));
970    }
971
972    #[test]
973    fn render_with_color() {
974        let adapter = TerminalAdapter::with_color(true);
975        let package = test_package();
976        let ctx = RenderContext {
977            package: &package,
978            detail_level: DetailLevel::Top,
979            file_filters: vec![],
980            diff_provider: None,
981            section_filter: None,
982        };
983
984        let output = adapter.render(&ctx).unwrap();
985        assert!(output.contains("Draft"));
986        // Color mode should contain ANSI escape codes.
987        assert!(output.contains("\x1b["));
988    }
989
990    #[test]
991    fn render_medium_level() {
992        let adapter = TerminalAdapter::new();
993        let package = test_package();
994        let ctx = RenderContext {
995            package: &package,
996            detail_level: DetailLevel::Medium,
997            file_filters: vec![],
998            diff_provider: None,
999            section_filter: None,
1000        };
1001
1002        let output = adapter.render(&ctx).unwrap();
1003        assert!(output.contains("Full JWT implementation"));
1004        assert!(output.contains("security"));
1005    }
1006
1007    #[test]
1008    fn file_filter_works() {
1009        let adapter = TerminalAdapter::new();
1010        let package = test_package();
1011        let ctx = RenderContext {
1012            package: &package,
1013            detail_level: DetailLevel::Top,
1014            file_filters: vec!["auth.rs".to_string()],
1015            diff_provider: None,
1016            section_filter: None,
1017        };
1018
1019        let output = adapter.render(&ctx).unwrap();
1020        assert!(output.contains("auth.rs"));
1021    }
1022
1023    #[test]
1024    fn file_filter_no_match_returns_error() {
1025        let adapter = TerminalAdapter::new();
1026        let package = test_package();
1027        let ctx = RenderContext {
1028            package: &package,
1029            detail_level: DetailLevel::Top,
1030            file_filters: vec!["nonexistent.rs".to_string()],
1031            diff_provider: None,
1032            section_filter: None,
1033        };
1034
1035        let result = adapter.render(&ctx);
1036        assert!(result.is_err());
1037    }
1038
1039    #[test]
1040    fn terminal_output_contains_no_html_tags() {
1041        // Regression test for the garbled HTML bug (ÆpendingÅ in terminal output).
1042        let adapter = TerminalAdapter::new();
1043        let package = test_package();
1044        let ctx = RenderContext {
1045            package: &package,
1046            detail_level: DetailLevel::Medium,
1047            file_filters: vec![],
1048            diff_provider: None,
1049            section_filter: None,
1050        };
1051        let output = adapter.render(&ctx).unwrap();
1052        assert!(
1053            !output.contains("<span"),
1054            "HTML span tags must not appear in terminal output"
1055        );
1056        assert!(
1057            !output.contains("</span>"),
1058            "HTML closing tags must not appear in terminal output"
1059        );
1060        assert!(
1061            output.contains("[pending]"),
1062            "Disposition badge must use bracket notation"
1063        );
1064    }
1065
1066    #[test]
1067    fn strip_html_removes_tags() {
1068        assert_eq!(
1069            TerminalAdapter::strip_html(r#"<span class="status">pending</span>"#).as_ref(),
1070            "pending"
1071        );
1072        assert_eq!(
1073            TerminalAdapter::strip_html("no tags here").as_ref(),
1074            "no tags here"
1075        );
1076        assert_eq!(TerminalAdapter::strip_html("").as_ref(), "");
1077    }
1078
1079    #[test]
1080    fn strip_html_preserves_code_placeholders() {
1081        // Angle brackets in code-style text (e.g. <id>, <path>, <T>) should be preserved.
1082        assert_eq!(
1083            TerminalAdapter::strip_html("ta session show <id>").as_ref(),
1084            "ta session show <id>"
1085        );
1086        assert_eq!(
1087            TerminalAdapter::strip_html("Vec<String>").as_ref(),
1088            "Vec<String>"
1089        );
1090        assert_eq!(
1091            TerminalAdapter::strip_html("list [--all] and show <id>").as_ref(),
1092            "list [--all] and show <id>"
1093        );
1094        // But actual HTML is still stripped.
1095        assert_eq!(
1096            TerminalAdapter::strip_html(r#"text <span class="x">inner</span> more"#).as_ref(),
1097            "text inner more"
1098        );
1099    }
1100
1101    #[test]
1102    fn strip_html_sanitizes_summary_fields() {
1103        // Simulate a package where the summary contains HTML (as if data was corrupted).
1104        let mut package = test_package();
1105        package.summary.what_changed =
1106            r#"Updated <span class="bold">auth</span> system"#.to_string();
1107
1108        let adapter = TerminalAdapter::new();
1109        let ctx = RenderContext {
1110            package: &package,
1111            detail_level: DetailLevel::Top,
1112            file_filters: vec![],
1113            diff_provider: None,
1114            section_filter: None,
1115        };
1116        let output = adapter.render(&ctx).unwrap();
1117        assert!(
1118            output.contains("Updated auth system"),
1119            "HTML should be stripped from summary"
1120        );
1121        assert!(!output.contains("<span"), "No HTML tags in terminal output");
1122    }
1123
1124    // ── v0.9.5 Structured view tests ──
1125
1126    #[test]
1127    fn render_grouped_changes_by_module() {
1128        let adapter = TerminalAdapter::new();
1129        let mut package = test_package();
1130        package.changes.artifacts.push(Artifact {
1131            resource_uri: "fs://workspace/tests/auth_test.rs".to_string(),
1132            change_type: ChangeType::Add,
1133            diff_ref: "changeset:1".to_string(),
1134            tests_run: vec![],
1135            disposition: ArtifactDisposition::Pending,
1136            rationale: Some("Added auth tests".to_string()),
1137            dependencies: vec![],
1138            explanation_tiers: None,
1139            comments: None,
1140            amendment: None,
1141            kind: None,
1142        });
1143        let ctx = RenderContext {
1144            package: &package,
1145            detail_level: DetailLevel::Top,
1146            file_filters: vec![],
1147            diff_provider: None,
1148            section_filter: None,
1149        };
1150        let output = adapter.render(&ctx).unwrap();
1151        assert!(output.contains("What Changed (2 files):"));
1152        assert!(output.contains("src/"));
1153        assert!(output.contains("tests/"));
1154    }
1155
1156    #[test]
1157    fn render_design_decisions() {
1158        let adapter = TerminalAdapter::new();
1159        let mut package = test_package();
1160        package.summary.alternatives_considered = vec![
1161            DesignAlternative {
1162                option: "Use HashMap for lookup".to_string(),
1163                rationale: "Best performance".to_string(),
1164                chosen: true,
1165            },
1166            DesignAlternative {
1167                option: "Use BTreeMap".to_string(),
1168                rationale: "Ordered but slower".to_string(),
1169                chosen: false,
1170            },
1171        ];
1172        let ctx = RenderContext {
1173            package: &package,
1174            detail_level: DetailLevel::Top,
1175            file_filters: vec![],
1176            diff_provider: None,
1177            section_filter: None,
1178        };
1179        let output = adapter.render(&ctx).unwrap();
1180        assert!(output.contains("Design Decisions:"));
1181        assert!(output.contains("[chosen]"));
1182        assert!(output.contains("[considered]"));
1183        assert!(output.contains("Use HashMap for lookup"));
1184        assert!(output.contains("Use BTreeMap"));
1185    }
1186
1187    #[test]
1188    fn render_no_design_decisions_when_empty() {
1189        let adapter = TerminalAdapter::new();
1190        let package = test_package();
1191        let ctx = RenderContext {
1192            package: &package,
1193            detail_level: DetailLevel::Top,
1194            file_filters: vec![],
1195            diff_provider: None,
1196            section_filter: None,
1197        };
1198        let output = adapter.render(&ctx).unwrap();
1199        assert!(!output.contains("Design Decisions:"));
1200    }
1201
1202    #[test]
1203    fn render_medium_shows_artifacts_section() {
1204        let adapter = TerminalAdapter::new();
1205        let package = test_package();
1206        let ctx = RenderContext {
1207            package: &package,
1208            detail_level: DetailLevel::Medium,
1209            file_filters: vec![],
1210            diff_provider: None,
1211            section_filter: None,
1212        };
1213        let output = adapter.render(&ctx).unwrap();
1214        // Medium shows both grouped summary and detailed artifacts
1215        assert!(output.contains("What Changed"));
1216        assert!(output.contains("Artifacts (1):"));
1217    }
1218
1219    // ── v0.14.7 Agent Decision Log tests ──
1220
1221    #[test]
1222    fn render_agent_decision_log() {
1223        use crate::draft_package::DecisionLogEntry;
1224
1225        let adapter = TerminalAdapter::new();
1226        let mut package = test_package();
1227        package.agent_decision_log = vec![DecisionLogEntry {
1228            decision: "Used Ed25519 instead of RSA".to_string(),
1229            rationale: "Ed25519 is faster and smaller keys".to_string(),
1230            alternatives: vec!["RSA-2048".to_string(), "ECDSA P-256".to_string()],
1231            alternatives_considered: vec![],
1232            confidence: Some(0.9),
1233            context: None,
1234        }];
1235        let ctx = RenderContext {
1236            package: &package,
1237            detail_level: DetailLevel::Top,
1238            file_filters: vec![],
1239            diff_provider: None,
1240            section_filter: None,
1241        };
1242        let output = adapter.render(&ctx).unwrap();
1243        assert!(output.contains("Agent Decision Log"));
1244        assert!(output.contains("Used Ed25519 instead of RSA"));
1245        assert!(output.contains("RSA-2048"));
1246        assert!(output.contains("ECDSA P-256"));
1247        assert!(output.contains("Ed25519 is faster"));
1248        // Confidence shown as percentage
1249        assert!(output.contains("90%"));
1250    }
1251
1252    #[test]
1253    fn render_agent_decision_log_empty() {
1254        let adapter = TerminalAdapter::new();
1255        let package = test_package();
1256        let ctx = RenderContext {
1257            package: &package,
1258            detail_level: DetailLevel::Top,
1259            file_filters: vec![],
1260            diff_provider: None,
1261            section_filter: None,
1262        };
1263        let output = adapter.render(&ctx).unwrap();
1264        assert!(!output.contains("Agent Decision Log"));
1265    }
1266
1267    #[test]
1268    fn section_filter_decisions() {
1269        use crate::draft_package::DecisionLogEntry;
1270        use crate::output_adapters::SectionFilter;
1271
1272        let adapter = TerminalAdapter::new();
1273        let mut package = test_package();
1274        package.agent_decision_log = vec![DecisionLogEntry {
1275            decision: "Chose async over sync".to_string(),
1276            rationale: "Better throughput".to_string(),
1277            alternatives: vec!["sync".to_string()],
1278            alternatives_considered: vec![],
1279            confidence: None,
1280            context: None,
1281        }];
1282        let ctx = RenderContext {
1283            package: &package,
1284            detail_level: DetailLevel::Top,
1285            file_filters: vec![],
1286            diff_provider: None,
1287            section_filter: Some(SectionFilter::Decisions),
1288        };
1289        let output = adapter.render(&ctx).unwrap();
1290        assert!(output.contains("Chose async over sync"));
1291        // Summary should not appear when section=decisions
1292        assert!(!output.contains("Status:"));
1293    }
1294
1295    #[test]
1296    fn section_filter_summary() {
1297        use crate::output_adapters::SectionFilter;
1298
1299        let adapter = TerminalAdapter::new();
1300        let package = test_package();
1301        let ctx = RenderContext {
1302            package: &package,
1303            detail_level: DetailLevel::Top,
1304            file_filters: vec![],
1305            diff_provider: None,
1306            section_filter: Some(SectionFilter::Summary),
1307        };
1308        let output = adapter.render(&ctx).unwrap();
1309        assert!(output.contains("Summary:"));
1310        // Files section should not appear
1311        assert!(!output.contains("What Changed"));
1312    }
1313
1314    #[test]
1315    fn section_filter_files() {
1316        use crate::output_adapters::SectionFilter;
1317
1318        let adapter = TerminalAdapter::new();
1319        let package = test_package();
1320        let ctx = RenderContext {
1321            package: &package,
1322            detail_level: DetailLevel::Top,
1323            file_filters: vec![],
1324            diff_provider: None,
1325            section_filter: Some(SectionFilter::Files),
1326        };
1327        let output = adapter.render(&ctx).unwrap();
1328        assert!(output.contains("What Changed"));
1329        // Header not present in files-only view
1330        assert!(!output.contains("Status:"));
1331    }
1332
1333    #[test]
1334    fn render_agent_decision_log_with_context() {
1335        // Verify context is shown as "▸ [context] → [decision]" (v0.14.9.2).
1336        use crate::draft_package::DecisionLogEntry;
1337
1338        let adapter = TerminalAdapter::new();
1339        let mut package = test_package();
1340        package.agent_decision_log = vec![DecisionLogEntry {
1341            decision: "Use Ed25519 keys".to_string(),
1342            rationale: "Smaller and faster than RSA".to_string(),
1343            alternatives: vec![],
1344            alternatives_considered: vec![],
1345            confidence: None,
1346            context: Some("Ollama thinking-mode config".to_string()),
1347        }];
1348        let ctx = RenderContext {
1349            package: &package,
1350            detail_level: DetailLevel::Top,
1351            file_filters: vec![],
1352            diff_provider: None,
1353            section_filter: None,
1354        };
1355        let output = adapter.render(&ctx).unwrap();
1356        assert!(output.contains("Ollama thinking-mode config"));
1357        assert!(output.contains("Use Ed25519 keys"));
1358        // Should show the "→" separator between context and decision
1359        assert!(output.contains("→"));
1360    }
1361
1362    #[test]
1363    fn file_filter_glob_match() {
1364        // Create a package with 2 artifacts, filter with "src/*.rs",
1365        // verify only the matching src-level file appears (v0.14.9.2).
1366        use crate::pr_package::*;
1367
1368        let adapter = TerminalAdapter::new();
1369        let mut package = test_package();
1370        // The default test_package has src/auth.rs — add a file in a different directory.
1371        package.changes.artifacts.push(Artifact {
1372            resource_uri: "fs://workspace/docs/README.md".to_string(),
1373            change_type: ChangeType::Modify,
1374            diff_ref: "changeset:1".to_string(),
1375            tests_run: vec![],
1376            disposition: ArtifactDisposition::Pending,
1377            rationale: Some("Documentation".to_string()),
1378            dependencies: vec![],
1379            explanation_tiers: None,
1380            comments: None,
1381            amendment: None,
1382            kind: None,
1383        });
1384        let ctx = RenderContext {
1385            package: &package,
1386            detail_level: DetailLevel::Top,
1387            file_filters: vec!["src/*.rs".to_string()],
1388            diff_provider: None,
1389            section_filter: None,
1390        };
1391        let output = adapter.render(&ctx).unwrap();
1392        // auth.rs should appear (matches glob src/*.rs)
1393        assert!(output.contains("auth.rs"), "auth.rs should be shown");
1394        // README.md should not appear (doesn't match glob)
1395        assert!(
1396            !output.contains("README.md"),
1397            "README.md should be filtered out"
1398        );
1399    }
1400
1401    #[test]
1402    fn file_filter_unmatched_returns_error() {
1403        // Filter with non-matching pattern should return an error (v0.14.9.2).
1404        let adapter = TerminalAdapter::new();
1405        let package = test_package();
1406        let ctx = RenderContext {
1407            package: &package,
1408            detail_level: DetailLevel::Top,
1409            file_filters: vec!["totally/nonexistent/path.rs".to_string()],
1410            diff_provider: None,
1411            section_filter: None,
1412        };
1413        let result = adapter.render(&ctx);
1414        assert!(result.is_err());
1415        let msg = result.unwrap_err().to_string();
1416        assert!(msg.contains("No artifacts match filters"));
1417    }
1418
1419    // ── v0.14.15 Image artifact rendering tests ──
1420
1421    fn image_artifact(uri: &str, frame_index: u32) -> Artifact {
1422        Artifact {
1423            resource_uri: uri.to_string(),
1424            change_type: ChangeType::Add,
1425            diff_ref: format!("changeset:{}", frame_index),
1426            tests_run: vec![],
1427            disposition: ArtifactDisposition::Pending,
1428            rationale: Some("Rendered frame".to_string()),
1429            dependencies: vec![],
1430            explanation_tiers: None,
1431            comments: None,
1432            amendment: None,
1433            kind: Some(crate::artifact_kind::ArtifactKind::Image {
1434                width: Some(1024),
1435                height: Some(1024),
1436                format: Some("PNG".to_string()),
1437                frame_index: Some(frame_index),
1438            }),
1439        }
1440    }
1441
1442    #[test]
1443    fn image_artifact_full_view_suppresses_diff() {
1444        // An image artifact in full detail should show image metadata,
1445        // not attempt to render a binary text diff.
1446        let adapter = TerminalAdapter::new();
1447        let mut package = test_package();
1448        package.changes.artifacts = vec![image_artifact(
1449            "fs://workspace/render_output/day/beauty/frame_0000.png",
1450            0,
1451        )];
1452
1453        struct AlwaysPanic;
1454        impl crate::output_adapters::DiffProvider for AlwaysPanic {
1455            fn get_diff(&self, _: &str) -> Result<String, ChangeSetError> {
1456                panic!("get_diff must not be called for image artifacts");
1457            }
1458        }
1459
1460        let provider = AlwaysPanic;
1461        let ctx = RenderContext {
1462            package: &package,
1463            detail_level: DetailLevel::Full,
1464            file_filters: vec![],
1465            diff_provider: Some(&provider),
1466            section_filter: None,
1467        };
1468        let output = adapter.render(&ctx).unwrap();
1469        assert!(
1470            output.contains("Image artifact"),
1471            "should show 'Image artifact' header; got: {}",
1472            output
1473        );
1474        assert!(
1475            output.contains("Binary image — text diff suppressed"),
1476            "should indicate binary diff suppression; got: {}",
1477            output
1478        );
1479        assert!(
1480            output.contains("PNG"),
1481            "should show format; got: {}",
1482            output
1483        );
1484        assert!(
1485            output.contains("1024"),
1486            "should show resolution; got: {}",
1487            output
1488        );
1489    }
1490
1491    #[test]
1492    fn image_artifact_set_summary_multiple_frames() {
1493        let artifacts: Vec<Artifact> = (0..42)
1494            .map(|i| image_artifact(&format!("fs://workspace/render/frame_{:04}.png", i), i))
1495            .collect();
1496        let refs: Vec<&Artifact> = artifacts.iter().collect();
1497        let summary = TerminalAdapter::render_image_artifact_set_summary(&refs);
1498        assert!(
1499            summary.contains("42"),
1500            "should contain frame count; got: {}",
1501            summary
1502        );
1503        assert!(
1504            summary.contains("PNG"),
1505            "should contain format; got: {}",
1506            summary
1507        );
1508        assert!(
1509            summary.contains("1024"),
1510            "should contain resolution; got: {}",
1511            summary
1512        );
1513    }
1514
1515    #[test]
1516    fn image_artifact_set_summary_single_frame() {
1517        let artifacts = [image_artifact("fs://workspace/render/frame_0000.png", 0)];
1518        let refs: Vec<&Artifact> = artifacts.iter().collect();
1519        let summary = TerminalAdapter::render_image_artifact_set_summary(&refs);
1520        assert!(
1521            summary.contains("1 PNG frame"),
1522            "singular 'frame' for single image; got: {}",
1523            summary
1524        );
1525    }
1526
1527    #[test]
1528    fn image_artifact_set_summary_empty() {
1529        // A set of non-image artifacts returns empty string.
1530        let mut package = test_package();
1531        package.changes.artifacts[0].kind = None;
1532        let refs: Vec<&Artifact> = package.changes.artifacts.iter().collect();
1533        let summary = TerminalAdapter::render_image_artifact_set_summary(&refs);
1534        assert_eq!(summary, "", "no images → empty summary");
1535    }
1536
1537    // ── v0.15.0 Binary artifact rendering tests ──
1538
1539    fn binary_artifact(uri: &str, mime: Option<&str>, byte_size: Option<u64>) -> Artifact {
1540        Artifact {
1541            resource_uri: uri.to_string(),
1542            change_type: ChangeType::Add,
1543            diff_ref: "changeset:bin0".to_string(),
1544            tests_run: vec![],
1545            disposition: ArtifactDisposition::Pending,
1546            rationale: Some("Binary asset".to_string()),
1547            dependencies: vec![],
1548            explanation_tiers: None,
1549            comments: None,
1550            amendment: None,
1551            kind: Some(crate::artifact_kind::ArtifactKind::Binary {
1552                mime_type: mime.map(|s| s.to_string()),
1553                byte_size,
1554            }),
1555        }
1556    }
1557
1558    fn text_artifact(uri: &str, encoding: Option<&str>, line_count: Option<u64>) -> Artifact {
1559        Artifact {
1560            resource_uri: uri.to_string(),
1561            change_type: ChangeType::Add,
1562            diff_ref: "changeset:txt0".to_string(),
1563            tests_run: vec![],
1564            disposition: ArtifactDisposition::Pending,
1565            rationale: Some("Generated text".to_string()),
1566            dependencies: vec![],
1567            explanation_tiers: None,
1568            comments: None,
1569            amendment: None,
1570            kind: Some(crate::artifact_kind::ArtifactKind::Text {
1571                encoding: encoding.map(|s| s.to_string()),
1572                line_count,
1573            }),
1574        }
1575    }
1576
1577    #[test]
1578    fn binary_artifact_full_view_suppresses_diff() {
1579        // Binary artifact in full detail should show size info, not call diff provider.
1580        let adapter = TerminalAdapter::new();
1581        let mut package = test_package();
1582        package.changes.artifacts = vec![binary_artifact(
1583            "fs://workspace/output/model.bin",
1584            Some("application/octet-stream"),
1585            Some(1_048_576),
1586        )];
1587
1588        struct AlwaysPanic;
1589        impl crate::output_adapters::DiffProvider for AlwaysPanic {
1590            fn get_diff(&self, _: &str) -> Result<String, ChangeSetError> {
1591                panic!("get_diff must not be called for binary artifacts");
1592            }
1593        }
1594
1595        let provider = AlwaysPanic;
1596        let ctx = RenderContext {
1597            package: &package,
1598            detail_level: DetailLevel::Full,
1599            file_filters: vec![],
1600            diff_provider: Some(&provider),
1601            section_filter: None,
1602        };
1603        let output = adapter.render(&ctx).unwrap();
1604        assert!(
1605            output.contains("Binary artifact"),
1606            "should show 'Binary artifact' header; got: {}",
1607            output
1608        );
1609        assert!(
1610            output.contains("Binary file") || output.contains("binary file"),
1611            "should indicate diff suppression; got: {}",
1612            output
1613        );
1614        assert!(
1615            output.contains("1.0 MB"),
1616            "should show size; got: {}",
1617            output
1618        );
1619    }
1620
1621    #[test]
1622    fn binary_artifact_full_view_shows_mime() {
1623        let adapter = TerminalAdapter::new();
1624        let mut package = test_package();
1625        package.changes.artifacts = vec![binary_artifact(
1626            "fs://workspace/output/archive.zip",
1627            Some("application/zip"),
1628            Some(512),
1629        )];
1630
1631        let ctx = RenderContext {
1632            package: &package,
1633            detail_level: DetailLevel::Full,
1634            file_filters: vec![],
1635            diff_provider: None,
1636            section_filter: None,
1637        };
1638        let output = adapter.render(&ctx).unwrap();
1639        assert!(
1640            output.contains("application/zip"),
1641            "should show MIME type; got: {}",
1642            output
1643        );
1644        assert!(
1645            output.contains("512 B"),
1646            "should show size in bytes; got: {}",
1647            output
1648        );
1649    }
1650
1651    #[test]
1652    fn binary_artifact_set_summary_with_sizes() {
1653        let artifacts = [
1654            binary_artifact("fs://workspace/a.bin", None, Some(1_024)),
1655            binary_artifact("fs://workspace/b.bin", None, Some(2_048)),
1656            binary_artifact("fs://workspace/c.bin", None, Some(1_024)),
1657        ];
1658        let refs: Vec<&Artifact> = artifacts.iter().collect();
1659        let summary = TerminalAdapter::render_binary_artifact_set_summary(&refs);
1660        assert!(
1661            summary.contains("3 binary files"),
1662            "should say '3 binary files'; got: {}",
1663            summary
1664        );
1665        assert!(
1666            summary.contains("4.0 KB"),
1667            "should show total size; got: {}",
1668            summary
1669        );
1670    }
1671
1672    #[test]
1673    fn binary_artifact_set_summary_unknown_size() {
1674        // When byte_size is absent for any artifact, total is omitted.
1675        let artifacts = [
1676            binary_artifact("fs://workspace/a.bin", None, Some(1_024)),
1677            binary_artifact("fs://workspace/b.bin", None, None),
1678        ];
1679        let refs: Vec<&Artifact> = artifacts.iter().collect();
1680        let summary = TerminalAdapter::render_binary_artifact_set_summary(&refs);
1681        assert!(
1682            summary.contains("2 binary files"),
1683            "should say '2 binary files'; got: {}",
1684            summary
1685        );
1686        // No total should appear because b.bin has unknown size.
1687        assert!(
1688            !summary.contains("total"),
1689            "should not show total when size unknown; got: {}",
1690            summary
1691        );
1692    }
1693
1694    #[test]
1695    fn binary_artifact_set_summary_single() {
1696        let artifacts = [binary_artifact("fs://workspace/x.bin", None, Some(256))];
1697        let refs: Vec<&Artifact> = artifacts.iter().collect();
1698        let summary = TerminalAdapter::render_binary_artifact_set_summary(&refs);
1699        assert!(
1700            summary.contains("1 binary file"),
1701            "singular form; got: {}",
1702            summary
1703        );
1704        assert!(
1705            !summary.contains("1 binary files"),
1706            "no plural 's'; got: {}",
1707            summary
1708        );
1709    }
1710
1711    #[test]
1712    fn binary_artifact_set_summary_empty() {
1713        let refs: Vec<&Artifact> = vec![];
1714        let summary = TerminalAdapter::render_binary_artifact_set_summary(&refs);
1715        assert_eq!(summary, "", "no binaries → empty summary");
1716    }
1717
1718    // ── v0.15.0 Text artifact rendering tests ──
1719
1720    #[test]
1721    fn text_artifact_full_view_renders_diff() {
1722        // Text artifact should fall through to diff rendering.
1723        let adapter = TerminalAdapter::new();
1724        let mut package = test_package();
1725        package.changes.artifacts = vec![text_artifact(
1726            "fs://workspace/scripts/setup.sh",
1727            Some("utf-8"),
1728            Some(42),
1729        )];
1730
1731        struct FixedDiff;
1732        impl crate::output_adapters::DiffProvider for FixedDiff {
1733            fn get_diff(&self, _: &str) -> Result<String, ChangeSetError> {
1734                Ok("+#!/bin/bash\n+echo hello\n".to_string())
1735            }
1736        }
1737
1738        let provider = FixedDiff;
1739        let ctx = RenderContext {
1740            package: &package,
1741            detail_level: DetailLevel::Full,
1742            file_filters: vec![],
1743            diff_provider: Some(&provider),
1744            section_filter: None,
1745        };
1746        let output = adapter.render(&ctx).unwrap();
1747        assert!(
1748            output.contains("Text artifact"),
1749            "should show 'Text artifact' header; got: {}",
1750            output
1751        );
1752        assert!(
1753            output.contains("utf-8"),
1754            "should show encoding; got: {}",
1755            output
1756        );
1757        assert!(
1758            output.contains("42"),
1759            "should show line count; got: {}",
1760            output
1761        );
1762        // diff should be rendered (not suppressed)
1763        assert!(
1764            output.contains("echo hello"),
1765            "should render diff content; got: {}",
1766            output
1767        );
1768    }
1769
1770    #[test]
1771    fn text_artifact_set_summary_multiple() {
1772        let artifacts = [
1773            text_artifact("fs://workspace/a.sh", None, None),
1774            text_artifact("fs://workspace/b.sh", None, None),
1775        ];
1776        let refs: Vec<&Artifact> = artifacts.iter().collect();
1777        let summary = TerminalAdapter::render_text_artifact_set_summary(&refs);
1778        assert_eq!(summary, "2 text files");
1779    }
1780
1781    #[test]
1782    fn text_artifact_set_summary_single() {
1783        let artifacts = [text_artifact("fs://workspace/a.conf", None, None)];
1784        let refs: Vec<&Artifact> = artifacts.iter().collect();
1785        let summary = TerminalAdapter::render_text_artifact_set_summary(&refs);
1786        assert_eq!(summary, "1 text file");
1787    }
1788
1789    #[test]
1790    fn text_artifact_set_summary_empty() {
1791        let refs: Vec<&Artifact> = vec![];
1792        let summary = TerminalAdapter::render_text_artifact_set_summary(&refs);
1793        assert_eq!(summary, "");
1794    }
1795
1796    // ── format_byte_size helper tests ──
1797
1798    #[test]
1799    fn format_byte_size_bytes() {
1800        assert_eq!(super::format_byte_size(0), "0 B");
1801        assert_eq!(super::format_byte_size(512), "512 B");
1802        assert_eq!(super::format_byte_size(1023), "1023 B");
1803    }
1804
1805    #[test]
1806    fn format_byte_size_kb() {
1807        assert_eq!(super::format_byte_size(1024), "1.0 KB");
1808        assert_eq!(super::format_byte_size(1536), "1.5 KB");
1809    }
1810
1811    #[test]
1812    fn format_byte_size_mb() {
1813        assert_eq!(super::format_byte_size(1_048_576), "1.0 MB");
1814        assert_eq!(super::format_byte_size(5 * 1_048_576), "5.0 MB");
1815    }
1816
1817    #[test]
1818    fn format_byte_size_gb() {
1819        assert_eq!(super::format_byte_size(1_073_741_824), "1.0 GB");
1820    }
1821
1822    // ── v0.15.1 Video artifact rendering tests ──
1823
1824    fn video_artifact(
1825        uri: &str,
1826        width: Option<u32>,
1827        height: Option<u32>,
1828        fps: Option<f32>,
1829        duration_secs: Option<f32>,
1830        format: Option<&str>,
1831    ) -> Artifact {
1832        Artifact {
1833            resource_uri: uri.to_string(),
1834            change_type: ChangeType::Add,
1835            diff_ref: "changeset:vid0".to_string(),
1836            tests_run: vec![],
1837            disposition: ArtifactDisposition::Pending,
1838            rationale: Some("Rendered video".to_string()),
1839            dependencies: vec![],
1840            explanation_tiers: None,
1841            comments: None,
1842            amendment: None,
1843            kind: Some(crate::artifact_kind::ArtifactKind::Video {
1844                width,
1845                height,
1846                fps,
1847                duration_secs,
1848                format: format.map(|s| s.to_string()),
1849                frame_count: None,
1850            }),
1851        }
1852    }
1853
1854    #[test]
1855    fn video_artifact_full_view_suppresses_diff() {
1856        // Video artifact in full detail should show metadata, not attempt to render a text diff.
1857        let adapter = TerminalAdapter::new();
1858        let mut package = test_package();
1859        package.changes.artifacts = vec![video_artifact(
1860            "fs://workspace/output/clip.mp4",
1861            Some(1920),
1862            Some(1080),
1863            Some(24.0),
1864            Some(6.2),
1865            Some("MP4"),
1866        )];
1867
1868        struct AlwaysPanic;
1869        impl crate::output_adapters::DiffProvider for AlwaysPanic {
1870            fn get_diff(&self, _: &str) -> Result<String, ChangeSetError> {
1871                panic!("get_diff must not be called for video artifacts");
1872            }
1873        }
1874
1875        let provider = AlwaysPanic;
1876        let ctx = RenderContext {
1877            package: &package,
1878            detail_level: DetailLevel::Full,
1879            file_filters: vec![],
1880            diff_provider: Some(&provider),
1881            section_filter: None,
1882        };
1883        let output = adapter.render(&ctx).unwrap();
1884        assert!(
1885            output.contains("Video artifact"),
1886            "should show 'Video artifact' header; got: {}",
1887            output
1888        );
1889        assert!(
1890            output.contains("Binary video — text diff suppressed"),
1891            "should indicate diff suppression; got: {}",
1892            output
1893        );
1894        assert!(
1895            output.contains("1920×1080"),
1896            "should show resolution; got: {}",
1897            output
1898        );
1899        assert!(
1900            output.contains("MP4"),
1901            "should show format; got: {}",
1902            output
1903        );
1904        assert!(
1905            output.contains("6.2s"),
1906            "should show duration; got: {}",
1907            output
1908        );
1909    }
1910
1911    #[test]
1912    fn video_artifact_set_summary_multiple() {
1913        let artifacts = [
1914            video_artifact(
1915                "fs://workspace/output/clip_a.mp4",
1916                Some(1920),
1917                Some(1080),
1918                Some(24.0),
1919                None,
1920                Some("MP4"),
1921            ),
1922            video_artifact(
1923                "fs://workspace/output/clip_b.mp4",
1924                Some(1920),
1925                Some(1080),
1926                Some(24.0),
1927                None,
1928                Some("MP4"),
1929            ),
1930        ];
1931        let refs: Vec<&Artifact> = artifacts.iter().collect();
1932        let summary = TerminalAdapter::render_video_artifact_set_summary(&refs);
1933        assert!(
1934            summary.contains("2 MP4 video files"),
1935            "should say '2 MP4 video files'; got: {}",
1936            summary
1937        );
1938        assert!(
1939            summary.contains("1920×1080"),
1940            "should contain resolution; got: {}",
1941            summary
1942        );
1943        assert!(
1944            summary.contains("24fps") || summary.contains("24"),
1945            "should contain fps; got: {}",
1946            summary
1947        );
1948    }
1949
1950    #[test]
1951    fn video_artifact_set_summary_single() {
1952        let artifacts = [video_artifact(
1953            "fs://workspace/output/clip.mov",
1954            None,
1955            None,
1956            None,
1957            Some(10.0),
1958            Some("MOV"),
1959        )];
1960        let refs: Vec<&Artifact> = artifacts.iter().collect();
1961        let summary = TerminalAdapter::render_video_artifact_set_summary(&refs);
1962        assert!(
1963            summary.contains("1 MOV video file"),
1964            "singular form; got: {}",
1965            summary
1966        );
1967        assert!(
1968            !summary.contains("1 MOV video files"),
1969            "no plural 's'; got: {}",
1970            summary
1971        );
1972    }
1973
1974    #[test]
1975    fn video_artifact_set_summary_empty() {
1976        let refs: Vec<&Artifact> = vec![];
1977        let summary = TerminalAdapter::render_video_artifact_set_summary(&refs);
1978        assert_eq!(summary, "", "no videos → empty summary");
1979    }
1980
1981    #[test]
1982    fn video_artifact_set_summary_no_metadata() {
1983        let artifacts = [video_artifact(
1984            "fs://workspace/output/clip.webm",
1985            None,
1986            None,
1987            None,
1988            None,
1989            None,
1990        )];
1991        let refs: Vec<&Artifact> = artifacts.iter().collect();
1992        let summary = TerminalAdapter::render_video_artifact_set_summary(&refs);
1993        assert!(
1994            summary.contains("1 video file"),
1995            "should say '1 video file' without format; got: {}",
1996            summary
1997        );
1998    }
1999}