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# CLI help tags. The hand-written --help text in `dodot-cli/src/help/`
111# uses these alongside the semantic tags above. Mirror standout's
112# default help theme so the look matches the rest of dodot's output:
113#   item    — bold (command names, option flags)
114#   desc    — plain (descriptions next to items)
115#   usage   — plain (the usage line)
116#   example — plain (example blocks)
117#   about   — plain (intro / about text)
118item:
119  bold: true
120desc: {}
121usage: {}
122example: {}
123about: {}
124"#;
125
126// ── Templates ───────────────────────────────────────────────────
127
128/// Status / up / down — pack-level output with file listings.
129///
130/// Per-item errors are surfaced as `[N]` markers next to the status label;
131/// their bodies render in a dedicated `Errors:` section at the bottom so
132/// the per-file columns stay single-line and aligned regardless of how
133/// long an individual error message is.
134pub const TEMPLATE_PACK_STATUS: &str = include_str!("../templates/pack-status.jinja");
135
136/// List — just pack names.
137pub const TEMPLATE_LIST: &str = include_str!("../templates/list.jinja");
138
139/// Simple message output (init, fill, adopt, addignore).
140pub const TEMPLATE_MESSAGE: &str = include_str!("../templates/message.jinja");
141
142/// Probe — deployment map, data-dir tree, summary. Branches on the
143/// `kind` field of the serialized result.
144pub const TEMPLATE_PROBE: &str = include_str!("../templates/probe.jinja");
145
146/// Git filter installation snippets (`dodot git-show-filters`).
147pub const TEMPLATE_GIT_FILTERS: &str = include_str!("../templates/git-filters.jinja");
148
149/// Dismissed-prompt registry listing (`dodot prompts list`).
150pub const TEMPLATE_PROMPTS_LIST: &str = include_str!("../templates/prompts-list.jinja");
151
152/// `dodot transform check` per-file action list + optional unresolved-
153/// marker section. See `commands::transform`.
154pub const TEMPLATE_TRANSFORM_CHECK: &str = include_str!("../templates/transform-check.jinja");
155
156/// `dodot transform install-hook` outcome message (created /
157/// appended / already_installed).
158pub const TEMPLATE_TRANSFORM_INSTALL_HOOK: &str =
159    include_str!("../templates/transform-install-hook.jinja");
160
161/// `dodot refresh` per-mode output (default report / quiet / list-paths).
162pub const TEMPLATE_REFRESH: &str = include_str!("../templates/refresh.jinja");
163
164/// `dodot template install-filter` outcome message.
165pub const TEMPLATE_TEMPLATE_INSTALL_FILTER: &str =
166    include_str!("../templates/template-install-filter.jinja");
167
168/// `dodot transform status` per-file state list.
169pub const TEMPLATE_TRANSFORM_STATUS: &str = include_str!("../templates/transform-status.jinja");
170
171/// `dodot git-show-alias` print-for-paste output.
172pub const TEMPLATE_GIT_SHOW_ALIAS: &str = include_str!("../templates/git-show-alias.jinja");
173
174/// `dodot git-install-alias` outcome message.
175pub const TEMPLATE_GIT_INSTALL_ALIAS: &str = include_str!("../templates/git-install-alias.jinja");
176
177/// `dodot secret probe` per-provider state list. Surfaces each
178/// configured provider's `probe()` outcome with the rendered
179/// hint; treats "no providers configured" / "secrets disabled"
180/// as a separate render branch.
181pub const TEMPLATE_SECRET_PROBE: &str = include_str!("../templates/secret-probe.jinja");
182
183/// `dodot secret list` per-reference enumeration. Lists every
184/// `secret(...)` call across the repo's templates with a
185/// per-row warning when the referenced scheme has no provider
186/// enabled in the current config. Independent rollup at the
187/// bottom names schemes with refs but no provider.
188pub const TEMPLATE_SECRET_LIST: &str = include_str!("../templates/secret-list.jinja");
189
190// ── Tutorial step templates ─────────────────────────────────────
191//
192// One per step of the interactive tutorial. The CLI driver renders
193// the appropriate template before each prompt.
194
195pub const TEMPLATE_TUTORIAL_INTRO: &str = include_str!("../templates/tutorial/intro.jinja");
196pub const TEMPLATE_TUTORIAL_CHECK_ROOT: &str =
197    include_str!("../templates/tutorial/check_root.jinja");
198pub const TEMPLATE_TUTORIAL_PICK_PACK: &str = include_str!("../templates/tutorial/pick_pack.jinja");
199pub const TEMPLATE_TUTORIAL_NO_PACKS: &str = include_str!("../templates/tutorial/no_packs.jinja");
200pub const TEMPLATE_TUTORIAL_SHOW_STATUS: &str =
201    include_str!("../templates/tutorial/show_status.jinja");
202pub const TEMPLATE_TUTORIAL_ANNOTATE_STATUS: &str =
203    include_str!("../templates/tutorial/annotate_status.jinja");
204pub const TEMPLATE_TUTORIAL_CONCEPT_TARGETS: &str =
205    include_str!("../templates/tutorial/concept_targets.jinja");
206pub const TEMPLATE_TUTORIAL_CONCEPT_SHELL: &str =
207    include_str!("../templates/tutorial/concept_shell.jinja");
208pub const TEMPLATE_TUTORIAL_DRY_RUN: &str = include_str!("../templates/tutorial/dry_run.jinja");
209pub const TEMPLATE_TUTORIAL_REAL_UP: &str = include_str!("../templates/tutorial/real_up.jinja");
210pub const TEMPLATE_TUTORIAL_OUTRO: &str = include_str!("../templates/tutorial/outro.jinja");
211
212/// Pairs of `(name, body)` for every tutorial step template.
213///
214/// `render_tutorial_step` looks up the body by name and renders
215/// against a fresh theme each call — no shared `Renderer` is
216/// retained, since each tutorial run renders fewer than a dozen
217/// templates and the per-call cost is negligible.
218pub const TUTORIAL_STEP_TEMPLATES: &[(&str, &str)] = &[
219    ("tutorial.intro", TEMPLATE_TUTORIAL_INTRO),
220    ("tutorial.check_root", TEMPLATE_TUTORIAL_CHECK_ROOT),
221    ("tutorial.pick_pack", TEMPLATE_TUTORIAL_PICK_PACK),
222    ("tutorial.no_packs", TEMPLATE_TUTORIAL_NO_PACKS),
223    ("tutorial.show_status", TEMPLATE_TUTORIAL_SHOW_STATUS),
224    (
225        "tutorial.annotate_status",
226        TEMPLATE_TUTORIAL_ANNOTATE_STATUS,
227    ),
228    (
229        "tutorial.concept_targets",
230        TEMPLATE_TUTORIAL_CONCEPT_TARGETS,
231    ),
232    ("tutorial.concept_shell", TEMPLATE_TUTORIAL_CONCEPT_SHELL),
233    ("tutorial.dry_run", TEMPLATE_TUTORIAL_DRY_RUN),
234    ("tutorial.real_up", TEMPLATE_TUTORIAL_REAL_UP),
235    ("tutorial.outro", TEMPLATE_TUTORIAL_OUTRO),
236];
237
238/// Render a tutorial step template with the dodot theme.
239///
240/// `mode` controls colour output: `OutputMode::Term` for ANSI in a
241/// real terminal, `OutputMode::Text` for tests / non-TTY.
242pub fn render_tutorial_step<T: serde::Serialize>(
243    step: &str,
244    data: &T,
245    mode: OutputMode,
246) -> Result<String> {
247    let body = TUTORIAL_STEP_TEMPLATES
248        .iter()
249        .find_map(|(name, body)| (*name == step).then_some(*body))
250        .ok_or_else(|| crate::DodotError::Other(format!("unknown tutorial template: {step}")))?;
251
252    let theme = create_theme();
253    render_with_output(body, data, &theme, mode)
254        .map_err(|e| crate::DodotError::Other(format!("tutorial render: {e}")))
255}
256
257// ── Renderer ────────────────────────────────────────────────────
258
259/// Create the dodot theme from the embedded YAML definition.
260pub fn create_theme() -> Theme {
261    Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
262}
263
264/// Create a pre-compiled renderer with all dodot templates registered.
265pub fn create_renderer() -> Renderer {
266    let theme = create_theme();
267    let mut renderer = Renderer::new(theme).expect("renderer creation must succeed");
268    renderer
269        .add_template("pack-status", TEMPLATE_PACK_STATUS)
270        .unwrap();
271    renderer.add_template("list", TEMPLATE_LIST).unwrap();
272    renderer.add_template("message", TEMPLATE_MESSAGE).unwrap();
273    renderer.add_template("probe", TEMPLATE_PROBE).unwrap();
274    renderer
275        .add_template("git-filters", TEMPLATE_GIT_FILTERS)
276        .unwrap();
277    renderer
278        .add_template("prompts-list", TEMPLATE_PROMPTS_LIST)
279        .unwrap();
280    renderer
281}
282
283/// Render a template with the given data and output mode.
284///
285/// For JSON mode, serializes the data directly (not through the
286/// template) to produce machine-readable output.
287pub fn render<T: serde::Serialize>(
288    template_name: &str,
289    data: &T,
290    mode: OutputMode,
291) -> Result<String> {
292    if matches!(mode, OutputMode::Json) {
293        return serde_json::to_string_pretty(data)
294            .map_err(|e| crate::DodotError::Other(format!("JSON serialization failed: {e}")));
295    }
296
297    let theme = create_theme();
298    let template = match template_name {
299        "pack-status" => TEMPLATE_PACK_STATUS,
300        "list" => TEMPLATE_LIST,
301        "message" => TEMPLATE_MESSAGE,
302        "probe" => TEMPLATE_PROBE,
303        "git-filters" => TEMPLATE_GIT_FILTERS,
304        "prompts-list" => TEMPLATE_PROMPTS_LIST,
305        other => {
306            return Err(crate::DodotError::Other(format!(
307                "unknown template: {other}"
308            )))
309        }
310    };
311
312    render_with_output(template, data, &theme, mode)
313        .map_err(|e| crate::DodotError::Other(format!("render failed: {e}")))
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn theme_parses_without_error() {
322        let _theme = create_theme();
323    }
324
325    #[test]
326    fn renderer_creates_with_all_templates() {
327        let _renderer = create_renderer();
328    }
329
330    #[test]
331    fn render_pack_status_text_mode() {
332        use serde::Serialize;
333
334        #[derive(Serialize)]
335        struct Data {
336            message: Option<String>,
337            dry_run: bool,
338            packs: Vec<Pack>,
339        }
340        #[derive(Serialize)]
341        struct Pack {
342            name: String,
343            files: Vec<File>,
344        }
345        #[derive(Serialize)]
346        struct File {
347            name: String,
348            symbol: String,
349            description: String,
350            status: String,
351            status_label: String,
352        }
353
354        let data = Data {
355            message: None,
356            dry_run: false,
357            packs: vec![Pack {
358                name: "vim".into(),
359                files: vec![File {
360                    name: "vimrc".into(),
361                    symbol: "➞".into(),
362                    description: "~/.vimrc".into(),
363                    status: "deployed".into(),
364                    status_label: "deployed".into(),
365                }],
366            }],
367        };
368
369        let output = render("pack-status", &data, OutputMode::Text).unwrap();
370        assert!(output.contains("vim"));
371        assert!(output.contains("vimrc"));
372        assert!(output.contains("deployed"));
373    }
374
375    #[test]
376    fn all_tutorial_templates_render_in_text_mode() {
377        // Every tutorial step template must parse and render with a
378        // populated context — this catches Jinja-syntax mistakes at
379        // build time rather than mid-tutorial.
380        use crate::commands::tutorial::{TutorialCtx, TutorialPack};
381
382        let ctx = TutorialCtx {
383            dotfiles_root: "/home/example/dotfiles".into(),
384            via: "DOTFILES_ROOT env var".into(),
385            packs: vec![
386                TutorialPack {
387                    name: "vim".into(),
388                    kind: "config only".into(),
389                    recommended: true,
390                },
391                TutorialPack {
392                    name: "zsh".into(),
393                    kind: "config + shell".into(),
394                    recommended: false,
395                },
396            ],
397            chosen_pack: Some("vim".into()),
398            chosen_pack_kind: Some("config only".into()),
399            status_output: Some("(rendered status would go here)".into()),
400            dry_run_output: Some("(dry-run output)".into()),
401            up_output: Some("(up output)".into()),
402            shell_integration: Some(crate::commands::tutorial::ShellIntegration {
403                shell_kind: "zsh".into(),
404                rc_path: "~/.zshrc".into(),
405                rc_path_abs: std::path::PathBuf::new(),
406                line_present: false,
407                eval_line: r#"eval "$(dodot init-sh)""#.into(),
408            }),
409            eval_line: r#"eval "$(dodot init-sh)""#.into(),
410            ..Default::default()
411        };
412
413        for (name, _) in TUTORIAL_STEP_TEMPLATES {
414            let out = render_tutorial_step(name, &ctx, OutputMode::Text)
415                .unwrap_or_else(|e| panic!("template {name} failed: {e}"));
416            assert!(!out.is_empty(), "template {name} produced empty output");
417        }
418    }
419
420    #[test]
421    fn json_mode_produces_json() {
422        use serde::Serialize;
423
424        #[derive(Serialize)]
425        struct Data {
426            name: String,
427        }
428
429        let data = Data {
430            name: "test".into(),
431        };
432
433        let output = render("list", &data, OutputMode::Json).unwrap();
434        assert!(output.contains("\"name\""));
435        assert!(output.contains("\"test\""));
436    }
437}