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    /// Soft warnings produced for a set of matches — non-fatal,
191    /// human-readable strings the orchestration surfaces in
192    /// `PackStatusResult.warnings`.
193    ///
194    /// Default empty. The symlink handler overrides this to flag
195    /// `_lib/` entries on non-macOS platforms (per
196    /// `docs/proposals/macos-paths.lex` §4.2): the pack is otherwise
197    /// fine, other entries deploy normally, but the user gets a visible
198    /// "skipped on this platform" notice.
199    fn warnings_for_matches(
200        &self,
201        _matches: &[RuleMatch],
202        _config: &HandlerConfig,
203        _paths: &dyn Pather,
204    ) -> Vec<String> {
205        Vec::new()
206    }
207
208    /// Check whether a file has been deployed by this handler.
209    fn check_status(
210        &self,
211        file: &Path,
212        pack: &str,
213        datastore: &dyn DataStore,
214    ) -> Result<HandlerStatus>;
215}
216
217/// Configuration subset relevant to handlers.
218///
219/// Populated from `DodotConfig::to_handler_config()`. Carries exactly
220/// what handlers need without coupling them to the full config.
221#[derive(Debug, Clone, Serialize)]
222pub struct HandlerConfig {
223    /// Paths that must be forced to `$HOME` (e.g. `["ssh", "bashrc"]`).
224    pub force_home: Vec<String>,
225    /// Paths that must be forced to the app-support root (e.g.
226    /// `["Code", "Cursor"]`). Curated GUI-app folder names whose first
227    /// path segment routes to `<app_support_dir>/<seg>/<rest>` without
228    /// requiring a `_app/` prefix in the pack tree. See
229    /// `docs/proposals/macos-paths.lex` §3.4.
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub force_app: Vec<String>,
232    /// Pack-name → app-support folder name rewrites. When the pack
233    /// name appears as a key here, the resolver's default rule routes
234    /// through `<app_support_dir>/<alias>/<rel_path>` instead of
235    /// `$XDG_CONFIG_HOME/<pack>/<rel_path>`. See `app_aliases` in
236    /// `docs/proposals/macos-paths.lex` §3.3.
237    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
238    pub app_aliases: std::collections::HashMap<String, String>,
239    /// Paths that must not be symlinked (e.g. `[".ssh/id_rsa"]`).
240    pub protected_paths: Vec<String>,
241    /// Per-file custom symlink target overrides.
242    /// Key = relative path in pack, Value = target path.
243    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
244    pub targets: std::collections::HashMap<String, String>,
245    /// Whether to auto-`chmod +x` files in path-handler directories.
246    /// See [`PathSection::auto_chmod_exec`](crate::config::PathSection::auto_chmod_exec).
247    pub auto_chmod_exec: bool,
248    /// Pack-level ignore patterns (from `[pack] ignore`). Handlers that
249    /// recurse into a matched directory should apply these so the
250    /// per-file fallback doesn't pick up `.DS_Store`, `.git`, etc.
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub pack_ignore: Vec<String>,
253}
254
255impl Default for HandlerConfig {
256    fn default() -> Self {
257        Self {
258            force_home: Vec::new(),
259            force_app: Vec::new(),
260            app_aliases: std::collections::HashMap::new(),
261            protected_paths: Vec::new(),
262            targets: std::collections::HashMap::new(),
263            auto_chmod_exec: true,
264            pack_ignore: Vec::new(),
265        }
266    }
267}
268
269/// Well-known handler names.
270pub const HANDLER_SYMLINK: &str = "symlink";
271pub const HANDLER_SHELL: &str = "shell";
272pub const HANDLER_PATH: &str = "path";
273pub const HANDLER_INSTALL: &str = "install";
274pub const HANDLER_HOMEBREW: &str = "homebrew";
275
276/// Names of all configuration-category handlers in the registry.
277///
278/// Returned in no particular order. Used by `dodot up` to wipe stale
279/// per-pack state before re-applying current source: every successful
280/// `up` for these handlers is equivalent to "down (these handlers) +
281/// up", so a deleted source file no longer leaves an orphan entry.
282///
283/// Code-execution handlers (install, homebrew) are excluded — their
284/// sentinels record "did this run with this content?" and must persist
285/// across re-runs of `up` so install scripts and `brew bundle` aren't
286/// re-executed every time.
287pub fn configuration_handler_names(fs: &dyn Fs) -> Vec<String> {
288    create_registry(fs)
289        .iter()
290        .filter(|(_, h)| h.category() == HandlerCategory::Configuration)
291        .map(|(name, _)| name.clone())
292        .collect()
293}
294
295/// Create the default handler registry.
296///
297/// Returns a map from handler name to handler instance. The `fs`
298/// reference is needed by install and homebrew handlers for checksum
299/// computation.
300pub fn create_registry(fs: &dyn Fs) -> HashMap<String, Box<dyn Handler + '_>> {
301    let mut registry: HashMap<String, Box<dyn Handler>> = HashMap::new();
302    registry.insert(HANDLER_SYMLINK.into(), Box::new(symlink::SymlinkHandler));
303    registry.insert(HANDLER_SHELL.into(), Box::new(shell::ShellHandler));
304    registry.insert(HANDLER_PATH.into(), Box::new(path::PathHandler));
305    registry.insert(
306        HANDLER_INSTALL.into(),
307        Box::new(install::InstallHandler::new(fs)),
308    );
309    registry.insert(
310        HANDLER_HOMEBREW.into(),
311        Box::new(homebrew::HomebrewHandler::new(fs)),
312    );
313    validate_registry(&registry);
314    registry
315}
316
317/// Enforce registry invariants.
318///
319/// At most one handler may be simultaneously [`MatchMode::Catchall`]
320/// and [`HandlerScope::Exclusive`]. Two such handlers would fight over
321/// the same "leftover" entries with no principled way to pick a winner.
322///
323/// This is a developer-only invariant: the built-in registry is
324/// hard-coded and third-party handlers would be added via code, not
325/// user input. We use `debug_assert!` so release builds never panic
326/// from a misconfiguration here; if the invariant is violated in a
327/// dev build the panic surfaces immediately.
328fn validate_registry(registry: &HashMap<String, Box<dyn Handler + '_>>) {
329    let exclusive_catchalls: Vec<&str> = registry
330        .values()
331        .filter(|h| h.match_mode() == MatchMode::Catchall && h.scope() == HandlerScope::Exclusive)
332        .map(|h| h.name())
333        .collect();
334    debug_assert!(
335        exclusive_catchalls.len() <= 1,
336        "at most one exclusive catchall handler allowed, found: {exclusive_catchalls:?}"
337    );
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    // Compile-time check: Handler must be object-safe
345    #[allow(dead_code)]
346    fn assert_object_safe(_: &dyn Handler) {}
347
348    #[allow(dead_code)]
349    fn assert_boxable(_: Box<dyn Handler>) {}
350
351    #[test]
352    fn handler_category_eq() {
353        assert_eq!(
354            HandlerCategory::Configuration,
355            HandlerCategory::Configuration
356        );
357        assert_ne!(
358            HandlerCategory::Configuration,
359            HandlerCategory::CodeExecution
360        );
361    }
362
363    #[test]
364    fn execution_phase_declaration_order_drives_ord() {
365        assert!(ExecutionPhase::Provision < ExecutionPhase::Setup);
366        assert!(ExecutionPhase::Setup < ExecutionPhase::PathExport);
367        assert!(ExecutionPhase::PathExport < ExecutionPhase::ShellInit);
368        assert!(ExecutionPhase::ShellInit < ExecutionPhase::Link);
369    }
370
371    #[test]
372    fn execution_phase_category_mapping() {
373        assert_eq!(
374            ExecutionPhase::Provision.category(),
375            HandlerCategory::CodeExecution
376        );
377        assert_eq!(
378            ExecutionPhase::Setup.category(),
379            HandlerCategory::CodeExecution
380        );
381        assert_eq!(
382            ExecutionPhase::PathExport.category(),
383            HandlerCategory::Configuration
384        );
385        assert_eq!(
386            ExecutionPhase::ShellInit.category(),
387            HandlerCategory::Configuration
388        );
389        assert_eq!(
390            ExecutionPhase::Link.category(),
391            HandlerCategory::Configuration
392        );
393    }
394
395    #[test]
396    fn builtin_handler_phases() {
397        let fs = crate::fs::OsFs::new();
398        let registry = create_registry(&fs);
399        assert_eq!(
400            registry[HANDLER_HOMEBREW].phase(),
401            ExecutionPhase::Provision
402        );
403        assert_eq!(registry[HANDLER_INSTALL].phase(), ExecutionPhase::Setup);
404        assert_eq!(registry[HANDLER_PATH].phase(), ExecutionPhase::PathExport);
405        assert_eq!(registry[HANDLER_SHELL].phase(), ExecutionPhase::ShellInit);
406        assert_eq!(registry[HANDLER_SYMLINK].phase(), ExecutionPhase::Link);
407    }
408
409    #[test]
410    fn handler_status_serializes() {
411        let status = HandlerStatus {
412            file: "vimrc".into(),
413            handler: "symlink".into(),
414            deployed: true,
415            message: "linked to ~/.vimrc".into(),
416        };
417        let json = serde_json::to_string(&status).unwrap();
418        assert!(json.contains("deployed"));
419        assert!(json.contains("linked to ~/.vimrc"));
420    }
421
422    #[test]
423    fn handler_config_default() {
424        let config = HandlerConfig::default();
425        assert!(config.force_home.is_empty());
426        assert!(config.protected_paths.is_empty());
427    }
428
429    #[test]
430    fn default_registry_has_exactly_one_exclusive_catchall() {
431        let fs = crate::fs::OsFs::new();
432        let registry = create_registry(&fs);
433        let exclusive_catchalls: Vec<&str> = registry
434            .values()
435            .filter(|h| {
436                h.match_mode() == MatchMode::Catchall && h.scope() == HandlerScope::Exclusive
437            })
438            .map(|h| h.name())
439            .collect();
440        assert_eq!(exclusive_catchalls, vec!["symlink"]);
441    }
442
443    #[test]
444    #[should_panic(expected = "at most one exclusive catchall handler")]
445    fn two_exclusive_catchalls_panic() {
446        struct FakeCatchall;
447        impl Handler for FakeCatchall {
448            fn name(&self) -> &str {
449                "fake"
450            }
451            fn phase(&self) -> ExecutionPhase {
452                ExecutionPhase::Link
453            }
454            fn match_mode(&self) -> MatchMode {
455                MatchMode::Catchall
456            }
457            fn scope(&self) -> HandlerScope {
458                HandlerScope::Exclusive
459            }
460            fn to_intents(
461                &self,
462                _matches: &[RuleMatch],
463                _config: &HandlerConfig,
464                _paths: &dyn Pather,
465                _fs: &dyn Fs,
466            ) -> Result<Vec<HandlerIntent>> {
467                Ok(Vec::new())
468            }
469            fn check_status(
470                &self,
471                _file: &Path,
472                _pack: &str,
473                _datastore: &dyn DataStore,
474            ) -> Result<HandlerStatus> {
475                unreachable!()
476            }
477        }
478        let fs = crate::fs::OsFs::new();
479        let mut registry = create_registry(&fs);
480        registry.insert("fake".into(), Box::new(FakeCatchall));
481        validate_registry(&registry);
482    }
483}