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# Tutorial prompt question text. The interactive `dodot tutorial`
103# uses inquire for the prompt UI; this style is mirrored by hand into
104# its `RenderConfig` (see `tutorial.rs::tutorial_render_config`). Keep
105# attributes here in sync with that function so users have one place
106# to change the look.
107tutorial-prompt:
108  italic: true
109"#;
110
111// ── Templates ───────────────────────────────────────────────────
112
113/// Status / up / down — pack-level output with file listings.
114///
115/// Per-item errors are surfaced as `[N]` markers next to the status label;
116/// their bodies render in a dedicated `Errors:` section at the bottom so
117/// the per-file columns stay single-line and aligned regardless of how
118/// long an individual error message is.
119pub const TEMPLATE_PACK_STATUS: &str = include_str!("../templates/pack-status.jinja");
120
121/// List — just pack names.
122pub const TEMPLATE_LIST: &str = include_str!("../templates/list.jinja");
123
124/// Simple message output (init, fill, adopt, addignore).
125pub const TEMPLATE_MESSAGE: &str = include_str!("../templates/message.jinja");
126
127/// Probe — deployment map, data-dir tree, summary. Branches on the
128/// `kind` field of the serialized result.
129pub const TEMPLATE_PROBE: &str = include_str!("../templates/probe.jinja");
130
131// ── Tutorial step templates ─────────────────────────────────────
132//
133// One per step of the interactive tutorial. The CLI driver renders
134// the appropriate template before each prompt.
135
136pub const TEMPLATE_TUTORIAL_INTRO: &str = include_str!("../templates/tutorial/intro.jinja");
137pub const TEMPLATE_TUTORIAL_CHECK_ROOT: &str =
138    include_str!("../templates/tutorial/check_root.jinja");
139pub const TEMPLATE_TUTORIAL_PICK_PACK: &str = include_str!("../templates/tutorial/pick_pack.jinja");
140pub const TEMPLATE_TUTORIAL_NO_PACKS: &str = include_str!("../templates/tutorial/no_packs.jinja");
141pub const TEMPLATE_TUTORIAL_SHOW_STATUS: &str =
142    include_str!("../templates/tutorial/show_status.jinja");
143pub const TEMPLATE_TUTORIAL_ANNOTATE_STATUS: &str =
144    include_str!("../templates/tutorial/annotate_status.jinja");
145pub const TEMPLATE_TUTORIAL_CONCEPT_TARGETS: &str =
146    include_str!("../templates/tutorial/concept_targets.jinja");
147pub const TEMPLATE_TUTORIAL_CONCEPT_SHELL: &str =
148    include_str!("../templates/tutorial/concept_shell.jinja");
149pub const TEMPLATE_TUTORIAL_DRY_RUN: &str = include_str!("../templates/tutorial/dry_run.jinja");
150pub const TEMPLATE_TUTORIAL_REAL_UP: &str = include_str!("../templates/tutorial/real_up.jinja");
151pub const TEMPLATE_TUTORIAL_OUTRO: &str = include_str!("../templates/tutorial/outro.jinja");
152
153/// Pairs of `(name, body)` for every tutorial step template.
154///
155/// `render_tutorial_step` looks up the body by name and renders
156/// against a fresh theme each call — no shared `Renderer` is
157/// retained, since each tutorial run renders fewer than a dozen
158/// templates and the per-call cost is negligible.
159pub const TUTORIAL_STEP_TEMPLATES: &[(&str, &str)] = &[
160    ("tutorial.intro", TEMPLATE_TUTORIAL_INTRO),
161    ("tutorial.check_root", TEMPLATE_TUTORIAL_CHECK_ROOT),
162    ("tutorial.pick_pack", TEMPLATE_TUTORIAL_PICK_PACK),
163    ("tutorial.no_packs", TEMPLATE_TUTORIAL_NO_PACKS),
164    ("tutorial.show_status", TEMPLATE_TUTORIAL_SHOW_STATUS),
165    (
166        "tutorial.annotate_status",
167        TEMPLATE_TUTORIAL_ANNOTATE_STATUS,
168    ),
169    (
170        "tutorial.concept_targets",
171        TEMPLATE_TUTORIAL_CONCEPT_TARGETS,
172    ),
173    ("tutorial.concept_shell", TEMPLATE_TUTORIAL_CONCEPT_SHELL),
174    ("tutorial.dry_run", TEMPLATE_TUTORIAL_DRY_RUN),
175    ("tutorial.real_up", TEMPLATE_TUTORIAL_REAL_UP),
176    ("tutorial.outro", TEMPLATE_TUTORIAL_OUTRO),
177];
178
179/// Render a tutorial step template with the dodot theme.
180///
181/// `mode` controls colour output: `OutputMode::Term` for ANSI in a
182/// real terminal, `OutputMode::Text` for tests / non-TTY.
183pub fn render_tutorial_step<T: serde::Serialize>(
184    step: &str,
185    data: &T,
186    mode: OutputMode,
187) -> Result<String> {
188    let body = TUTORIAL_STEP_TEMPLATES
189        .iter()
190        .find_map(|(name, body)| (*name == step).then_some(*body))
191        .ok_or_else(|| crate::DodotError::Other(format!("unknown tutorial template: {step}")))?;
192
193    let theme = create_theme();
194    render_with_output(body, data, &theme, mode)
195        .map_err(|e| crate::DodotError::Other(format!("tutorial render: {e}")))
196}
197
198// ── Renderer ────────────────────────────────────────────────────
199
200/// Create the dodot theme from the embedded YAML definition.
201pub fn create_theme() -> Theme {
202    Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
203}
204
205/// Create a pre-compiled renderer with all dodot templates registered.
206pub fn create_renderer() -> Renderer {
207    let theme = create_theme();
208    let mut renderer = Renderer::new(theme).expect("renderer creation must succeed");
209    renderer
210        .add_template("pack-status", TEMPLATE_PACK_STATUS)
211        .unwrap();
212    renderer.add_template("list", TEMPLATE_LIST).unwrap();
213    renderer.add_template("message", TEMPLATE_MESSAGE).unwrap();
214    renderer.add_template("probe", TEMPLATE_PROBE).unwrap();
215    renderer
216}
217
218/// Render a template with the given data and output mode.
219///
220/// For JSON mode, serializes the data directly (not through the
221/// template) to produce machine-readable output.
222pub fn render<T: serde::Serialize>(
223    template_name: &str,
224    data: &T,
225    mode: OutputMode,
226) -> Result<String> {
227    if matches!(mode, OutputMode::Json) {
228        return serde_json::to_string_pretty(data)
229            .map_err(|e| crate::DodotError::Other(format!("JSON serialization failed: {e}")));
230    }
231
232    let theme = create_theme();
233    let template = match template_name {
234        "pack-status" => TEMPLATE_PACK_STATUS,
235        "list" => TEMPLATE_LIST,
236        "message" => TEMPLATE_MESSAGE,
237        "probe" => TEMPLATE_PROBE,
238        other => {
239            return Err(crate::DodotError::Other(format!(
240                "unknown template: {other}"
241            )))
242        }
243    };
244
245    render_with_output(template, data, &theme, mode)
246        .map_err(|e| crate::DodotError::Other(format!("render failed: {e}")))
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn theme_parses_without_error() {
255        let _theme = create_theme();
256    }
257
258    #[test]
259    fn renderer_creates_with_all_templates() {
260        let _renderer = create_renderer();
261    }
262
263    #[test]
264    fn render_pack_status_text_mode() {
265        use serde::Serialize;
266
267        #[derive(Serialize)]
268        struct Data {
269            message: Option<String>,
270            dry_run: bool,
271            packs: Vec<Pack>,
272        }
273        #[derive(Serialize)]
274        struct Pack {
275            name: String,
276            files: Vec<File>,
277        }
278        #[derive(Serialize)]
279        struct File {
280            name: String,
281            symbol: String,
282            description: String,
283            status: String,
284            status_label: String,
285        }
286
287        let data = Data {
288            message: None,
289            dry_run: false,
290            packs: vec![Pack {
291                name: "vim".into(),
292                files: vec![File {
293                    name: "vimrc".into(),
294                    symbol: "➞".into(),
295                    description: "~/.vimrc".into(),
296                    status: "deployed".into(),
297                    status_label: "deployed".into(),
298                }],
299            }],
300        };
301
302        let output = render("pack-status", &data, OutputMode::Text).unwrap();
303        assert!(output.contains("vim"));
304        assert!(output.contains("vimrc"));
305        assert!(output.contains("deployed"));
306    }
307
308    #[test]
309    fn all_tutorial_templates_render_in_text_mode() {
310        // Every tutorial step template must parse and render with a
311        // populated context — this catches Jinja-syntax mistakes at
312        // build time rather than mid-tutorial.
313        use crate::commands::tutorial::{TutorialCtx, TutorialPack};
314
315        let ctx = TutorialCtx {
316            dotfiles_root: "/home/example/dotfiles".into(),
317            via: "DOTFILES_ROOT env var".into(),
318            packs: vec![
319                TutorialPack {
320                    name: "vim".into(),
321                    kind: "config only".into(),
322                    recommended: true,
323                },
324                TutorialPack {
325                    name: "zsh".into(),
326                    kind: "config + shell".into(),
327                    recommended: false,
328                },
329            ],
330            chosen_pack: Some("vim".into()),
331            chosen_pack_kind: Some("config only".into()),
332            status_output: Some("(rendered status would go here)".into()),
333            dry_run_output: Some("(dry-run output)".into()),
334            up_output: Some("(up output)".into()),
335            shell_integration: Some(crate::commands::tutorial::ShellIntegration {
336                shell_kind: "zsh".into(),
337                rc_path: "~/.zshrc".into(),
338                rc_path_abs: std::path::PathBuf::new(),
339                line_present: false,
340                eval_line: r#"eval "$(dodot init-sh)""#.into(),
341            }),
342            eval_line: r#"eval "$(dodot init-sh)""#.into(),
343            ..Default::default()
344        };
345
346        for (name, _) in TUTORIAL_STEP_TEMPLATES {
347            let out = render_tutorial_step(name, &ctx, OutputMode::Text)
348                .unwrap_or_else(|e| panic!("template {name} failed: {e}"));
349            assert!(!out.is_empty(), "template {name} produced empty output");
350        }
351    }
352
353    #[test]
354    fn json_mode_produces_json() {
355        use serde::Serialize;
356
357        #[derive(Serialize)]
358        struct Data {
359            name: String,
360        }
361
362        let data = Data {
363            name: "test".into(),
364        };
365
366        let output = render("list", &data, OutputMode::Json).unwrap();
367        assert!(output.contains("\"name\""));
368        assert!(output.contains("\"test\""));
369    }
370}