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"#;
62
63// ── Templates ───────────────────────────────────────────────────
64
65/// Status / up / down — pack-level output with file listings.
66pub const TEMPLATE_PACK_STATUS: &str = r#"{% if message %}[message]{{ message }}[/message]
67{% endif %}{% if dry_run %}[dry-run]  (dry run — no changes made)[/dry-run]
68{% endif %}{% for pack in packs %}[pack-name]{{ pack.name }}[/pack-name]
69{% 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 }}]
70{% endfor %}
71{% endfor %}"#;
72
73/// List — just pack names.
74pub const TEMPLATE_LIST: &str = r#"{% for pack in packs %}{{ pack.name }}{% if pack.ignored %} [dim](ignored)[/dim]{% endif %}
75{% endfor %}"#;
76
77/// Simple message output (init, fill, adopt, addignore).
78pub const TEMPLATE_MESSAGE: &str = r#"{% if message %}[message]{{ message }}[/message]
79{% endif %}{% for line in details %}  {{ line }}
80{% endfor %}"#;
81
82// ── Renderer ────────────────────────────────────────────────────
83
84/// Create the dodot theme from the embedded YAML definition.
85pub fn create_theme() -> Theme {
86    Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
87}
88
89/// Create a pre-compiled renderer with all dodot templates registered.
90pub fn create_renderer() -> Renderer {
91    let theme = create_theme();
92    let mut renderer = Renderer::new(theme).expect("renderer creation must succeed");
93    renderer
94        .add_template("pack-status", TEMPLATE_PACK_STATUS)
95        .unwrap();
96    renderer.add_template("list", TEMPLATE_LIST).unwrap();
97    renderer.add_template("message", TEMPLATE_MESSAGE).unwrap();
98    renderer
99}
100
101/// Render a template with the given data and output mode.
102///
103/// For JSON mode, serializes the data directly (not through the
104/// template) to produce machine-readable output.
105pub fn render<T: serde::Serialize>(
106    template_name: &str,
107    data: &T,
108    mode: OutputMode,
109) -> Result<String> {
110    if matches!(mode, OutputMode::Json) {
111        return serde_json::to_string_pretty(data)
112            .map_err(|e| crate::DodotError::Other(format!("JSON serialization failed: {e}")));
113    }
114
115    let theme = create_theme();
116    let template = match template_name {
117        "pack-status" => TEMPLATE_PACK_STATUS,
118        "list" => TEMPLATE_LIST,
119        "message" => TEMPLATE_MESSAGE,
120        other => {
121            return Err(crate::DodotError::Other(format!(
122                "unknown template: {other}"
123            )))
124        }
125    };
126
127    render_with_output(template, data, &theme, mode)
128        .map_err(|e| crate::DodotError::Other(format!("render failed: {e}")))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn theme_parses_without_error() {
137        let _theme = create_theme();
138    }
139
140    #[test]
141    fn renderer_creates_with_all_templates() {
142        let _renderer = create_renderer();
143    }
144
145    #[test]
146    fn render_pack_status_text_mode() {
147        use serde::Serialize;
148
149        #[derive(Serialize)]
150        struct Data {
151            message: Option<String>,
152            dry_run: bool,
153            packs: Vec<Pack>,
154        }
155        #[derive(Serialize)]
156        struct Pack {
157            name: String,
158            files: Vec<File>,
159        }
160        #[derive(Serialize)]
161        struct File {
162            name: String,
163            symbol: String,
164            description: String,
165            status: String,
166            status_label: String,
167        }
168
169        let data = Data {
170            message: None,
171            dry_run: false,
172            packs: vec![Pack {
173                name: "vim".into(),
174                files: vec![File {
175                    name: "vimrc".into(),
176                    symbol: "➞".into(),
177                    description: "~/.vimrc".into(),
178                    status: "deployed".into(),
179                    status_label: "deployed".into(),
180                }],
181            }],
182        };
183
184        let output = render("pack-status", &data, OutputMode::Text).unwrap();
185        assert!(output.contains("vim"));
186        assert!(output.contains("vimrc"));
187        assert!(output.contains("deployed"));
188    }
189
190    #[test]
191    fn json_mode_produces_json() {
192        use serde::Serialize;
193
194        #[derive(Serialize)]
195        struct Data {
196            name: String,
197        }
198
199        let data = Data {
200            name: "test".into(),
201        };
202
203        let output = render("list", &data, OutputMode::Json).unwrap();
204        assert!(output.contains("\"name\""));
205        assert!(output.contains("\"test\""));
206    }
207}