dodot-lib 4.1.1

Core library for dodot dotfiles manager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! Rendering infrastructure for dodot output.
//!
//! Wraps standout-render to provide a consistent rendering pipeline
//! across all commands. The theme and templates are defined here;
//! the CLI layer just picks an [`OutputMode`].

use standout_render::{render_with_output, OutputMode, Renderer, Theme};

use crate::Result;

/// The dodot colour theme, defined in YAML for readability.
///
/// Style names are semantic — templates reference them by name,
/// and the theme adapts to terminal capabilities automatically.
const THEME_YAML: &str = r#"
pack-name:
  bold: true
  fg: blue

filename:
  fg: white

handler-symbol:
  bold: true
  fg: yellow

description:
  dim: true

deployed:
  fg: green

pending:
  fg: magenta

error:
  fg: red
  bold: true

broken:
  fg: red

stale:
  fg: yellow

skipped:
  dim: true

warning:
  fg: yellow

message:
  fg: cyan

dim:
  dim: true

header:
  bold: true

dry-run:
  fg: yellow
  italic: true

conflict-banner:
  fg: white
  bg: red
  bold: true

conflict-header:
  fg: white
  bg: red
  bold: true

conflict-target:
  fg: red
  bold: true

conflict-pack:
  fg: red

conflict-hint:
  dim: true

ignored-pack:
  dim: true
  italic: true

group-banner-deployed:
  fg: green
  bold: true

group-banner-pending:
  fg: yellow
  bold: true

group-banner-error:
  fg: red
  bold: true

group-banner-ignored:
  dim: true
  bold: true

# Tutorial prompt question text. The interactive `dodot tutorial`
# uses inquire for the prompt UI; this style is mirrored by hand into
# its `RenderConfig` (see `tutorial.rs::tutorial_render_config`). Keep
# attributes here in sync with that function so users have one place
# to change the look.
tutorial-prompt:
  italic: true

# CLI help tags. The hand-written --help text in `dodot-cli/src/help/`
# uses these alongside the semantic tags above. Mirror standout's
# default help theme so the look matches the rest of dodot's output:
#   item    — bold (command names, option flags)
#   desc    — plain (descriptions next to items)
#   usage   — plain (the usage line)
#   example — plain (example blocks)
#   about   — plain (intro / about text)
item:
  bold: true
desc: {}
usage: {}
example: {}
about: {}
"#;

// ── Templates ───────────────────────────────────────────────────

/// Status / up / down — pack-level output with file listings.
///
/// Per-item errors are surfaced as `[N]` markers next to the status label;
/// their bodies render in a dedicated `Errors:` section at the bottom so
/// the per-file columns stay single-line and aligned regardless of how
/// long an individual error message is.
pub const TEMPLATE_PACK_STATUS: &str = include_str!("../templates/pack-status.jinja");

/// List — just pack names.
pub const TEMPLATE_LIST: &str = include_str!("../templates/list.jinja");

/// Simple message output (init, fill, adopt, addignore).
pub const TEMPLATE_MESSAGE: &str = include_str!("../templates/message.jinja");

/// Probe — deployment map, data-dir tree, summary. Branches on the
/// `kind` field of the serialized result.
pub const TEMPLATE_PROBE: &str = include_str!("../templates/probe.jinja");

/// Git filter installation snippets (`dodot git-show-filters`).
pub const TEMPLATE_GIT_FILTERS: &str = include_str!("../templates/git-filters.jinja");

/// Dismissed-prompt registry listing (`dodot prompts list`).
pub const TEMPLATE_PROMPTS_LIST: &str = include_str!("../templates/prompts-list.jinja");

/// `dodot transform check` per-file action list + optional unresolved-
/// marker section. See `commands::transform`.
pub const TEMPLATE_TRANSFORM_CHECK: &str = include_str!("../templates/transform-check.jinja");

/// `dodot transform install-hook` outcome message (created /
/// appended / already_installed).
pub const TEMPLATE_TRANSFORM_INSTALL_HOOK: &str =
    include_str!("../templates/transform-install-hook.jinja");

/// `dodot refresh` per-mode output (default report / quiet / list-paths).
pub const TEMPLATE_REFRESH: &str = include_str!("../templates/refresh.jinja");

/// `dodot template install-filter` outcome message.
pub const TEMPLATE_TEMPLATE_INSTALL_FILTER: &str =
    include_str!("../templates/template-install-filter.jinja");

/// `dodot transform status` per-file state list.
pub const TEMPLATE_TRANSFORM_STATUS: &str = include_str!("../templates/transform-status.jinja");

/// `dodot git-show-alias` print-for-paste output.
pub const TEMPLATE_GIT_SHOW_ALIAS: &str = include_str!("../templates/git-show-alias.jinja");

/// `dodot git-install-alias` outcome message.
pub const TEMPLATE_GIT_INSTALL_ALIAS: &str = include_str!("../templates/git-install-alias.jinja");

