dodot-lib 5.5.0

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
//! Public command API — the entry points for all dodot operations.
//!
//! Each function returns a `Result<T>` where `T: Serialize`. These
//! types are the contract with standout's rendering layer — they
//! carry everything needed to produce both human-readable (template)
//! and machine-readable (JSON) output.

pub mod addignore;
pub mod adopt;
pub mod down;
pub mod fill;
pub mod git_alias;
pub mod git_filters;
pub mod init;
pub mod install;
pub mod list;
pub mod probe;
pub mod prompts;
pub mod refresh;
pub mod reset;
pub mod secret;
pub mod status;
pub mod template_clean;
pub mod template_install_filter;
pub mod transform;
pub mod tutorial;
pub mod up;

#[cfg(test)]
mod tests;

use serde::Serialize;

/// Shared `{message, details}` result for commands whose output is a
/// short headline plus a list of secondary lines. Renders through the
/// `message` template. Per-command result types (e.g. `InitResult`,
/// `AddIgnoreResult`) exist for backwards compatibility and where
/// commands grow additional fields; use [`MessageResult`] for new
/// commands that need only headline + lines.
#[derive(Debug, Clone, Serialize)]
pub struct MessageResult {
    pub message: String,
    pub details: Vec<String>,
}

// ── Shared display types ────────────────────────────────────────

/// Handler symbols matching the Go implementation.
pub fn handler_symbol(handler: &str) -> &'static str {
    match handler {
        "symlink" => "",
        "shell" => "",
        "path" => "+",
        "homebrew" => "",
        "install" => "×",
        "nix" => "",
        "skip" => "·",
        "gate" => "·",
        _ => "?",
    }
}

/// Status string for standout template tag matching (maps to theme style names).
pub fn status_style(deployed: bool) -> &'static str {
    if deployed {
        "deployed"
    } else {
        "pending"
    }
}

/// Human-readable handler description for a file.
pub fn handler_description(handler: &str, rel_path: &str, user_target: Option<&str>) -> String {
    match handler {
        "symlink" => {
            // Callers normally pass a fully-resolved user_target (computed
            // by `resolve_target` with the pack name in scope). The
            // pack-namespaced XDG default cannot be reconstructed from
            // `rel_path` alone, so when no target is provided we fall
            // back to a generic "<symlink>" placeholder rather than
            // guessing a wrong `~/.<name>` path.
            user_target
                .map(str::to_string)
                .unwrap_or_else(|| "<symlink>".to_string())
        }
        "shell" => "shell profile".into(),
        "path" => format!("$PATH/{rel_path}"),
        "install" => "run script".into(),
        "homebrew" => "brew install".into(),
        "nix" => "nix profile install".into(),
        "skip" => "not deployed".into(),
        "gate" => "not deployed".into(),
        _ => String::new(),
    }
}

/// A file entry for pack status display.
#[derive(Debug, Clone, Serialize)]
pub struct DisplayFile {
    pub name: String,
    pub symbol: String,
    pub description: String,
    pub status: String,
    pub status_label: String,
    pub handler: String,
    /// 1-based index into `PackStatusResult.notes`. `Some(N)` means the
    /// row has a command-wide error/note attached; the template renders
    /// `[N]` next to the status label and the body appears in the notes
    /// section at the bottom of the output. Indices are assigned at
    /// assembly time and are stable within a single command invocation.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub note_ref: Option<u32>,
}

/// A pack entry for status display.
#[derive(Debug, Clone, Serialize)]
pub struct DisplayPack {
    pub name: String,
    pub files: Vec<DisplayFile>,
    /// Aggregated pack-level status, one of `"error"`, `"pending"`,
    /// `"deployed"`. Rollup rules: `error` ← any file with `error` or
    /// `broken`; otherwise `pending` ← any file with `pending`,
    /// `warning`, or `stale`; otherwise `deployed`. Always populated so
    /// JSON consumers and short-mode templates can use it uniformly.
    pub summary_status: String,
    /// Number of files in the pack whose status rolls up to
    /// `summary_status`. Displayed as `(N)` in short-mode output.
    pub summary_count: usize,
}

impl DisplayPack {
    /// Compute aggregated status and count from the pack's files.
    pub fn new(name: String, files: Vec<DisplayFile>) -> Self {
        let (summary_status, summary_count) = aggregate_status(&files);
        DisplayPack {
            name,
            files,
            summary_status,
            summary_count,
        }
    }

    /// Recompute `summary_status` / `summary_count` after mutating
    /// `files` (e.g. after `overlay_errors` flips a row to `error` or
    /// adopt failures synthesize new rows).
    pub fn recompute_summary(&mut self) {
        let (status, count) = aggregate_status(&self.files);
        self.summary_status = status;
        self.summary_count = count;
    }
}

