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