1use 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
12fn 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 fn strip_html(s: &str) -> std::borrow::Cow<'_, str> {
49 if !s.contains('<') {
50 return std::borrow::Cow::Borrowed(s);
51 }
52 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 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 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 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 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 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 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 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 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 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 }
353
354 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 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 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 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 }
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 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 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 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 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 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 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 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 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 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 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 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 output.push_str(&self.render_header(ctx));
794
795 output.push_str(&self.render_agent_decision_log(ctx));
797
798 output.push_str(&self.render_design_decisions(ctx));
800
801 output.push_str(&self.render_grouped_changes(&filtered_artifacts));
803
804 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 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 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 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 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 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 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 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 #[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 assert!(output.contains("What Changed"));
1216 assert!(output.contains("Artifacts (1):"));
1217 }
1218
1219 #[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 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 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 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 assert!(!output.contains("Status:"));
1331 }
1332
1333 #[test]
1334 fn render_agent_decision_log_with_context() {
1335 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 assert!(output.contains("→"));
1360 }
1361
1362 #[test]
1363 fn file_filter_glob_match() {
1364 use crate::pr_package::*;
1367
1368 let adapter = TerminalAdapter::new();
1369 let mut package = test_package();
1370 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 assert!(output.contains("auth.rs"), "auth.rs should be shown");
1394 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 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 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 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 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 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 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 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 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 #[test]
1721 fn text_artifact_full_view_renders_diff() {
1722 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 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 #[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 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 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}