/// Roll up per-file statuses into one of `error`, `pending`, `deployed`
/// (precedence: error > pending > deployed). Returns the bucket name
/// and the number of files that fall into it.
fn aggregate_status(files: &[DisplayFile]) -> (String, usize) {
    let mut errors = 0usize;
    let mut pendings = 0usize;
    let mut deployeds = 0usize;
    for f in files {
        match f.status.as_str() {
            "error" | "broken" => errors += 1,
            "pending" | "warning" | "stale" => pendings += 1,
            "deployed" => deployeds += 1,
            _ => {}
        }
    }
    if errors > 0 {
        ("error".into(), errors)
    } else if pendings > 0 {
        ("pending".into(), pendings)
    } else {
        ("deployed".into(), deployeds)
    }
}

/// A command-wide note (error / warning) referenced by
/// `DisplayFile.note_ref`. Indices into `PackStatusResult.notes` are
/// 1-based; position in the vec matches the `[N]` shown inline.
#[derive(Debug, Clone, Serialize)]
pub struct DisplayNote {
    pub body: String,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub hint: Option<String>,
    /// Note severity: `"error"` (hard failure — renders under
    /// `Errors:` with a dim `[N]` marker) or `"warning"`
    /// (informational instability — renders under `Warnings:` with a
    /// warning-styled marker). A warning must never present as a
    /// current error: it doesn't change the row verdict it annotates.
    pub kind: String,
    /// Shell-init run timeline for runtime-history warnings: one
    /// entry per applicable run, oldest→newest, `true` = exited zero.
    /// The template renders it as ✓/✗ symbols (deployed/error styles)
    /// ahead of the note body. `None` for plain notes.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub timeline: Option<Vec<bool>>,
    /// Follow-up command rendered in normal emphasis inside the
    /// warning prose (e.g. `dodot probe shell-init vim/aliases.sh`).
    /// `None` for plain notes.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub command: Option<String>,
}

impl DisplayNote {
    /// Plain error-kind note — the shape every hard-failure footnote
    /// uses. Warning notes are built field-by-field at their one
    /// construction site (`status::Health::footnote`).
    pub fn error(body: String) -> Self {
        DisplayNote {
            body,
            hint: None,
            kind: "error".into(),
            timeline: None,
            command: None,
        }
    }
}

/// One claimant of a cross-pack conflict, formatted for display.
#[derive(Debug, Clone, Serialize)]
pub struct DisplayClaimant {
    pub pack: String,
    /// Short, pack-relative source description (e.g. `git/env.sh`).
    pub source: String,
}

/// A single cross-pack conflict, flattened for template rendering.
#[derive(Debug, Clone, Serialize)]
pub struct DisplayConflict {
    /// Conflict kind. Serializes as `"symlink"` or `"path"` so the
    /// template can branch on it.
    pub kind: String,
    /// Human-readable target (path for symlink, executable name for path).
    pub target: String,
    pub claimants: Vec<DisplayClaimant>,
}

impl DisplayConflict {
    /// Convert a detection-layer conflict into its display form,
    /// shortening paths relative to `home` when possible.
    pub fn from_conflict(c: &crate::conflicts::Conflict, home: &std::path::Path) -> Self {
        let kind = match c.kind {
            crate::conflicts::ConflictKind::SymlinkTarget => "symlink",
            crate::conflicts::ConflictKind::PathExecutable => "path",
        };
        let target = match c.kind {
            crate::conflicts::ConflictKind::SymlinkTarget => shorten_path(&c.target, home),
            crate::conflicts::ConflictKind::PathExecutable => c
                .target
                .file_name()
                .map(|n| n.to_string_lossy().into_owned())
                .unwrap_or_else(|| c.target.display().to_string()),
        };
        let claimants = c
            .claimants
            .iter()
            .map(|cl| DisplayClaimant {
                pack: cl.pack.clone(),
                source: pack_relative_source(&cl.source, &cl.pack),
            })
            .collect();
        DisplayConflict {
            kind: kind.into(),
            target,
            claimants,
        }
    }
}

/// Display a path as `~/...` when it lives under `home`. Shared by
/// conflict display and `reset`'s data-dir headline.
pub(crate) fn shorten_path(p: &std::path::Path, home: &std::path::Path) -> String {
    if let Ok(rel) = p.strip_prefix(home) {
        format!("~/{}", rel.display())
    } else {
        p.display().to_string()
    }
}

