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
111pub const TEMPLATE_PACK_STATUS: &str = include_str!("../templates/pack-status.jinja");
120
121pub const TEMPLATE_LIST: &str = include_str!("../templates/list.jinja");
123
124pub const TEMPLATE_MESSAGE: &str = include_str!("../templates/message.jinja");
126
127pub const TEMPLATE_PROBE: &str = include_str!("../templates/probe.jinja");
130
131pub const TEMPLATE_TUTORIAL_INTRO: &str = include_str!("../templates/tutorial/intro.jinja");
137pub const TEMPLATE_TUTORIAL_CHECK_ROOT: &str =
138 include_str!("../templates/tutorial/check_root.jinja");
139pub const TEMPLATE_TUTORIAL_PICK_PACK: &str = include_str!("../templates/tutorial/pick_pack.jinja");
140pub const TEMPLATE_TUTORIAL_NO_PACKS: &str = include_str!("../templates/tutorial/no_packs.jinja");
141pub const TEMPLATE_TUTORIAL_SHOW_STATUS: &str =
142 include_str!("../templates/tutorial/show_status.jinja");
143pub const TEMPLATE_TUTORIAL_ANNOTATE_STATUS: &str =
144 include_str!("../templates/tutorial/annotate_status.jinja");
145pub const TEMPLATE_TUTORIAL_CONCEPT_TARGETS: &str =
146 include_str!("../templates/tutorial/concept_targets.jinja");
147pub const TEMPLATE_TUTORIAL_CONCEPT_SHELL: &str =
148 include_str!("../templates/tutorial/concept_shell.jinja");
149pub const TEMPLATE_TUTORIAL_DRY_RUN: &str = include_str!("../templates/tutorial/dry_run.jinja");
150pub const TEMPLATE_TUTORIAL_REAL_UP: &str = include_str!("../templates/tutorial/real_up.jinja");
151pub const TEMPLATE_TUTORIAL_OUTRO: &str = include_str!("../templates/tutorial/outro.jinja");
152
153pub const TUTORIAL_STEP_TEMPLATES: &[(&str, &str)] = &[
160 ("tutorial.intro", TEMPLATE_TUTORIAL_INTRO),
161 ("tutorial.check_root", TEMPLATE_TUTORIAL_CHECK_ROOT),
162 ("tutorial.pick_pack", TEMPLATE_TUTORIAL_PICK_PACK),
163 ("tutorial.no_packs", TEMPLATE_TUTORIAL_NO_PACKS),
164 ("tutorial.show_status", TEMPLATE_TUTORIAL_SHOW_STATUS),
165 (
166 "tutorial.annotate_status",
167 TEMPLATE_TUTORIAL_ANNOTATE_STATUS,
168 ),
169 (
170 "tutorial.concept_targets",
171 TEMPLATE_TUTORIAL_CONCEPT_TARGETS,
172 ),
173 ("tutorial.concept_shell", TEMPLATE_TUTORIAL_CONCEPT_SHELL),
174 ("tutorial.dry_run", TEMPLATE_TUTORIAL_DRY_RUN),
175 ("tutorial.real_up", TEMPLATE_TUTORIAL_REAL_UP),
176 ("tutorial.outro", TEMPLATE_TUTORIAL_OUTRO),
177];
178
179pub fn render_tutorial_step<T: serde::Serialize>(
184 step: &str,
185 data: &T,
186 mode: OutputMode,
187) -> Result<String> {
188 let body = TUTORIAL_STEP_TEMPLATES
189 .iter()
190 .find_map(|(name, body)| (*name == step).then_some(*body))
191 .ok_or_else(|| crate::DodotError::Other(format!("unknown tutorial template: {step}")))?;
192
193 let theme = create_theme();
194 render_with_output(body, data, &theme, mode)
195 .map_err(|e| crate::DodotError::Other(format!("tutorial render: {e}")))
196}
197
198pub fn create_theme() -> Theme {
202 Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
203}
204
205pub fn create_renderer() -> Renderer {
207 let theme = create_theme();
208 let mut renderer = Renderer::new(theme).expect("renderer creation must succeed");
209 renderer
210 .add_template("pack-status", TEMPLATE_PACK_STATUS)
211 .unwrap();
212 renderer.add_template("list", TEMPLATE_LIST).unwrap();
213 renderer.add_template("message", TEMPLATE_MESSAGE).unwrap();
214 renderer.add_template("probe", TEMPLATE_PROBE).unwrap();
215 renderer
216}
217
218pub fn render<T: serde::Serialize>(
223 template_name: &str,
224 data: &T,
225 mode: OutputMode,
226) -> Result<String> {
227 if matches!(mode, OutputMode::Json) {
228 return serde_json::to_string_pretty(data)
229 .map_err(|e| crate::DodotError::Other(format!("JSON serialization failed: {e}")));
230 }
231
232 let theme = create_theme();
233 let template = match template_name {
234 "pack-status" => TEMPLATE_PACK_STATUS,
235 "list" => TEMPLATE_LIST,
236 "message" => TEMPLATE_MESSAGE,
237 "probe" => TEMPLATE_PROBE,
238 other => {
239 return Err(crate::DodotError::Other(format!(
240 "unknown template: {other}"
241 )))
242 }
243 };
244
245 render_with_output(template, data, &theme, mode)
246 .map_err(|e| crate::DodotError::Other(format!("render failed: {e}")))
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn theme_parses_without_error() {
255 let _theme = create_theme();
256 }
257
258 #[test]
259 fn renderer_creates_with_all_templates() {
260 let _renderer = create_renderer();
261 }
262
263 #[test]
264 fn render_pack_status_text_mode() {
265 use serde::Serialize;
266
267 #[derive(Serialize)]
268 struct Data {
269 message: Option<String>,
270 dry_run: bool,
271 packs: Vec<Pack>,
272 }
273 #[derive(Serialize)]
274 struct Pack {
275 name: String,
276 files: Vec<File>,
277 }
278 #[derive(Serialize)]
279 struct File {
280 name: String,
281 symbol: String,
282 description: String,
283 status: String,
284 status_label: String,
285 }
286
287 let data = Data {
288 message: None,
289 dry_run: false,
290 packs: vec![Pack {
291 name: "vim".into(),
292 files: vec![File {
293 name: "vimrc".into(),
294 symbol: "➞".into(),
295 description: "~/.vimrc".into(),
296 status: "deployed".into(),
297 status_label: "deployed".into(),
298 }],
299 }],
300 };
301
302 let output = render("pack-status", &data, OutputMode::Text).unwrap();
303 assert!(output.contains("vim"));
304 assert!(output.contains("vimrc"));
305 assert!(output.contains("deployed"));
306 }
307
308 #[test]
309 fn all_tutorial_templates_render_in_text_mode() {
310 use crate::commands::tutorial::{TutorialCtx, TutorialPack};
314
315 let ctx = TutorialCtx {
316 dotfiles_root: "/home/example/dotfiles".into(),
317 via: "DOTFILES_ROOT env var".into(),
318 packs: vec![
319 TutorialPack {
320 name: "vim".into(),
321 kind: "config only".into(),
322 recommended: true,
323 },
324 TutorialPack {
325 name: "zsh".into(),
326 kind: "config + shell".into(),
327 recommended: false,
328 },
329 ],
330 chosen_pack: Some("vim".into()),
331 chosen_pack_kind: Some("config only".into()),
332 status_output: Some("(rendered status would go here)".into()),
333 dry_run_output: Some("(dry-run output)".into()),
334 up_output: Some("(up output)".into()),
335 shell_integration: Some(crate::commands::tutorial::ShellIntegration {
336 shell_kind: "zsh".into(),
337 rc_path: "~/.zshrc".into(),
338 rc_path_abs: std::path::PathBuf::new(),
339 line_present: false,
340 eval_line: r#"eval "$(dodot init-sh)""#.into(),
341 }),
342 eval_line: r#"eval "$(dodot init-sh)""#.into(),
343 ..Default::default()
344 };
345
346 for (name, _) in TUTORIAL_STEP_TEMPLATES {
347 let out = render_tutorial_step(name, &ctx, OutputMode::Text)
348 .unwrap_or_else(|e| panic!("template {name} failed: {e}"));
349 assert!(!out.is_empty(), "template {name} produced empty output");
350 }
351 }
352
353 #[test]
354 fn json_mode_produces_json() {
355 use serde::Serialize;
356
357 #[derive(Serialize)]
358 struct Data {
359 name: String,
360 }
361
362 let data = Data {
363 name: "test".into(),
364 };
365
366 let output = render("list", &data, OutputMode::Json).unwrap();
367 assert!(output.contains("\"name\""));
368 assert!(output.contains("\"test\""));
369 }
370}