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
86group-banner-deployed:
87  fg: green
88  bold: true
89
90group-banner-pending:
91  fg: yellow
92  bold: true
93
94group-banner-error:
95  fg: red
96  bold: true
97
98group-banner-ignored:
99  dim: true
100  bold: true
101"#;
102
103// ── Templates ───────────────────────────────────────────────────
104
105/// Status / up / down — pack-level output with file listings.
106///
107/// Per-item errors are surfaced as `[N]` markers next to the status label;
108/// their bodies render in a dedicated `Errors:` section at the bottom so
109/// the per-file columns stay single-line and aligned regardless of how
110/// long an individual error message is.
111pub const TEMPLATE_PACK_STATUS: &str = include_str!("../templates/pack-status.jinja");
112
113/// List — just pack names.
114pub const TEMPLATE_LIST: &str = include_str!("../templates/list.jinja");
115
116/// Simple message output (init, fill, adopt, addignore).
117pub const TEMPLATE_MESSAGE: &str = include_str!("../templates/message.jinja");
118
119/// Probe — deployment map, data-dir tree, summary. Branches on the
120/// `kind` field of the serialized result.
121pub const TEMPLATE_PROBE: &str = include_str!("../templates/probe.jinja");
122
123// ── Renderer ────────────────────────────────────────────────────
124
125/// Create the dodot theme from the embedded YAML definition.
126pub fn create_theme() -> Theme {
127    Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
128}
129
130/// Create a pre-compiled renderer with all dodot templates registered.
131pub fn create_renderer() -> Renderer {
132    let theme = create_theme();
133    let mut renderer = Renderer::new(theme).expect("renderer creation must succeed");
134    renderer
135        .add_template("pack-status", TEMPLATE_PACK_STATUS)
136        .unwrap();
137    renderer.add_template("list", TEMPLATE_LIST).unwrap();
138    renderer.add_template("message", TEMPLATE_MESSAGE).unwrap();
139    renderer.add_template("probe", TEMPLATE_PROBE).unwrap();
140    renderer
141}
142
143/// Render a template with the given data and output mode.
144///
145/// For JSON mode, serializes the data directly (not through the
146/// template) to produce machine-readable output.
147pub fn render<T: serde::Serialize>(
148    template_name: &str,
149    data: &T,
150    mode: OutputMode,
151) -> Result<String> {
152    if matches!(mode, OutputMode::Json) {
153        return serde_json::to_string_pretty(data)
154            .map_err(|e| crate::DodotError::Other(format!("JSON serialization failed: {e}")));
155    }
156
157    let theme = create_theme();
158    let template = match template_name {
159        "pack-status" => TEMPLATE_PACK_STATUS,
160        "list" => TEMPLATE_LIST,
161        "message" => TEMPLATE_MESSAGE,
162        "probe" => TEMPLATE_PROBE,
163        other => {
164            return Err(crate::DodotError::Other(format!(
165                "unknown template: {other}"
166            )))
167        }
168    };
169
170    render_with_output(template, data, &theme, mode)
171        .map_err(|e| crate::DodotError::Other(format!("render failed: {e}")))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn theme_parses_without_error() {
180        let _theme = create_theme();
181    }
182
183    #[test]
184    fn renderer_creates_with_all_templates() {
185        let _renderer = create_renderer();
186    }
187
188    #[test]
189    fn render_pack_status_text_mode() {
190        use serde::Serialize;
191
192        #[derive(Serialize)]
193        struct Data {
194            message: Option<String>,
195            dry_run: bool,
196            packs: Vec<Pack>,
197        }
198        #[derive(Serialize)]
199        struct Pack {
200            name: String,
201            files: Vec<File>,
202        }
203        #[derive(Serialize)]
204        struct File {
205            name: String,
206            symbol: String,
207            description: String,
208            status: String,
209            status_label: String,
210        }
211
212        let data = Data {
213            message: None,
214            dry_run: false,
215            packs: vec![Pack {
216                name: "vim".into(),
217                files: vec![File {
218                    name: "vimrc".into(),
219                    symbol: "➞".into(),
220                    description: "~/.vimrc".into(),
221                    status: "deployed".into(),
222                    status_label: "deployed".into(),
223                }],
224            }],
225        };
226
227        let output = render("pack-status", &data, OutputMode::Text).unwrap();
228        assert!(output.contains("vim"));
229        assert!(output.contains("vimrc"));
230        assert!(output.contains("deployed"));
231    }
232
233    #[test]
234    fn json_mode_produces_json() {
235        use serde::Serialize;
236
237        #[derive(Serialize)]
238        struct Data {
239            name: String,
240        }
241
242        let data = Data {
243            name: "test".into(),
244        };
245
246        let output = render("list", &data, OutputMode::Json).unwrap();
247        assert!(output.contains("\"name\""));
248        assert!(output.contains("\"test\""));
249    }
250}