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(®istry);
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(®istry);
429 }
430}