Skip to main content

runemark/
diff.rs

1//! Diff and file generation preview components.
2
3use crate::color::{Console, SymbolTheme, Tone, sanitize_visible_text};
4use std::path::PathBuf;
5
6/// The action taken on a file by a scaffolder, generator, or auto-fixer.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum FileAction {
10    /// File was created.
11    Added,
12    /// Existing file was modified.
13    Modified,
14    /// File was removed.
15    Deleted,
16    /// File was moved or renamed.
17    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/// A record of a single file modification.
51#[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    /// Creates a file change entry.
61    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    /// Attaches a byte or line size delta string (e.g. `+1.2 kB`).
70    pub fn with_delta(mut self, delta: impl Into<String>) -> Self {
71        self.delta = Some(delta.into());
72        self
73    }
74}
75
76/// A block rendering generator or fixer file changes.
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct DiffBlock {
80    pub changes: Vec<FileChange>,
81}
82
83impl DiffBlock {
84    /// Creates a new empty diff block.
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Adds a file change to the block.
90    pub fn add_change(mut self, change: FileChange) -> Self {
91        self.changes.push(change);
92        self
93    }
94
95    /// Renders the diff block.
96    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}