/// Render a claimant source as `<pack>/<relative-path>` when possible,
/// falling back to just the filename.
fn pack_relative_source(source: &std::path::Path, pack: &str) -> String {
    let s = source.to_string_lossy();
    let marker = format!("/{pack}/");
    if let Some(idx) = s.rfind(&marker) {
        let rel = &s[idx + 1..];
        return rel.to_string();
    }
    let fname = source
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    format!("{pack}/{fname}")
}

/// A unified-diff payload for a single run-once file whose recorded
/// content hash diverges from the current source. Emitted only by
/// `dodot status --diff` for run-once handlers (`install`, `homebrew`)
/// when a snapshot of the previously-run content is on disk; old
/// sentinels predating the snapshot convention have no diff and never
/// appear here.
#[derive(Debug, Clone, Serialize)]
pub struct DisplayDiff {
    /// Pack the file belongs to (display name).
    pub pack: String,
    /// File name (typically `install.sh` / `Brewfile`).
    pub file: String,
    /// Handler that owns this file (`install`, `homebrew`, …) — useful
    /// for JSON consumers that want to filter/group.
    pub handler: String,
    /// Unified-diff body, ready for direct display. Includes the
    /// `--- old` / `+++ new` headers and any `@@ … @@` hunks. May be
    /// empty if snapshot and current content are byte-identical (which
    /// is unusual but possible when only metadata changed and the
    /// diffing tool collapses).
    pub body: String,
}

/// Result type for commands that display pack status
/// (status, up, down).
#[derive(Debug, Clone, Serialize)]
pub struct PackStatusResult {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    pub dry_run: bool,
    pub packs: Vec<DisplayPack>,
    /// Informational command-level messages not attached to any row
    /// (e.g. "pack X is ignored, skipping"). Real errors belong in
    /// `notes` so they can be referenced from an item row.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub warnings: Vec<String>,
    /// Command-wide error/note list. Each entry is referenced by a
    /// `DisplayFile.note_ref` (1-based). Rendered at the end of the
    /// output so per-item rows stay single-line and column-aligned.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub notes: Vec<DisplayNote>,
    /// Cross-pack conflicts to display at the end of the output.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub conflicts: Vec<DisplayConflict>,
    /// Pack-shaped directories skipped because they carry an ignore marker,
    /// including the matching marker filename used by the shared status row.
    /// Surfaced by `status`, `up`, and `down` so users aren't baffled when a
    /// directory they expected doesn't appear.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub ignored_packs: Vec<crate::packs::IgnoredPack>,
    /// Packs gated out by `[pack] os` on the current host. Each entry
    /// is a pre-formatted display string (e.g. `"mac-tools (os=darwin,
    /// current=linux)"`) so the template renders them under their own
    /// heading without needing a typed structure. See
    /// `docs/proposals/conditional-running.lex` §5.3.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub inactive_packs: Vec<String>,
    /// `"full"` (default) shows per-file listing; `"short"` collapses
    /// each pack to a single summary line.
    pub view_mode: String,
    /// `"name"` (default) lists packs in their discovery order;
    /// `"status"` groups active packs under Deployed / Pending / Error
    /// banners. Ignored rows follow the groups after a blank separator.
    pub group_mode: String,
    /// Unified diffs for run-once files whose recorded content hash
    /// no longer matches the source on disk. Populated only when the
    /// caller passes `--diff` to `dodot status` and the relevant
    /// sentinel has a `<sentinel>.snapshot` sibling on disk. Empty in
    /// every other case (including the common `--diff` invocation
    /// against packs with no `ran older version` entries).
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub diffs: Vec<DisplayDiff>,
    /// Shell-hookup activation state, when there is something to say
    /// about it: the prominent "no shell has loaded dodot yet" banner,
    /// the "open a new shell" hint, or (in `status` only) the quiet
    /// "hookup: ok" line. `None` means silence — deployment output is
    /// not the place to celebrate a working hookup. See
    /// `docs/proposals/shipped/shell-hookup.lex` §5.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub shell_hookup: Option<crate::shell::ActivationNotice>,
}

/// View style for pack-status output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewMode {
    #[default]
    Full,
    Short,
}

impl ViewMode {
    pub fn as_str(self) -> &'static str {
        match self {
            ViewMode::Full => "full",
            ViewMode::Short => "short",
        }
    }
}

/// Grouping style for pack-status output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GroupMode {
    #[default]
    Name,
    Status,
}

impl GroupMode {
    pub fn as_str(self) -> &'static str {
        match self {
            GroupMode::Name => "name",
            GroupMode::Status => "status",
        }
    }
}