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