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, 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    /// Whether to auto-`chmod +x` files in path-handler directories.
104    /// See [`PathSection::auto_chmod_exec`](crate::config::PathSection::auto_chmod_exec).
105    pub auto_chmod_exec: bool,
106}
107
108impl Default for HandlerConfig {
109    fn default() -> Self {
110        Self {
111            force_home: Vec::new(),
112            protected_paths: Vec::new(),
113            targets: std::collections::HashMap::new(),
114            auto_chmod_exec: true,
115        }
116    }
117}
118
119/// Well-known handler names.
120pub const HANDLER_SYMLINK: &str = "symlink";
121pub const HANDLER_SHELL: &str = "shell";
122pub const HANDLER_PATH: &str = "path";
123pub const HANDLER_INSTALL: &str = "install";
124pub const HANDLER_HOMEBREW: &str = "homebrew";
125
126/// Create the default handler registry.
127///
128/// Returns a map from handler name to handler instance. The `fs`
129/// reference is needed by install and homebrew handlers for checksum
130/// computation.
131pub fn create_registry(fs: &dyn Fs) -> HashMap<String, Box<dyn Handler + '_>> {
132    let mut registry: HashMap<String, Box<dyn Handler>> = HashMap::new();
133    registry.insert(HANDLER_SYMLINK.into(), Box::new(symlink::SymlinkHandler));
134    registry.insert(HANDLER_SHELL.into(), Box::new(shell::ShellHandler));
135    registry.insert(HANDLER_PATH.into(), Box::new(path::PathHandler));
136    registry.insert(
137        HANDLER_INSTALL.into(),
138        Box::new(install::InstallHandler::new(fs)),
139    );
140    registry.insert(
141        HANDLER_HOMEBREW.into(),
142        Box::new(homebrew::HomebrewHandler::new(fs)),
143    );
144    registry
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    // Compile-time check: Handler must be object-safe
152    #[allow(dead_code)]
153    fn assert_object_safe(_: &dyn Handler) {}
154
155    #[allow(dead_code)]
156    fn assert_boxable(_: Box<dyn Handler>) {}
157
158    #[test]
159    fn handler_category_eq() {
160        assert_eq!(
161            HandlerCategory::Configuration,
162            HandlerCategory::Configuration
163        );
164        assert_ne!(
165            HandlerCategory::Configuration,
166            HandlerCategory::CodeExecution
167        );
168    }
169
170    #[test]
171    fn handler_status_serializes() {
172        let status = HandlerStatus {
173            file: "vimrc".into(),
174            handler: "symlink".into(),
175            deployed: true,
176            message: "linked to ~/.vimrc".into(),
177        };
178        let json = serde_json::to_string(&status).unwrap();
179        assert!(json.contains("deployed"));
180        assert!(json.contains("linked to ~/.vimrc"));
181    }
182
183    #[test]
184    fn handler_config_default() {
185        let config = HandlerConfig::default();
186        assert!(config.force_home.is_empty());
187        assert!(config.protected_paths.is_empty());
188    }
189}