1use standout_render::{render_with_output, OutputMode, Renderer, Theme};
8
9use crate::Result;
10
11const 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"#;
86
87pub const TEMPLATE_PACK_STATUS: &str = r#"{% if conflicts %}[conflict-banner] ✗ Cross-pack conflicts detected — see details below [/conflict-banner]
91{% endif %}{% if message %}[message]{{ message }}[/message]
92{% endif %}{% if dry_run %}[dry-run] (dry run — no changes made)[/dry-run]
93{% endif %}{% for pack in packs %}[pack-name]{{ pack.name }}[/pack-name]
94{% 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 }}]
95{% endfor %}
96{% endfor %}{% if ignored_packs %}[pack-name]Ignored Packs[/pack-name]
97{% for name in ignored_packs %} [ignored-pack]{{ name }}[/ignored-pack]
98{% endfor %}{% endif %}{% if conflicts %}
99[conflict-header] Cross-pack conflicts [/conflict-header]
100{% for c in conflicts %}
101{% if c.kind == "symlink" %}The target path [conflict-target]{{ c.target }}[/conflict-target] would be used by multiple packs:
102{% else %}The executable [conflict-target]{{ c.target }}[/conflict-target] would be shadowed across multiple packs in $PATH:
103{% endif %}{% for cl in c.claimants %} - [conflict-pack]{{ cl.source }}[/conflict-pack]
104{% endfor %}{% endfor %}
105[conflict-hint]Common fixes: give them unique names, override the destination in the pack's config,[/conflict-hint]
106[conflict-hint]or ignore the pack entirely. See `dodot config --help` for the last option.[/conflict-hint]
107[conflict-hint]`dodot up` won't run while conflicts exist.[/conflict-hint]
108{% endif %}"#;
109
110pub const TEMPLATE_LIST: &str = r#"{% for pack in packs %}{{ pack.name }}{% if pack.ignored %} [dim](ignored)[/dim]{% endif %}
112{% endfor %}"#;
113
114pub const TEMPLATE_MESSAGE: &str = r#"{% if message %}[message]{{ message }}[/message]
116{% endif %}{% for line in details %} {{ line }}
117{% endfor %}"#;
118
119pub fn create_theme() -> Theme {
123 Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
124}
125
126pub fn create_renderer() -> Renderer {
128 let theme = create_theme();
129 let mut renderer = Renderer::new(theme).expect("renderer creation must succeed");
130 renderer
131 .add_template("pack-status", TEMPLATE_PACK_STATUS)
132 .unwrap();
133 renderer.add_template("list", TEMPLATE_LIST).unwrap();
134 renderer.add_template("message", TEMPLATE_MESSAGE).unwrap();
135 renderer
136}
137
138pub fn render<T: serde::Serialize>(
143 template_name: &str,
144 data: &T,
145 mode: OutputMode,
146) -> Result<String> {
147 if matches!(mode, OutputMode::Json) {
148 return serde_json::to_string_pretty(data)
149 .map_err(|e| crate::DodotError::Other(format!("JSON serialization failed: {e}")));
150 }
151
152 let theme = create_theme();
153 let template = match template_name {
154 "pack-status" => TEMPLATE_PACK_STATUS,
155 "list" => TEMPLATE_LIST,
156 "message" => TEMPLATE_MESSAGE,
157 other => {
158 return Err(crate::DodotError::Other(format!(
159 "unknown template: {other}"
160 )))
161 }
162 };
163
164 render_with_output(template, data, &theme, mode)
165 .map_err(|e| crate::DodotError::Other(format!("render failed: {e}")))
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn theme_parses_without_error() {
174 let _theme = create_theme();
175 }
176
177 #[test]
178 fn renderer_creates_with_all_templates() {
179 let _renderer = create_renderer();
180 }
181
182 #[test]
183 fn render_pack_status_text_mode() {
184 use serde::Serialize;
185
186 #[derive(Serialize)]
187 struct Data {
188 message: Option<String>,
189 dry_run: bool,
190 packs: Vec<Pack>,
191 }
192 #[derive(Serialize)]
193 struct Pack {
194 name: String,
195 files: Vec<File>,
196 }
197 #[derive(Serialize)]
198 struct File {
199 name: String,
200 symbol: String,
201 description: String,
202 status: String,
203 status_label: String,
204 }
205
206 let data = Data {
207 message: None,
208 dry_run: false,
209 packs: vec![Pack {
210 name: "vim".into(),
211 files: vec![File {
212 name: "vimrc".into(),
213 symbol: "➞".into(),
214 description: "~/.vimrc".into(),
215 status: "deployed".into(),
216 status_label: "deployed".into(),
217 }],
218 }],
219 };
220
221 let output = render("pack-status", &data, OutputMode::Text).unwrap();
222 assert!(output.contains("vim"));
223 assert!(output.contains("vimrc"));
224 assert!(output.contains("deployed"));
225 }
226
227 #[test]
228 fn json_mode_produces_json() {
229 use serde::Serialize;
230
231 #[derive(Serialize)]
232 struct Data {
233 name: String,
234 }
235
236 let data = Data {
237 name: "test".into(),
238 };
239
240 let output = render("list", &data, OutputMode::Json).unwrap();
241 assert!(output.contains("\"name\""));
242 assert!(output.contains("\"test\""));
243 }
244}