Skip to main content

dodot_lib/render/
mod.rs

1//! Rendering infrastructure for dodot output.
2//!
3//! Wraps standout-render to provide a consistent rendering pipeline
4//! across all commands. The theme and templates are defined here;
5//! the CLI layer just picks an [`OutputMode`].
6
7use standout_render::{render_with_output, OutputMode, Renderer, Theme};
8
9use crate::Result;
10
11/// The dodot colour theme, defined in YAML for readability.
12///
13/// Style names are semantic — templates reference them by name,
14/// and the theme adapts to terminal capabilities automatically.
15const THEME_YAML: &str = r#"
16pack-name:
17  bold: true
18  fg: blue
19
20filename:
21  fg: white
22
23handler-symbol:
24  bold: true
25  fg: yellow
26
27description:
28  dim: true
29
30deployed:
31  fg: green
32
33pending:
34  fg: magenta
35
36error:
37  fg: red
38  bold: true
39
40broken:
41  fg: red
42
43stale:
44  fg: yellow
45
46warning:
47  fg: yellow
48
49message:
50  fg: cyan
51
52dim:
53  dim: true
54
55header:
56  bold: true
57
58dry-run:
59  fg: yellow
60  italic: true
61
62conflict-banner:
63  fg: white
64  bg: red
65  bold: true
66
67conflict-header:
68  fg: white
69  bg: red
70  bold: true
71
72conflict-target:
73  fg: red
74  bold: true
75
76conflict-pack:
77  fg: red
78
79conflict-hint:
80  dim: true
81
82ignored-pack:
83  dim: true
84  italic: true
85"#;
86
87// ── Templates ───────────────────────────────────────────────────
88
89/// Status / up / down — pack-level output with file listings.
90///
91/// Per-item errors are surfaced as `[N]` markers next to the status label;
92/// their bodies render in a dedicated `Errors:` section at the bottom so
93/// the per-file columns stay single-line and aligned regardless of how
94/// long an individual error message is.
95pub const TEMPLATE_PACK_STATUS: &str = r#"{% if conflicts %}[conflict-banner] ✗ Cross-pack conflicts detected — see details below [/conflict-banner]
96{% endif %}{% if message %}[message]{{ message }}[/message]
97{% endif %}{% if dry_run %}[dry-run]  (dry run — no changes made)[/dry-run]
98{% endif %}{% for pack in packs %}[pack-name]{{ pack.name }}[/pack-name]
99{% for file in pack.files %}  {{ file.name | col(24) }} [handler-symbol]{{ file.symbol }}[/handler-symbol] [description]{{ file.description | col(30) }}[/description]  [{{ file.status }}]{{ file.status_label }}[/{{ file.status }}]{% if file.note_ref %} [dim][{{ file.note_ref }}][/dim]{% endif %}
100{% endfor %}
101{% endfor %}{% if ignored_packs %}[pack-name]Ignored Packs[/pack-name]
102{% for name in ignored_packs %}  [ignored-pack]{{ name }}[/ignored-pack]
103{% endfor %}{% endif %}{% if notes %}
104[header]Errors:[/header]
105{% for note in notes %}  [dim][{{ loop.index }}][/dim] {{ note.body }}
106{% if note.hint %}      [dim]hint:[/dim] {{ note.hint }}
107{% endif %}{% endfor %}{% endif %}{% if conflicts %}
108[conflict-header] Cross-pack conflicts [/conflict-header]
109{% for c in conflicts %}
110{% if c.kind == "symlink" %}The target path [conflict-target]{{ c.target }}[/conflict-target] would be used by multiple packs:
111{% else %}The executable [conflict-target]{{ c.target }}[/conflict-target] would be shadowed across multiple packs in $PATH:
112{% endif %}{% for cl in c.claimants %}  - [conflict-pack]{{ cl.source }}[/conflict-pack]
113{% endfor %}{% endfor %}
114[conflict-hint]Common fixes: give them unique names, override the destination in the pack's config,[/conflict-hint]
115[conflict-hint]or ignore the pack entirely. See `dodot config --help` for the last option.[/conflict-hint]
116[conflict-hint]`dodot up` won't run while conflicts exist.[/conflict-hint]
117{% endif %}"#;
118
119/// List — just pack names.
120pub const TEMPLATE_LIST: &str = r#"{% for pack in packs %}{{ pack.name }}{% if pack.ignored %} [dim](ignored)[/dim]{% endif %}
121{% endfor %}"#;
122
123/// Simple message output (init, fill, adopt, addignore).
124pub const TEMPLATE_MESSAGE: &str = r#"{% if message %}[message]{{ message }}[/message]
125{% endif %}{% for line in details %}  {{ line }}
126{% endfor %}"#;
127
128// ── Renderer ────────────────────────────────────────────────────
129
130/// Create the dodot theme from the embedded YAML definition.
131pub fn create_theme() -> Theme {
132    Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
133}
134
135/// Create a pre-compiled renderer with all dodot templates registered.
136pub fn create_renderer() -> Renderer {
137    let theme = create_theme();
138    let mut renderer = Renderer::new(theme).expect("renderer creation must succeed");
139    renderer
140        .add_template("pack-status", TEMPLATE_PACK_STATUS)
141        .unwrap();
142    renderer.add_template("list", TEMPLATE_LIST).unwrap();
143    renderer.add_template("message", TEMPLATE_MESSAGE).unwrap();
144    renderer
145}
146
147/// Render a template with the given data and output mode.
148///
149/// For JSON mode, serializes the data directly (not through the
150/// template) to produce machine-readable output.
151pub fn render<T: serde::Serialize>(
152    template_name: &str,
153    data: &T,
154    mode: OutputMode,
155) -> Result<String> {
156    if matches!(mode, OutputMode::Json) {
157        return serde_json::to_string_pretty(data)
158            .map_err(|e| crate::DodotError::Other(format!("JSON serialization failed: {e}")));
159    }
160
161    let theme = create_theme();
162    let template = match template_name {
163        "pack-status" => TEMPLATE_PACK_STATUS,
164        "list" => TEMPLATE_LIST,
165        "message" => TEMPLATE_MESSAGE,
166        other => {
167            return Err(crate::DodotError::Other(format!(
168                "unknown template: {other}"
169            )))
170        }
171    };
172
173    render_with_output(template, data, &theme, mode)
174        .map_err(|e| crate::DodotError::Other(format!("render failed: {e}")))
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn theme_parses_without_error() {
183        let _theme = create_theme();
184    }
185
186    #[test]
187    fn renderer_creates_with_all_templates() {
188        let _renderer = create_renderer();
189    }
190
191    #[test]
192    fn render_pack_status_text_mode() {
193        use serde::Serialize;
194
195        #[derive(Serialize)]
196        struct Data {
197            message: Option<String>,
198            dry_run: bool,
199            packs: Vec<Pack>,
200        }
201        #[derive(Serialize)]
202        struct Pack {
203            name: String,
204            files: Vec<File>,
205        }
206        #[derive(Serialize)]
207        struct File {
208            name: String,
209            symbol: String,
210            description: String,
211            status: String,
212            status_label: String,
213        }
214
215        let data = Data {
216            message: None,
217            dry_run: false,
218            packs: vec![Pack {
219                name: "vim".into(),
220                files: vec![File {
221                    name: "vimrc".into(),
222                    symbol: "➞".into(),
223                    description: "~/.vimrc".into(),
224                    status: "deployed".into(),
225                    status_label: "deployed".into(),
226                }],
227            }],
228        };
229
230        let output = render("pack-status", &data, OutputMode::Text).unwrap();
231        assert!(output.contains("vim"));
232        assert!(output.contains("vimrc"));
233        assert!(output.contains("deployed"));
234    }
235
236    #[test]
237    fn json_mode_produces_json() {
238        use serde::Serialize;
239
240        #[derive(Serialize)]
241        struct Data {
242            name: String,
243        }
244
245        let data = Data {
246            name: "test".into(),
247        };
248
249        let output = render("list", &data, OutputMode::Json).unwrap();
250        assert!(output.contains("\"name\""));
251        assert!(output.contains("\"test\""));
252    }
253}