1use crate::color::{Console, SymbolTheme, Tone, sanitize_visible_text};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum FileAction {
10 Added,
12 Modified,
14 Deleted,
16 Renamed,
18}
19
20impl FileAction {
21 pub fn symbol(self, theme: SymbolTheme) -> &'static str {
22 match (self, theme) {
23 (Self::Added, _) => "+",
24 (Self::Modified, _) => "~",
25 (Self::Deleted, _) => "-",
26 (Self::Renamed, SymbolTheme::Unicode) => "→",
27 (Self::Renamed, SymbolTheme::Ascii) => "->",
28 }
29 }
30
31 pub fn tone(self) -> Tone {
32 match self {
33 Self::Added => Tone::Success,
34 Self::Modified => Tone::Warning,
35 Self::Deleted => Tone::Error,
36 Self::Renamed => Tone::Info,
37 }
38 }
39
40 pub fn label(self) -> &'static str {
41 match self {
42 Self::Added => "CREATE",
43 Self::Modified => "UPDATE",
44 Self::Deleted => "DELETE",
45 Self::Renamed => "RENAME",
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52#[non_exhaustive]
53pub struct FileChange {
54 pub path: PathBuf,
55 pub action: FileAction,
56 pub delta: Option<String>,
57}
58
59impl FileChange {
60 pub fn new(action: FileAction, path: impl Into<PathBuf>) -> Self {
62 Self {
63 action,
64 path: path.into(),
65 delta: None,
66 }
67 }
68
69 pub fn with_delta(mut self, delta: impl Into<String>) -> Self {
71 self.delta = Some(delta.into());
72 self
73 }
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct DiffBlock {
80 pub changes: Vec<FileChange>,
81}
82
83impl DiffBlock {
84 pub fn new() -> Self {
86 Self::default()
87 }
88
89 pub fn add_change(mut self, change: FileChange) -> Self {
91 self.changes.push(change);
92 self
93 }
94
95 pub fn render(&self, console: Console) -> String {
97 let mut out = String::new();
98 for c in &self.changes {
99 let sym = console.paint(c.action.tone(), c.action.symbol(console.symbol_theme()));
100 let label = console.paint(c.action.tone(), c.action.label());
101 let path_str = c.path.display().to_string();
102 let safe_path = sanitize_visible_text(&path_str);
103 let delta_str = c
104 .delta
105 .as_deref()
106 .map(|d| console.paint(Tone::Muted, format!(" ({d})")))
107 .unwrap_or_default();
108
109 out.push_str(&format!(" {sym} {label:<6} {safe_path}{delta_str}\n"));
110 }
111 out
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::color::ColorMode;
119
120 #[test]
121 fn diff_block_rendering() {
122 let diff = DiffBlock::new()
123 .add_change(
124 FileChange::new(FileAction::Added, "src/components/Header.tsx")
125 .with_delta("+1.4 kB"),
126 )
127 .add_change(FileChange::new(FileAction::Modified, "src/App.tsx"));
128
129 let console = Console::new(ColorMode::Never, false);
130 let output = diff.render(console);
131
132 assert!(output.contains("+ CREATE src/components/Header.tsx (+1.4 kB)"));
133 assert!(output.contains("~ UPDATE src/App.tsx"));
134 }
135
136 #[test]
137 fn diff_uses_ascii_rename_marker_for_plain_output() {
138 let diff = DiffBlock::new().add_change(FileChange::new(FileAction::Renamed, "src/new.rs"));
139 let console = Console::new(ColorMode::Never, false);
140
141 assert_eq!(diff.render(console), " -> RENAME src/new.rs\n");
142 }
143}