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(®istry);
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(®istry);
322 }
323}