Skip to main content

dodot_lib/handlers/
mod.rs

1//! Handler trait, types, and implementations.
2//!
3//! Handlers are the bridge between file matching (rules) and execution
4//! (operations). Each handler knows how to transform a set of matched
5//! files into [`HandlerIntent`]s that the executor will carry out.
6//!
7//! Handlers are intent planners. They may perform **read-only**
8//! filesystem inspection (e.g. the symlink handler reads a matched
9//! directory's contents to decide between wholesale and per-file
10//! linking) but must not mutate anything — mutations are the executor's
11//! job. This keeps planning idempotent and safe to re-run.
12
13pub mod homebrew;
14pub mod install;
15pub mod path;
16pub mod shell;
17pub mod symlink;
18
19use std::collections::HashMap;
20use std::path::Path;
21
22use serde::Serialize;
23
24use crate::datastore::DataStore;
25use crate::fs::Fs;
26use crate::operations::HandlerIntent;
27use crate::paths::Pather;
28use crate::rules::RuleMatch;
29use crate::Result;
30
31/// Whether a handler manages configuration or executes code.
32///
33/// Configuration handlers (symlink, shell, path) are safe to run
34/// repeatedly. Code execution handlers (install, homebrew) run once
35/// and are tracked by sentinels.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
37pub enum HandlerCategory {
38    Configuration,
39    CodeExecution,
40}
41
42/// Whether a handler matches specific names or acts as a catchall.
43///
44/// [`MatchMode::Precise`] handlers only match whitelisted patterns
45/// (e.g. `bin/`, `install.sh`). [`MatchMode::Catchall`] handlers
46/// match anything not already claimed and must run after all precise
47/// handlers.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum MatchMode {
50    Precise,
51    Catchall,
52}
53
54/// Whether a handler's match is consumed or leaves the entry available.
55///
56/// [`HandlerScope::Exclusive`] handlers consume their matches — once
57/// claimed, no other handler sees the entry. [`HandlerScope::Shared`]
58/// handlers let other handlers also process the same entry (future
59/// use-cases like audit/indexing). At most one `Exclusive` + `Catchall`
60/// handler may exist in a registry; this is validated at build time.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum HandlerScope {
63    Exclusive,
64    Shared,
65}
66
67/// The status of a handler's operations for a single file.
68#[derive(Debug, Clone, Serialize)]
69pub struct HandlerStatus {
70    /// Which file this status is for (relative to pack root).
71    pub file: String,
72    /// Which handler produced this status.
73    pub handler: String,
74    /// Whether the file is currently deployed.
75    pub deployed: bool,
76    /// Human-readable status message (e.g. "linked to ~/.vimrc").
77    pub message: String,
78}
79
80/// The core handler abstraction.
81///
82/// Each handler is a small struct (often zero-sized) that implements
83/// this trait. Handlers are stored in a `HashMap<String, Box<dyn Handler>>`
84/// registry and dispatched by name at runtime.
85///
86/// # Object safety
87///
88/// This trait is designed to be used as `&dyn Handler` and
89/// `Box<dyn Handler>`. All methods use `&self` and return owned types.
90pub trait Handler: Send + Sync {
91    /// Unique name for this handler (e.g. `"symlink"`, `"install"`).
92    fn name(&self) -> &str;
93
94    /// Whether this is a configuration or code-execution handler.
95    fn category(&self) -> HandlerCategory;
96
97    /// How this handler decides what to claim.
98    ///
99    /// Defaults to [`MatchMode::Precise`]. Override to `Catchall` for
100    /// a fallback handler (like symlink) that takes anything not
101    /// already claimed.
102    fn match_mode(&self) -> MatchMode {
103        MatchMode::Precise
104    }
105
106    /// Whether a match removes the entry from further consideration.
107    ///
108    /// Defaults to [`HandlerScope::Exclusive`] — a matched entry is
109    /// consumed and no other handler will see it.
110    fn scope(&self) -> HandlerScope {
111        HandlerScope::Exclusive
112    }
113
114    /// Transform matched files into intents.
115    ///
116    /// This is the heart of each handler: it declares what operations
117    /// are needed. `fs` is available for **read-only** inspection
118    /// (e.g. enumerating a matched directory to decide wholesale vs
119    /// per-file linking). Handlers must not write, delete, or rename
120    /// anything here — mutations happen in the executor.
121    fn to_intents(
122        &self,
123        matches: &[RuleMatch],
124        config: &HandlerConfig,
125        paths: &dyn Pather,
126        fs: &dyn Fs,
127    ) -> Result<Vec<HandlerIntent>>;
128
129    /// Check whether a file has been deployed by this handler.
130    fn check_status(
131        &self,
132        file: &Path,
133        pack: &str,
134        datastore: &dyn DataStore,
135    ) -> Result<HandlerStatus>;
136}
137
138/// Configuration subset relevant to handlers.
139///
140/// Populated from `DodotConfig::to_handler_config()`. Carries exactly
141/// what handlers need without coupling them to the full config.
142#[derive(Debug, Clone, Serialize)]
143pub struct HandlerConfig {
144    /// Paths that must be forced to `$HOME` (e.g. `["ssh", "bashrc"]`).
145    pub force_home: Vec<String>,
146    /// Paths that must not be symlinked (e.g. `[".ssh/id_rsa"]`).
147    pub protected_paths: Vec<String>,
148    /// Per-file custom symlink target overrides.
149    /// Key = relative path in pack, Value = target path.
150    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
151    pub targets: std::collections::HashMap<String, String>,
152    /// Whether to auto-`chmod +x` files in path-handler directories.
153    /// See [`PathSection::auto_chmod_exec`](crate::config::PathSection::auto_chmod_exec).
154    pub auto_chmod_exec: bool,
155    /// Pack-level ignore patterns (from `[pack] ignore`). Handlers that
156    /// recurse into a matched directory should apply these so the
157    /// per-file fallback doesn't pick up `.DS_Store`, `.git`, etc.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub pack_ignore: Vec<String>,
160}
161
162impl Default for HandlerConfig {
163    fn default() -> Self {
164        Self {
165            force_home: Vec::new(),
166            protected_paths: Vec::new(),
167            targets: std::collections::HashMap::new(),
168            auto_chmod_exec: true,
169            pack_ignore: Vec::new(),
170        }
171    }
172}
173
174/// Well-known handler names.
175pub const HANDLER_SYMLINK: &str = "symlink";
176pub const HANDLER_SHELL: &str = "shell";
177pub const HANDLER_PATH: &str = "path";
178pub const HANDLER_INSTALL: &str = "install";
179pub const HANDLER_HOMEBREW: &str = "homebrew";
180
181/// Create the default handler registry.
182///
183/// Returns a map from handler name to handler instance. The `fs`
184/// reference is needed by install and homebrew handlers for checksum
185/// computation.
186pub fn create_registry(fs: &dyn Fs) -> HashMap<String, Box<dyn Handler + '_>> {
187    let mut registry: HashMap<String, Box<dyn Handler>> = HashMap::new();
188    registry.insert(HANDLER_SYMLINK.into(), Box::new(symlink::SymlinkHandler));
189    registry.insert(HANDLER_SHELL.into(), Box::new(shell::ShellHandler));
190    registry.insert(HANDLER_PATH.into(), Box::new(path::PathHandler));
191    registry.insert(
192        HANDLER_INSTALL.into(),
193        Box::new(install::InstallHandler::new(fs)),
194    );
195    registry.insert(
196        HANDLER_HOMEBREW.into(),
197        Box::new(homebrew::HomebrewHandler::new(fs)),
198    );
199    validate_registry(&registry);
200    registry
201}
202
203/// Enforce registry invariants.
204///
205/// At most one handler may be simultaneously [`MatchMode::Catchall`]
206/// and [`HandlerScope::Exclusive`]. Two such handlers would fight over
207/// the same "leftover" entries with no principled way to pick a winner.
208///
209/// This is a developer-only invariant: the built-in registry is
210/// hard-coded and third-party handlers would be added via code, not
211/// user input. We use `debug_assert!` so release builds never panic
212/// from a misconfiguration here; if the invariant is violated in a
213/// dev build the panic surfaces immediately.
214fn validate_registry(registry: &HashMap<String, Box<dyn Handler + '_>>) {
215    let exclusive_catchalls: Vec<&str> = registry
216        .values()
217        .filter(|h| h.match_mode() == MatchMode::Catchall && h.scope() == HandlerScope::Exclusive)
218        .map(|h| h.name())
219        .collect();
220    debug_assert!(
221        exclusive_catchalls.len() <= 1,
222        "at most one exclusive catchall handler allowed, found: {exclusive_catchalls:?}"
223    );
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    // Compile-time check: Handler must be object-safe
231    #[allow(dead_code)]
232    fn assert_object_safe(_: &dyn Handler) {}
233
234    #[allow(dead_code)]
235    fn assert_boxable(_: Box<dyn Handler>) {}
236
237    #[test]
238    fn handler_category_eq() {
239        assert_eq!(
240            HandlerCategory::Configuration,
241            HandlerCategory::Configuration
242        );
243        assert_ne!(
244            HandlerCategory::Configuration,
245            HandlerCategory::CodeExecution
246        );
247    }
248
249    #[test]
250    fn handler_status_serializes() {
251        let status = HandlerStatus {
252            file: "vimrc".into(),
253            handler: "symlink".into(),
254            deployed: true,
255            message: "linked to ~/.vimrc".into(),
256        };
257        let json = serde_json::to_string(&status).unwrap();
258        assert!(json.contains("deployed"));
259        assert!(json.contains("linked to ~/.vimrc"));
260    }
261
262    #[test]
263    fn handler_config_default() {
264        let config = HandlerConfig::default();
265        assert!(config.force_home.is_empty());
266        assert!(config.protected_paths.is_empty());
267    }
268
269    #[test]
270    fn default_registry_has_exactly_one_exclusive_catchall() {
271        let fs = crate::fs::OsFs::new();
272        let registry = create_registry(&fs);
273        let exclusive_catchalls: Vec<&str> = registry
274            .values()
275            .filter(|h| {
276                h.match_mode() == MatchMode::Catchall && h.scope() == HandlerScope::Exclusive
277            })
278            .map(|h| h.name())
279            .collect();
280        assert_eq!(exclusive_catchalls, vec!["symlink"]);
281    }
282
283    #[test]
284    #[should_panic(expected = "at most one exclusive catchall handler")]
285    fn two_exclusive_catchalls_panic() {
286        struct FakeCatchall;
287        impl Handler for FakeCatchall {
288            fn name(&self) -> &str {
289                "fake"
290            }
291            fn category(&self) -> HandlerCategory {
292                HandlerCategory::Configuration
293            }
294            fn match_mode(&self) -> MatchMode {
295                MatchMode::Catchall
296            }
297            fn scope(&self) -> HandlerScope {
298                HandlerScope::Exclusive
299            }
300            fn to_intents(
301                &self,
302                _matches: &[RuleMatch],
303                _config: &HandlerConfig,
304                _paths: &dyn Pather,
305                _fs: &dyn Fs,
306            ) -> Result<Vec<HandlerIntent>> {
307                Ok(Vec::new())
308            }
309            fn check_status(
310                &self,
311                _file: &Path,
312                _pack: &str,
313                _datastore: &dyn DataStore,
314            ) -> Result<HandlerStatus> {
315                unreachable!()
316            }
317        }
318        let fs = crate::fs::OsFs::new();
319        let mut registry = create_registry(&fs);
320        registry.insert("fake".into(), Box::new(FakeCatchall));
321        validate_registry(&registry);
322    }
323}