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