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}
77
78/// A pack entry for status display.
79#[derive(Debug, Clone, Serialize)]
80pub struct DisplayPack {
81    pub name: String,
82    pub files: Vec<DisplayFile>,
83    /// Per-pack footnotes referenced by `(N)` markers in `status_label`.
84    /// Each entry is the body text only (no leading `(N)`); the renderer
85    /// pairs them with the marker number assigned at assembly time.
86    #[serde(skip_serializing_if = "Vec::is_empty", default)]
87    pub footnotes: Vec<String>,
88}
89
90/// One claimant of a cross-pack conflict, formatted for display.
91#[derive(Debug, Clone, Serialize)]
92pub struct DisplayClaimant {
93    /// Pack name.
94    pub pack: String,
95    /// Short, pack-relative source description (e.g. `git/env.sh`).
96    pub source: String,
97}
98
99/// A single cross-pack conflict, flattened for template rendering.
100#[derive(Debug, Clone, Serialize)]
101pub struct DisplayConflict {
102    /// Conflict kind. Serializes as `"symlink"` or `"path"` so the
103    /// template can branch on it.
104    pub kind: String,
105    /// Human-readable target (path for symlink, executable name for path).
106    pub target: String,
107    pub claimants: Vec<DisplayClaimant>,
108}
109
110impl DisplayConflict {
111    /// Convert a detection-layer conflict into its display form,
112    /// shortening paths relative to `home` when possible.
113    pub fn from_conflict(c: &crate::conflicts::Conflict, home: &std::path::Path) -> Self {
114        let kind = match c.kind {
115            crate::conflicts::ConflictKind::SymlinkTarget => "symlink",
116            crate::conflicts::ConflictKind::PathExecutable => "path",
117        };
118        let target = match c.kind {
119            crate::conflicts::ConflictKind::SymlinkTarget => shorten_path(&c.target, home),
120            crate::conflicts::ConflictKind::PathExecutable => c
121                .target
122                .file_name()
123                .map(|n| n.to_string_lossy().into_owned())
124                .unwrap_or_else(|| c.target.display().to_string()),
125        };
126        let claimants = c
127            .claimants
128            .iter()
129            .map(|cl| DisplayClaimant {
130                pack: cl.pack.clone(),
131                source: pack_relative_source(&cl.source, &cl.pack),
132            })
133            .collect();
134        DisplayConflict {
135            kind: kind.into(),
136            target,
137            claimants,
138        }
139    }
140}
141
142fn shorten_path(p: &std::path::Path, home: &std::path::Path) -> String {
143    if let Ok(rel) = p.strip_prefix(home) {
144        format!("~/{}", rel.display())
145    } else {
146        p.display().to_string()
147    }
148}
149
150/// Render a claimant source as `<pack>/<relative-path>` when possible,
151/// falling back to just the filename.
152fn pack_relative_source(source: &std::path::Path, pack: &str) -> String {
153    let s = source.to_string_lossy();
154    let marker = format!("/{pack}/");
155    if let Some(idx) = s.rfind(&marker) {
156        let rel = &s[idx + 1..];
157        return rel.to_string();
158    }
159    // Fallback: pack/filename
160    let fname = source
161        .file_name()
162        .map(|n| n.to_string_lossy().into_owned())
163        .unwrap_or_default();
164    format!("{pack}/{fname}")
165}
166
167/// Result type for commands that display pack status
168/// (status, up, down).
169#[derive(Debug, Clone, Serialize)]
170pub struct PackStatusResult {
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub message: Option<String>,
173    pub dry_run: bool,
174    pub packs: Vec<DisplayPack>,
175    #[serde(skip_serializing_if = "Vec::is_empty")]
176    pub warnings: Vec<String>,
177    /// Cross-pack conflicts to display at the end of the output.
178    #[serde(skip_serializing_if = "Vec::is_empty")]
179    pub conflicts: Vec<DisplayConflict>,
180    /// Names of pack-shaped directories skipped because they carry a
181    /// `.dodotignore` marker. Surfaced by `status` so users aren't
182    /// baffled when a directory they expected doesn't appear.
183    #[serde(skip_serializing_if = "Vec::is_empty")]
184    pub ignored_packs: Vec<String>,
185}