Skip to main content

dodot_lib/commands/
mod.rs

1//! Public command API — the entry points for all dodot operations.
2//!
3//! Each function returns a `Result<T>` where `T: Serialize`. These
4//! types are the contract with standout's rendering layer — they
5//! carry everything needed to produce both human-readable (template)
6//! and machine-readable (JSON) output.
7
8pub mod addignore;
9pub mod adopt;
10pub mod down;
11pub mod fill;
12pub mod git_alias;
13pub mod git_filters;
14pub mod init;
15pub mod list;
16pub mod probe;
17pub mod prompts;
18pub mod refresh;
19pub mod secret;
20pub mod status;
21pub mod template_clean;
22pub mod template_install_filter;
23pub mod transform;
24pub mod tutorial;
25pub mod up;
26
27#[cfg(test)]
28mod tests;
29
30use serde::Serialize;
31
32/// Shared `{message, details}` result for commands whose output is a
33/// short headline plus a list of secondary lines. Renders through the
34/// `message` template. Per-command result types (e.g. `InitResult`,
35/// `AddIgnoreResult`) exist for backwards compatibility and where
36/// commands grow additional fields; use [`MessageResult`] for new
37/// commands that need only headline + lines.
38#[derive(Debug, Clone, Serialize)]
39pub struct MessageResult {
40    pub message: String,
41    pub details: Vec<String>,
42}
43
44// ── Shared display types ────────────────────────────────────────
45
46/// Handler symbols matching the Go implementation.
47pub fn handler_symbol(handler: &str) -> &'static str {
48    match handler {
49        "symlink" => "➞",
50        "shell" => "⚙",
51        "path" => "+",
52        "homebrew" => "⚙",
53        "install" => "×",
54        _ => "?",
55    }
56}
57
58/// Status string for standout template tag matching (maps to theme style names).
59pub fn status_style(deployed: bool) -> &'static str {
60    if deployed {
61        "deployed"
62    } else {
63        "pending"
64    }
65}
66
67/// Human-readable handler description for a file.
68pub fn handler_description(handler: &str, rel_path: &str, user_target: Option<&str>) -> String {
69    match handler {
70        "symlink" => {
71            // Callers normally pass a fully-resolved user_target (computed
72            // by `resolve_target` with the pack name in scope). The
73            // pack-namespaced XDG default cannot be reconstructed from
74            // `rel_path` alone, so when no target is provided we fall
75            // back to a generic "<symlink>" placeholder rather than
76            // guessing a wrong `~/.<name>` path.
77            user_target
78                .map(str::to_string)
79                .unwrap_or_else(|| "<symlink>".to_string())
80        }
81        "shell" => "shell profile".into(),
82        "path" => format!("$PATH/{rel_path}"),
83        "install" => "run script".into(),
84        "homebrew" => "brew install".into(),
85        _ => String::new(),
86    }
87}
88
89/// A file entry for pack status display.
90#[derive(Debug, Clone, Serialize)]
91pub struct DisplayFile {
92    pub name: String,
93    pub symbol: String,
94    pub description: String,
95    pub status: String,
96    pub status_label: String,
97    pub handler: String,
98    /// 1-based index into `PackStatusResult.notes`. `Some(N)` means the
99    /// row has a command-wide error/note attached; the template renders
100    /// `[N]` next to the status label and the body appears in the notes
101    /// section at the bottom of the output. Indices are assigned at
102    /// assembly time and are stable within a single command invocation.
103    #[serde(skip_serializing_if = "Option::is_none", default)]
104    pub note_ref: Option<u32>,
105}
106
107/// A pack entry for status display.
108#[derive(Debug, Clone, Serialize)]
109pub struct DisplayPack {
110    pub name: String,
111    pub files: Vec<DisplayFile>,
112    /// Aggregated pack-level status, one of `"error"`, `"pending"`,
113    /// `"deployed"`. Rollup rules: `error` ← any file with `error` or
114    /// `broken`; otherwise `pending` ← any file with `pending`,
115    /// `warning`, or `stale`; otherwise `deployed`. Always populated so
116    /// JSON consumers and short-mode templates can use it uniformly.
117    pub summary_status: String,
118    /// Number of files in the pack whose status rolls up to
119    /// `summary_status`. Displayed as `(N)` in short-mode output.
120    pub summary_count: usize,
121}
122
123impl DisplayPack {
124    /// Compute aggregated status and count from the pack's files.
125    pub fn new(name: String, files: Vec<DisplayFile>) -> Self {
126        let (summary_status, summary_count) = aggregate_status(&files);
127        DisplayPack {
128            name,
129            files,
130            summary_status,
131            summary_count,
132        }
133    }
134
135    /// Recompute `summary_status` / `summary_count` after mutating
136    /// `files` (e.g. after `overlay_errors` flips a row to `error` or
137    /// adopt failures synthesize new rows).
138    pub fn recompute_summary(&mut self) {
139        let (status, count) = aggregate_status(&self.files);
140        self.summary_status = status;
141        self.summary_count = count;
142    }
143}
144
145/// Roll up per-file statuses into one of `error`, `pending`, `deployed`
146/// (precedence: error > pending > deployed). Returns the bucket name
147/// and the number of files that fall into it.
148fn aggregate_status(files: &[DisplayFile]) -> (String, usize) {
149    let mut errors = 0usize;
150    let mut pendings = 0usize;
151    let mut deployeds = 0usize;
152    for f in files {
153        match f.status.as_str() {
154            "error" | "broken" => errors += 1,
155            "pending" | "warning" | "stale" => pendings += 1,
156            "deployed" => deployeds += 1,
157            _ => {}
158        }
159    }
160    if errors > 0 {
161        ("error".into(), errors)
162    } else if pendings > 0 {
163        ("pending".into(), pendings)
164    } else {
165        ("deployed".into(), deployeds)
166    }
167}
168
169/// A command-wide note (error / inline conflict) referenced by
170/// `DisplayFile.note_ref`. Indices into `PackStatusResult.notes` are
171/// 1-based; position in the vec matches the `[N]` shown inline.
172#[derive(Debug, Clone, Serialize)]
173pub struct DisplayNote {
174    pub body: String,
175    #[serde(skip_serializing_if = "Option::is_none", default)]
176    pub hint: Option<String>,
177}
178
179/// One claimant of a cross-pack conflict, formatted for display.
180#[derive(Debug, Clone, Serialize)]
181pub struct DisplayClaimant {
182    /// Pack name.
183    pub pack: String,
184    /// Short, pack-relative source description (e.g. `git/env.sh`).
185    pub source: String,
186}
187
188/// A single cross-pack conflict, flattened for template rendering.
189#[derive(Debug, Clone, Serialize)]
190pub struct DisplayConflict {
191    /// Conflict kind. Serializes as `"symlink"` or `"path"` so the
192    /// template can branch on it.
193    pub kind: String,
194    /// Human-readable target (path for symlink, executable name for path).
195    pub target: String,
196    pub claimants: Vec<DisplayClaimant>,
197}
198
199impl DisplayConflict {
200    /// Convert a detection-layer conflict into its display form,
201    /// shortening paths relative to `home` when possible.
202    pub fn from_conflict(c: &crate::conflicts::Conflict, home: &std::path::Path) -> Self {
203        let kind = match c.kind {
204            crate::conflicts::ConflictKind::SymlinkTarget => "symlink",
205            crate::conflicts::ConflictKind::PathExecutable => "path",
206        };
207        let target = match c.kind {
208            crate::conflicts::ConflictKind::SymlinkTarget => shorten_path(&c.target, home),
209            crate::conflicts::ConflictKind::PathExecutable => c
210                .target
211                .file_name()
212                .map(|n| n.to_string_lossy().into_owned())
213                .unwrap_or_else(|| c.target.display().to_string()),
214        };
215        let claimants = c
216            .claimants
217            .iter()
218            .map(|cl| DisplayClaimant {
219                pack: cl.pack.clone(),
220                source: pack_relative_source(&cl.source, &cl.pack),
221            })
222            .collect();
223        DisplayConflict {
224            kind: kind.into(),
225            target,
226            claimants,
227        }
228    }
229}
230
231fn shorten_path(p: &std::path::Path, home: &std::path::Path) -> String {
232    if let Ok(rel) = p.strip_prefix(home) {
233        format!("~/{}", rel.display())
234    } else {
235        p.display().to_string()
236    }
237}
238
239/// Render a claimant source as `<pack>/<relative-path>` when possible,
240/// falling back to just the filename.
241fn pack_relative_source(source: &std::path::Path, pack: &str) -> String {
242    let s = source.to_string_lossy();
243    let marker = format!("/{pack}/");
244    if let Some(idx) = s.rfind(&marker) {
245        let rel = &s[idx + 1..];
246        return rel.to_string();
247    }
248    // Fallback: pack/filename
249    let fname = source
250        .file_name()
251        .map(|n| n.to_string_lossy().into_owned())
252        .unwrap_or_default();
253    format!("{pack}/{fname}")
254}
255
256/// Result type for commands that display pack status
257/// (status, up, down).
258#[derive(Debug, Clone, Serialize)]
259pub struct PackStatusResult {
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub message: Option<String>,
262    pub dry_run: bool,
263    pub packs: Vec<DisplayPack>,
264    /// Informational command-level messages not attached to any row
265    /// (e.g. "pack X is ignored, skipping"). Real errors belong in
266    /// `notes` so they can be referenced from an item row.
267    #[serde(skip_serializing_if = "Vec::is_empty")]
268    pub warnings: Vec<String>,
269    /// Command-wide error/note list. Each entry is referenced by a
270    /// `DisplayFile.note_ref` (1-based). Rendered at the end of the
271    /// output so per-item rows stay single-line and column-aligned.
272    #[serde(skip_serializing_if = "Vec::is_empty")]
273    pub notes: Vec<DisplayNote>,
274    /// Cross-pack conflicts to display at the end of the output.
275    #[serde(skip_serializing_if = "Vec::is_empty")]
276    pub conflicts: Vec<DisplayConflict>,
277    /// Names of pack-shaped directories skipped because they carry a
278    /// `.dodotignore` marker. Surfaced by `status` so users aren't
279    /// baffled when a directory they expected doesn't appear.
280    #[serde(skip_serializing_if = "Vec::is_empty")]
281    pub ignored_packs: Vec<String>,
282    /// `"full"` (default) shows per-file listing; `"short"` collapses
283    /// each pack to a single summary line.
284    pub view_mode: String,
285    /// `"name"` (default) lists packs in their discovery order;
286    /// `"status"` groups packs under Ignored / Deployed / Pending /
287    /// Error banners.
288    pub group_mode: String,
289}
290
291/// View style for pack-status output.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
293pub enum ViewMode {
294    #[default]
295    Full,
296    Short,
297}
298
299impl ViewMode {
300    pub fn as_str(self) -> &'static str {
301        match self {
302            ViewMode::Full => "full",
303            ViewMode::Short => "short",
304        }
305    }
306}
307
308/// Grouping style for pack-status output.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
310pub enum GroupMode {
311    #[default]
312    Name,
313    Status,
314}
315
316impl GroupMode {
317    pub fn as_str(self) -> &'static str {
318        match self {
319            GroupMode::Name => "name",
320            GroupMode::Status => "status",
321        }
322    }
323}