/// `dodot secret probe` per-provider state list. Surfaces each
/// configured provider's `probe()` outcome with the rendered
/// hint; treats "no providers configured" / "secrets disabled"
/// as a separate render branch.
pub const TEMPLATE_SECRET_PROBE: &str = include_str!("../templates/secret-probe.jinja");

/// `dodot secret list` per-reference enumeration. Lists every
/// `secret(...)` call across the repo's templates with a
/// per-row warning when the referenced scheme has no provider
/// enabled in the current config. Independent rollup at the
/// bottom names schemes with refs but no provider.
pub const TEMPLATE_SECRET_LIST: &str = include_str!("../templates/secret-list.jinja");

// ── Tutorial step templates ─────────────────────────────────────
//
// One per step of the interactive tutorial. The CLI driver renders
// the appropriate template before each prompt.

pub const TEMPLATE_TUTORIAL_INTRO: &str = include_str!("../templates/tutorial/intro.jinja");
pub const TEMPLATE_TUTORIAL_CHECK_ROOT: &str =
    include_str!("../templates/tutorial/check_root.jinja");
pub const TEMPLATE_TUTORIAL_PICK_PACK: &str = include_str!("../templates/tutorial/pick_pack.jinja");
pub const TEMPLATE_TUTORIAL_NO_PACKS: &str = include_str!("../templates/tutorial/no_packs.jinja");
pub const TEMPLATE_TUTORIAL_SHOW_STATUS: &str =
    include_str!("../templates/tutorial/show_status.jinja");
pub const TEMPLATE_TUTORIAL_ANNOTATE_STATUS: &str =
    include_str!("../templates/tutorial/annotate_status.jinja");
pub const TEMPLATE_TUTORIAL_CONCEPT_TARGETS: &str =
    include_str!("../templates/tutorial/concept_targets.jinja");
pub const TEMPLATE_TUTORIAL_CONCEPT_SHELL: &str =
    include_str!("../templates/tutorial/concept_shell.jinja");
pub const TEMPLATE_TUTORIAL_DRY_RUN: &str = include_str!("../templates/tutorial/dry_run.jinja");
pub const TEMPLATE_TUTORIAL_REAL_UP: &str = include_str!("../templates/tutorial/real_up.jinja");
pub const TEMPLATE_TUTORIAL_OUTRO: &str = include_str!("../templates/tutorial/outro.jinja");

/// Pairs of `(name, body)` for every tutorial step template.
///
/// `render_tutorial_step` looks up the body by name and renders
/// against a fresh theme each call — no shared `Renderer` is
/// retained, since each tutorial run renders fewer than a dozen
/// templates and the per-call cost is negligible.
pub const TUTORIAL_STEP_TEMPLATES: &[(&str, &str)] = &[
    ("tutorial.intro", TEMPLATE_TUTORIAL_INTRO),
    ("tutorial.check_root", TEMPLATE_TUTORIAL_CHECK_ROOT),
    ("tutorial.pick_pack", TEMPLATE_TUTORIAL_PICK_PACK),
    ("tutorial.no_packs", TEMPLATE_TUTORIAL_NO_PACKS),
    ("tutorial.show_status", TEMPLATE_TUTORIAL_SHOW_STATUS),
    (
        "tutorial.annotate_status",
        TEMPLATE_TUTORIAL_ANNOTATE_STATUS,
    ),
    (
        "tutorial.concept_targets",
        TEMPLATE_TUTORIAL_CONCEPT_TARGETS,
    ),
    ("tutorial.concept_shell", TEMPLATE_TUTORIAL_CONCEPT_SHELL),
    ("tutorial.dry_run", TEMPLATE_TUTORIAL_DRY_RUN),
    ("tutorial.real_up", TEMPLATE_TUTORIAL_REAL_UP),
    ("tutorial.outro", TEMPLATE_TUTORIAL_OUTRO),
];

/// Render a tutorial step template with the dodot theme.
///
/// `mode` controls colour output: `OutputMode::Term` for ANSI in a
/// real terminal, `OutputMode::Text` for tests / non-TTY.
pub fn render_tutorial_step<T: serde::Serialize>(
    step: &str,
    data: &T,
    mode: OutputMode,
) -> Result<String> {
    let body = TUTORIAL_STEP_TEMPLATES
        .iter()
        .find_map(|(name, body)| (*name == step).then_some(*body))
        .ok_or_else(|| crate::DodotError::Other(format!("unknown tutorial template: {step}")))?;

    let theme = create_theme();
    render_with_output(body, data, &theme, mode)
        .map_err(|e| crate::DodotError::Other(format!("tutorial render: {e}")))
}

// ── Renderer ────────────────────────────────────────────────────

/// Create the dodot theme from the embedded YAML definition.
pub fn create_theme() -> Theme {
    Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
}

