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 pure data transformers — they declare what operations
8//! they need without performing any I/O themselves.
9
10pub mod homebrew;
11pub mod install;
12pub mod path;
13pub mod shell;
14pub mod symlink;
15
16use std::collections::HashMap;
17use std::path::Path;
18
19use serde::Serialize;
20
21use crate::datastore::DataStore;
22use crate::fs::Fs;
23use crate::operations::HandlerIntent;
24use crate::paths::Pather;
25use crate::rules::RuleMatch;
26use crate::Result;
27
28/// Whether a handler manages configuration or executes code.
29///
30/// Configuration handlers (symlink, shell, path) are safe to run
31/// repeatedly. Code execution handlers (install, homebrew) run once
32/// and are tracked by sentinels.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
34pub enum HandlerCategory {
35    Configuration,
36    CodeExecution,
37}
38
39/// The status of a handler's operations for a single file.
40#[derive(Debug, Clone, Serialize)]
41pub struct HandlerStatus {
42    /// Which file this status is for (relative to pack root).
43    pub file: String,
44    /// Which handler produced this status.
45    pub handler: String,
46    /// Whether the file is currently deployed.
47    pub deployed: bool,
48    /// Human-readable status message (e.g. "linked to ~/.vimrc").
49    pub message: String,
50}
51
52/// The core handler abstraction.
53///
54/// Each handler is a small struct (often zero-sized) that implements
55/// this trait. Handlers are stored in a `HashMap<String, Box<dyn Handler>>`
56/// registry and dispatched by name at runtime.
57///
58/// # Object safety
59///
60/// This trait is designed to be used as `&dyn Handler` and
61/// `Box<dyn Handler>`. All methods use `&self` and return owned types.
62pub trait Handler: Send + Sync {
63    /// Unique name for this handler (e.g. `"symlink"`, `"install"`).
64    fn name(&self) -> &str;
65
66    /// Whether this is a configuration or code-execution handler.
67    fn category(&self) -> HandlerCategory;
68
69    /// Transform matched files into intents.
70    ///
71    /// This is the heart of each handler — it declares what operations
72    /// are needed without performing any I/O.
73    fn to_intents(
74        &self,
75        matches: &[RuleMatch],
76        config: &HandlerConfig,
77        paths: &dyn Pather,
78    ) -> Result<Vec<HandlerIntent>>;
79
80    /// Check whether a file has been deployed by this handler.
81    fn check_status(
82        &self,
83        file: &Path,
84        pack: &str,
85        datastore: &dyn DataStore,
86    ) -> Result<HandlerStatus>;
87}
88
89/// Configuration subset relevant to handlers.
90///
91/// Populated from `DodotConfig::to_handler_config()`. Carries exactly
92/// what handlers need without coupling them to the full config.
93#[derive(Debug, Clone, Default, Serialize)]
94pub struct HandlerConfig {
95    /// Paths that must be forced to `$HOME` (e.g. `["ssh", "bashrc"]`).
96    pub force_home: Vec<String>,
97    /// Paths that must not be symlinked (e.g. `[".ssh/id_rsa"]`).
98    pub protected_paths: Vec<String>,
99    /// Per-file custom symlink target overrides.
100    /// Key = relative path in pack, Value = target path.
101    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
102    pub targets: std::collections::HashMap<String, String>,
103}
104
105/// Well-known handler names.
106pub const HANDLER_SYMLINK: &str = "symlink";
107pub const HANDLER_SHELL: &str = "shell";
108pub const HANDLER_PATH: &str = "path";
109pub const HANDLER_INSTALL: &str = "install";
110pub const HANDLER_HOMEBREW: &str = "homebrew";
111
112/// Create the default handler registry.
113///
114/// Returns a map from handler name to handler instance. The `fs`
115/// reference is needed by install and homebrew handlers for checksum
116/// computation.
117pub fn create_registry(fs: &dyn Fs) -> HashMap<String, Box<dyn Handler + '_>> {
118    let mut registry: HashMap<String, Box<dyn Handler>> = HashMap::new();
119    registry.insert(HANDLER_SYMLINK.into(), Box::new(symlink::SymlinkHandler));
120    registry.insert(HANDLER_SHELL.into(), Box::new(shell::ShellHandler));
121    registry.insert(HANDLER_PATH.into(), Box::new(path::PathHandler));
122    registry.insert(
123        HANDLER_INSTALL.into(),
124        Box::new(install::InstallHandler::new(fs)),
125    );
126    registry.insert(
127        HANDLER_HOMEBREW.into(),
128        Box::new(homebrew::HomebrewHandler::new(fs)),
129    );
130    registry
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    // Compile-time check: Handler must be object-safe
138    #[allow(dead_code)]
139    fn assert_object_safe(_: &dyn Handler) {}
140
141    #[allow(dead_code)]
142    fn assert_boxable(_: Box<dyn Handler>) {}
143
144    #[test]
145    fn handler_category_eq() {
146        assert_eq!(
147            HandlerCategory::Configuration,
148            HandlerCategory::Configuration
149        );
150        assert_ne!(
151            HandlerCategory::Configuration,
152            HandlerCategory::CodeExecution
153        );
154    }
155
156    #[test]
157    fn handler_status_serializes() {
158        let status = HandlerStatus {
159            file: "vimrc".into(),
160            handler: "symlink".into(),
161            deployed: true,
162            message: "linked to ~/.vimrc".into(),
163        };
164        let json = serde_json::to_string(&status).unwrap();
165        assert!(json.contains("deployed"));
166        assert!(json.contains("linked to ~/.vimrc"));
167    }
168
169    #[test]
170    fn handler_config_default() {
171        let config = HandlerConfig::default();
172        assert!(config.force_home.is_empty());
173        assert!(config.protected_paths.is_empty());
174    }
175}