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