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