dodot_lib/datastore/mod.rs
1//! State management for dodot.
2//!
3//! The [`DataStore`] trait defines dodot's 8-method storage API.
4//! [`FilesystemDataStore`] implements it using symlinks and sentinel
5//! files on a real (or test) filesystem via the [`Fs`](crate::fs::Fs) trait.
6
7mod filesystem;
8
9pub use filesystem::FilesystemDataStore;
10
11use std::path::{Path, PathBuf};
12
13use crate::Result;
14
15/// Dodot's storage interface.
16///
17/// State is represented entirely by symlinks and sentinel files in the
18/// filesystem — no database, no lock files. The 8 methods break into
19/// three groups:
20///
21/// **Mutations** — modify state:
22/// - [`create_data_link`](DataStore::create_data_link)
23/// - [`create_user_link`](DataStore::create_user_link)
24/// - [`run_and_record`](DataStore::run_and_record)
25/// - [`remove_state`](DataStore::remove_state)
26///
27/// **Queries** — read state:
28/// - [`has_sentinel`](DataStore::has_sentinel)
29/// - [`has_handler_state`](DataStore::has_handler_state)
30/// - [`list_pack_handlers`](DataStore::list_pack_handlers)
31/// - [`list_handler_sentinels`](DataStore::list_handler_sentinels)
32pub trait DataStore: Send + Sync {
33 /// Creates an intermediate symlink in the datastore:
34 /// `handler_data_dir(pack, handler) / filename -> source_file`
35 ///
36 /// Returns the absolute path of the created datastore link.
37 /// Idempotent: if the link exists and already points to the correct
38 /// source, this is a no-op.
39 fn create_data_link(&self, pack: &str, handler: &str, source_file: &Path) -> Result<PathBuf>;
40
41 /// Creates a user-visible symlink:
42 /// `user_path -> datastore_path`
43 ///
44 /// This is the second leg of the double-link architecture.
45 /// Creates parent directories as needed.
46 fn create_user_link(&self, datastore_path: &Path, user_path: &Path) -> Result<()>;
47
48 /// Executes `command` via shell and records a sentinel on success.
49 ///
50 /// Idempotent: if the sentinel already exists, the command is not
51 /// re-run. The sentinel file stores `completed|{timestamp}`.
52 ///
53 /// **Edge case**: if the command succeeds but the sentinel write
54 /// fails, a subsequent call will re-run the command. This is by
55 /// design — re-running is safer than falsely marking as complete.
56 /// Install scripts should be idempotent to handle this.
57 fn run_and_record(
58 &self,
59 pack: &str,
60 handler: &str,
61 executable: &str,
62 arguments: &[String],
63 sentinel: &str,
64 force: bool,
65 ) -> Result<()>;
66
67 /// Checks whether a sentinel exists for this pack/handler.
68 fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool>;
69
70 /// Removes all state for a pack/handler pair.
71 ///
72 /// Deletes the handler data directory and everything in it.
73 fn remove_state(&self, pack: &str, handler: &str) -> Result<()>;
74
75 /// Checks if any state exists for a pack/handler pair.
76 fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool>;
77
78 /// Lists handler names that have state for a pack.
79 fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>>;
80
81 /// Lists sentinel file names for a pack/handler.
82 fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>>;
83
84 /// Returns the absolute path where a sentinel file would be stored.
85 fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> std::path::PathBuf;
86}
87
88/// Abstraction over process execution.
89///
90/// [`FilesystemDataStore`] uses this to run commands in
91/// [`run_and_record`](DataStore::run_and_record). Tests can provide a
92/// mock that records calls without spawning processes.
93pub trait CommandRunner: Send + Sync {
94 fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput>;
95}
96
97/// Output from a command execution.
98#[derive(Debug, Clone)]
99pub struct CommandOutput {
100 pub exit_code: i32,
101 pub stdout: String,
102 pub stderr: String,
103}
104
105/// [`CommandRunner`] that spawns a real shell process.
106pub struct ShellCommandRunner;
107
108pub(crate) fn format_command_for_display(executable: &str, arguments: &[String]) -> String {
109 if arguments.is_empty() {
110 return executable.to_string();
111 }
112
113 let args = arguments
114 .iter()
115 .map(|arg| {
116 if arg.is_empty()
117 || arg.chars().any(char::is_whitespace)
118 || arg.contains('"')
119 || arg.contains('\'')
120 {
121 format!("{arg:?}")
122 } else {
123 arg.clone()
124 }
125 })
126 .collect::<Vec<_>>()
127 .join(" ");
128 format!("{executable} {args}")
129}
130
131impl CommandRunner for ShellCommandRunner {
132 fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
133 let output = std::process::Command::new(executable)
134 .args(arguments)
135 .output()
136 .map_err(|e| crate::DodotError::CommandFailed {
137 command: format_command_for_display(executable, arguments),
138 exit_code: -1,
139 stderr: e.to_string(),
140 })?;
141
142 let exit_code = output.status.code().unwrap_or(-1);
143 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
144 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
145
146 if !output.status.success() {
147 return Err(crate::DodotError::CommandFailed {
148 command: format_command_for_display(executable, arguments),
149 exit_code,
150 stderr,
151 });
152 }
153
154 Ok(CommandOutput {
155 exit_code,
156 stdout,
157 stderr,
158 })
159 }
160}