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