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/// The ordered execution phase a handler belongs to.
43///
44/// Phases run in declaration order — the derived [`Ord`] drives
45/// [`crate::rules::handler_execution_order`]. Each handler belongs to
46/// exactly one phase, so adding a handler is a deliberate design
47/// choice: *where does this fit in the pipeline?* rather than a silent
48/// answer from alphabetical sort.
49///
50/// # Why this order
51///
52/// - [`Provision`](Self::Provision) installs packages. Anything later
53///   (including user `install.sh` scripts) may depend on the tools it
54///   put on PATH.
55/// - [`Setup`](Self::Setup) runs user-authored setup scripts that can
56///   lean on Provision having completed (`brew` and formulas available).
57/// - [`PathExport`](Self::PathExport) stages `bin/` directories onto
58///   `$PATH`. It runs before [`ShellInit`](Self::ShellInit) so shell
59///   init scripts can reference the executables it exposes.
60/// - [`ShellInit`](Self::ShellInit) registers shell startup files.
61/// - [`Link`](Self::Link) is the catchall symlink phase. It runs last
62///   because the symlink handler is catchall — precise handlers above
63///   must have already claimed their files.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
65pub enum ExecutionPhase {
66    /// Install packages (homebrew).
67    Provision,
68    /// Run user setup scripts (install).
69    Setup,
70    /// Stage directories onto `$PATH` (path).
71    PathExport,
72    /// Register shell init files (shell).
73    ShellInit,
74    /// Catchall: link remaining files (symlink). Always last.
75    Link,
76}
77
78impl ExecutionPhase {
79    /// The category this phase belongs to.
80    ///
81    /// Provision and Setup run user-authored code and are gated by
82    /// sentinels; the rest are idempotent filesystem work.
83    pub fn category(self) -> HandlerCategory {
84        match self {
85            Self::Provision | Self::Setup => HandlerCategory::CodeExecution,
86            Self::PathExport | Self::ShellInit | Self::Link => HandlerCategory::Configuration,
87        }
88    }
89}
90
91/// Whether a handler matches specific names or acts as a catchall.
92///
93/// [`MatchMode::Precise`] handlers only match whitelisted patterns
94/// (e.g. `bin/`, `install.sh`). [`MatchMode::Catchall`] handlers
95/// match anything not already claimed and must run after all precise
96/// handlers.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum MatchMode {
99    Precise,
100    Catchall,
101}
102
103/// Whether a handler's match is consumed or leaves the entry available.
104///
105/// [`HandlerScope::Exclusive`] handlers consume their matches — once
106/// claimed, no other handler sees the entry. [`HandlerScope::Shared`]
107/// handlers let other handlers also process the same entry (future
108/// use-cases like audit/indexing). At most one `Exclusive` + `Catchall`
109/// handler may exist in a registry; this is validated at build time.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum HandlerScope {
112    Exclusive,
113    Shared,
114}
115
116/// The status of a handler's operations for a single file.
117#[derive(Debug, Clone, Serialize)]
118pub struct HandlerStatus {
119    /// Which file this status is for (relative to pack root).
120    pub file: String,
121    /// Which handler produced this status.
122    pub handler: String,
123    /// Whether the file is currently deployed.
124    pub deployed: bool,
125    /// Human-readable status message (e.g. "linked to ~/.vimrc").
126    pub message: String,
127}
128
129/// The core handler abstraction.
130///
131/// Each handler is a small struct (often zero-sized) that implements
132/// this trait. Handlers are stored in a `HashMap<String, Box<dyn Handler>>`
133/// registry and dispatched by name at runtime.
134///
135/// # Object safety
136///
137/// This trait is designed to be used as `&dyn Handler` and
138/// `Box<dyn Handler>`. All methods use `&self` and return owned types.
139pub trait Handler: Send + Sync {
140    /// Unique name for this handler (e.g. `"symlink"`, `"install"`).
141    fn name(&self) -> &str;
142
143    /// Execution phase for this handler.
144    ///
145    /// Determines where this handler runs in the per-pack pipeline.
146    /// See [`ExecutionPhase`] for the ordering and the rationale for
147    /// each slot.
148    fn phase(&self) -> ExecutionPhase;
149
150    /// Whether this is a configuration or code-execution handler.
151    ///
152    /// Derived from [`Self::phase`]; override only if a handler's
153    /// phase doesn't imply its category (none today).
154    fn category(&self) -> HandlerCategory {
155        self.phase().category()
156    }
157
158    /// How this handler decides what to claim.
159    ///
160    /// Defaults to [`MatchMode::Precise`]. Override to `Catchall` for
161    /// a fallback handler (like symlink) that takes anything not
162    /// already claimed.
163    fn match_mode(&self) -> MatchMode {
164        MatchMode::Precise
165    }
166
167    /// Whether a match removes the entry from further consideration.
168    ///
169    /// Defaults to [`HandlerScope::Exclusive`] — a matched entry is
170    /// consumed and no other handler will see it.
171    fn scope(&self) -> HandlerScope {
172        HandlerScope::Exclusive
173    }
174
175    /// Transform matched files into intents.
176    ///
177    /// This is the heart of each handler: it declares what operations
178    /// are needed. `fs` is available for **read-only** inspection
179    /// (e.g. enumerating a matched directory to decide wholesale vs
180    /// per-file linking). Handlers must not write, delete, or rename
181    /// anything here — mutations happen in the executor.
182    fn to_intents(
183        &self,
184        matches: &[RuleMatch],
185        config: &HandlerConfig,
186        paths: &dyn Pather,
187        fs: &dyn Fs,
188    ) -> Result<Vec<HandlerIntent>>;
189
190    /// Check whether a file has been deployed by this handler.
191    fn check_status(
192        &self,
193        file: &Path,
194        pack: &str,
195        datastore: &dyn DataStore,
196    ) -> Result<HandlerStatus>;
197}
198
199/// Configuration subset relevant to handlers.
200///
201/// Populated from `DodotConfig::to_handler_config()`. Carries exactly
202/// what handlers need without coupling them to the full config.
203#[derive(Debug, Clone, Serialize)]
204pub struct HandlerConfig {
205    /// Paths that must be forced to `$HOME` (e.g. `["ssh", "bashrc"]`).
206    pub force_home: Vec<String>,
207    /// Paths that must not be symlinked (e.g. `[".ssh/id_rsa"]`).
208    pub protected_paths: Vec<String>,
209    /// Per-file custom symlink target overrides.
210    /// Key = relative path in pack, Value = target path.
211    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
212    pub targets: std::collections::HashMap<String, String>,
213    /// Whether to auto-`chmod +x` files in path-handler directories.
214    /// See [`PathSection::auto_chmod_exec`](crate::config::PathSection::auto_chmod_exec).
215    pub auto_chmod_exec: bool,
216    /// Pack-level ignore patterns (from `[pack] ignore`). Handlers that
217    /// recurse into a matched directory should apply these so the
218    /// per-file fallback doesn't pick up `.DS_Store`, `.git`, etc.
219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220    pub pack_ignore: Vec<String>,
221}
222
223impl Default for HandlerConfig {
224    fn default() -> Self {
225        Self {
226            force_home: Vec::new(),
227            protected_paths: Vec::new(),
228            targets: std::collections::HashMap::new(),
229            auto_chmod_exec: true,
230            pack_ignore: Vec::new(),
231        }
232    }
233}
234
235/// Well-known handler names.
236pub const HANDLER_SYMLINK: &str = "symlink";
237pub const HANDLER_SHELL: &str = "shell";
238pub const HANDLER_PATH: &str = "path";
239pub const HANDLER_INSTALL: &str = "install";
240pub const HANDLER_HOMEBREW: &str = "homebrew";
241
242/// Create the default handler registry.
243///
244/// Returns a map from handler name to handler instance. The `fs`
245/// reference is needed by install and homebrew handlers for checksum
246/// computation.
247pub fn create_registry(fs: &dyn Fs) -> HashMap<String, Box<dyn Handler + '_>> {
248    let mut registry: HashMap<String, Box<dyn Handler>> = HashMap::new();
249    registry.insert(HANDLER_SYMLINK.into(), Box::new(symlink::SymlinkHandler));
250    registry.insert(HANDLER_SHELL.into(), Box::new(shell::ShellHandler));
251    registry.insert(HANDLER_PATH.into(), Box::new(path::PathHandler));
252    registry.insert(
253        HANDLER_INSTALL.into(),
254        Box::new(install::InstallHandler::new(fs)),
255    );
256    registry.insert(
257        HANDLER_HOMEBREW.into(),
258        Box::new(homebrew::HomebrewHandler::new(fs)),
259    );
260    validate_registry(&registry);
261    registry
262}
263
264/// Enforce registry invariants.
265///
266/// At most one handler may be simultaneously [`MatchMode::Catchall`]
267/// and [`HandlerScope::Exclusive`]. Two such handlers would fight over
268/// the same "leftover" entries with no principled way to pick a winner.
269///
270/// This is a developer-only invariant: the built-in registry is
271/// hard-coded and third-party handlers would be added via code, not
272/// user input. We use `debug_assert!` so release builds never panic
273/// from a misconfiguration here; if the invariant is violated in a
274/// dev build the panic surfaces immediately.
275fn validate_registry(registry: &HashMap<String, Box<dyn Handler + '_>>) {
276    let exclusive_catchalls: Vec<&str> = registry
277        .values()
278        .filter(|h| h.match_mode() == MatchMode::Catchall && h.scope() == HandlerScope::Exclusive)
279        .map(|h| h.name())
280        .collect();
281    debug_assert!(
282        exclusive_catchalls.len() <= 1,
283        "at most one exclusive catchall handler allowed, found: {exclusive_catchalls:?}"
284    );
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    // Compile-time check: Handler must be object-safe
292    #[allow(dead_code)]
293    fn assert_object_safe(_: &dyn Handler) {}
294
295    #[allow(dead_code)]
296    fn assert_boxable(_: Box<dyn Handler>) {}
297
298    #[test]
299    fn handler_category_eq() {
300        assert_eq!(
301            HandlerCategory::Configuration,
302            HandlerCategory::Configuration
303        );
304        assert_ne!(
305            HandlerCategory::Configuration,
306            HandlerCategory::CodeExecution
307        );
308    }
309
310    #[test]
311    fn execution_phase_declaration_order_drives_ord() {
312        assert!(ExecutionPhase::Provision < ExecutionPhase::Setup);
313        assert!(ExecutionPhase::Setup < ExecutionPhase::PathExport);
314        assert!(ExecutionPhase::PathExport < ExecutionPhase::ShellInit);
315        assert!(ExecutionPhase::ShellInit < ExecutionPhase::Link);
316    }
317
318    #[test]
319    fn execution_phase_category_mapping() {
320        assert_eq!(
321            ExecutionPhase::Provision.category(),
322            HandlerCategory::CodeExecution
323        );
324        assert_eq!(
325            ExecutionPhase::Setup.category(),
326            HandlerCategory::CodeExecution
327        );
328        assert_eq!(
329            ExecutionPhase::PathExport.category(),
330            HandlerCategory::Configuration
331        );
332        assert_eq!(
333            ExecutionPhase::ShellInit.category(),
334            HandlerCategory::Configuration
335        );
336        assert_eq!(
337            ExecutionPhase::Link.category(),
338            HandlerCategory::Configuration
339        );
340    }
341
342    #[test]
343    fn builtin_handler_phases() {
344        let fs = crate::fs::OsFs::new();
345        let registry = create_registry(&fs);
346        assert_eq!(
347            registry[HANDLER_HOMEBREW].phase(),
348            ExecutionPhase::Provision
349        );
350        assert_eq!(registry[HANDLER_INSTALL].phase(), ExecutionPhase::Setup);
351        assert_eq!(registry[HANDLER_PATH].phase(), ExecutionPhase::PathExport);
352        assert_eq!(registry[HANDLER_SHELL].phase(), ExecutionPhase::ShellInit);
353        assert_eq!(registry[HANDLER_SYMLINK].phase(), ExecutionPhase::Link);
354    }
355
356    #[test]
357    fn handler_status_serializes() {
358        let status = HandlerStatus {
359            file: "vimrc".into(),
360            handler: "symlink".into(),
361            deployed: true,
362            message: "linked to ~/.vimrc".into(),
363        };
364        let json = serde_json::to_string(&status).unwrap();
365        assert!(json.contains("deployed"));
366        assert!(json.contains("linked to ~/.vimrc"));
367    }
368
369    #[test]
370    fn handler_config_default() {
371        let config = HandlerConfig::default();
372        assert!(config.force_home.is_empty());
373        assert!(config.protected_paths.is_empty());
374    }
375
376    #[test]
377    fn default_registry_has_exactly_one_exclusive_catchall() {
378        let fs = crate::fs::OsFs::new();
379        let registry = create_registry(&fs);
380        let exclusive_catchalls: Vec<&str> = registry
381            .values()
382            .filter(|h| {
383                h.match_mode() == MatchMode::Catchall && h.scope() == HandlerScope::Exclusive
384            })
385            .map(|h| h.name())
386            .collect();
387        assert_eq!(exclusive_catchalls, vec!["symlink"]);
388    }
389
390    #[test]
391    #[should_panic(expected = "at most one exclusive catchall handler")]
392    fn two_exclusive_catchalls_panic() {
393        struct FakeCatchall;
394        impl Handler for FakeCatchall {
395            fn name(&self) -> &str {
396                "fake"
397            }
398            fn phase(&self) -> ExecutionPhase {
399                ExecutionPhase::Link
400            }
401            fn match_mode(&self) -> MatchMode {
402                MatchMode::Catchall
403            }
404            fn scope(&self) -> HandlerScope {
405                HandlerScope::Exclusive
406            }
407            fn to_intents(
408                &self,
409                _matches: &[RuleMatch],
410                _config: &HandlerConfig,
411                _paths: &dyn Pather,
412                _fs: &dyn Fs,
413            ) -> Result<Vec<HandlerIntent>> {
414                Ok(Vec::new())
415            }
416            fn check_status(
417                &self,
418                _file: &Path,
419                _pack: &str,
420                _datastore: &dyn DataStore,
421            ) -> Result<HandlerStatus> {
422                unreachable!()
423            }
424        }
425        let fs = crate::fs::OsFs::new();
426        let mut registry = create_registry(&fs);
427        registry.insert("fake".into(), Box::new(FakeCatchall));
428        validate_registry(&registry);
429    }
430}