/// Create a pre-compiled renderer with all dodot templates registered.
pub fn create_renderer() -> Renderer {
    let theme = create_theme();
    let mut renderer = Renderer::new(theme).expect("renderer creation must succeed");
    renderer
        .add_template("pack-status", TEMPLATE_PACK_STATUS)
        .unwrap();
    renderer.add_template("list", TEMPLATE_LIST).unwrap();
    renderer.add_template("message", TEMPLATE_MESSAGE).unwrap();
    renderer.add_template("probe", TEMPLATE_PROBE).unwrap();
    renderer
        .add_template("git-filters", TEMPLATE_GIT_FILTERS)
        .unwrap();
    renderer
        .add_template("prompts-list", TEMPLATE_PROMPTS_LIST)
        .unwrap();
    renderer
}

/// Render a template with the given data and output mode.
///
/// For JSON mode, serializes the data directly (not through the
/// template) to produce machine-readable output.
pub fn render<T: serde::Serialize>(
    template_name: &str,
    data: &T,
    mode: OutputMode,
) -> Result<String> {
    if matches!(mode, OutputMode::Json) {
        return serde_json::to_string_pretty(data)
            .map_err(|e| crate::DodotError::Other(format!("JSON serialization failed: {e}")));
    }

    let theme = create_theme();
    let template = match template_name {
        "pack-status" => TEMPLATE_PACK_STATUS,
        "list" => TEMPLATE_LIST,
        "message" => TEMPLATE_MESSAGE,
        "probe" => TEMPLATE_PROBE,
        "git-filters" => TEMPLATE_GIT_FILTERS,
        "prompts-list" => TEMPLATE_PROMPTS_LIST,
        other => {
            return Err(crate::DodotError::Other(format!(
                "unknown template: {other}"
            )))
        }
    };

    render_with_output(template, data, &theme, mode)
        .map_err(|e| crate::DodotError::Other(format!("render failed: {e}")))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn theme_parses_without_error() {
        let _theme = create_theme();
    }

    #[test]
    fn renderer_creates_with_all_templates() {
        let _renderer = create_renderer();
    }

    #[test]
    fn render_pack_status_text_mode() {
        use serde::Serialize;

        #[derive(Serialize)]
        struct Data {
            message: Option<String>,
            dry_run: bool,
            packs: Vec<Pack>,
        }
        #[derive(Serialize)]
        struct Pack {
            name: String,
            files: Vec<File>,
        }
        #[derive(Serialize)]
        struct File {
            name: String,
            symbol: String,
            description: String,
            status: String,
            status_label: String,
        }

        let data = Data {
            message: None,
            dry_run: false,
            packs: vec![Pack {
                name: "vim".into(),
                files: vec![File {
                    name: "vimrc".into(),
                    symbol: "".into(),
                    description: "~/.vimrc".into(),
                    status: "deployed".into(),
                    status_label: "deployed".into(),
                }],
            }],
        };

        let output = render("pack-status", &data, OutputMode::Text).unwrap();
        assert!(output.contains("vim"));
        assert!(output.contains("vimrc"));
        assert!(output.contains("deployed"));
    }

    #[test]
    fn all_tutorial_templates_render_in_text_mode() {
        // Every tutorial step template must parse and render with a
        // populated context — this catches Jinja-syntax mistakes at
        // build time rather than mid-tutorial.
        use crate::commands::tutorial::{TutorialCtx, TutorialPack};

        let ctx = TutorialCtx {
            dotfiles_root: "/home/example/dotfiles".into(),
            via: "DOTFILES_ROOT env var".into(),
            packs: vec![
                TutorialPack {
                    name: "vim".into(),
                    kind: "config only".into(),
                    recommended: true,
                },
                TutorialPack {
                    name: "zsh".into(),
                    kind: "config + shell".into(),
                    recommended: false,
                },
            ],
            chosen_pack: Some("vim".into()),
            chosen_pack_kind: Some("config only".into()),
            status_output: Some("(rendered status would go here)".into()),
            dry_run_output: Some("(dry-run output)".into()),
            up_output: Some("(up output)".into()),
            shell_integration: Some(crate::commands::tutorial::ShellIntegration {
                shell_kind: "zsh".into(),
                rc_path: "~/.zshrc".into(),
                rc_path_abs: std::path::PathBuf::new(),
                line_present: false,
                eval_line: r#"eval "$(dodot init-sh)""#.into(),
            }),
            eval_line: r#"eval "$(dodot init-sh)""#.into(),
            ..Default::default()
        };

        for (name, _) in TUTORIAL_STEP_TEMPLATES {
            let out = render_tutorial_step(name, &ctx, OutputMode::Text)
                .unwrap_or_else(|e| panic!("template {name} failed: {e}"));
            assert!(!out.is_empty(), "template {name} produced empty output");
        }
    }

    #[test]
    fn json_mode_produces_json() {
        use serde::Serialize;

        #[derive(Serialize)]
        struct Data {
            name: String,
        }

        let data = Data {
            name: "test".into(),
        };

        let output = render("list", &data, OutputMode::Json).unwrap();
        assert!(output.contains("\"name\""));
        assert!(output.contains("\"test\""));
    }
}