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
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
126pub const TEMPLATE_PACK_STATUS: &str = include_str!("../templates/pack-status.jinja");
135
136pub const TEMPLATE_LIST: &str = include_str!("../templates/list.jinja");
138
139pub const TEMPLATE_MESSAGE: &str = include_str!("../templates/message.jinja");
141
142pub const TEMPLATE_PROBE: &str = include_str!("../templates/probe.jinja");
145
146pub const TEMPLATE_GIT_FILTERS: &str = include_str!("../templates/git-filters.jinja");
148
149pub const TEMPLATE_PROMPTS_LIST: &str = include_str!("../templates/prompts-list.jinja");
151
152pub const TEMPLATE_TRANSFORM_CHECK: &str = include_str!("../templates/transform-check.jinja");
155
156pub const TEMPLATE_TRANSFORM_INSTALL_HOOK: &str =
159 include_str!("../templates/transform-install-hook.jinja");
160
161pub const TEMPLATE_REFRESH: &str = include_str!("../templates/refresh.jinja");
163
164pub const TEMPLATE_TEMPLATE_INSTALL_FILTER: &str =
166 include_str!("../templates/template-install-filter.jinja");
167
168pub const TEMPLATE_TRANSFORM_STATUS: &str = include_str!("../templates/transform-status.jinja");
170
171pub const TEMPLATE_GIT_SHOW_ALIAS: &str = include_str!("../templates/git-show-alias.jinja");
173
174pub const TEMPLATE_GIT_INSTALL_ALIAS: &str = include_str!("../templates/git-install-alias.jinja");
176
177pub const TEMPLATE_SECRET_PROBE: &str = include_str!("../templates/secret-probe.jinja");
182
183pub const TEMPLATE_SECRET_LIST: &str = include_str!("../templates/secret-list.jinja");
189
190pub 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
212pub 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
238pub 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
257pub fn create_theme() -> Theme {
261 Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
262}
263
264pub 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
283pub 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 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}