Skip to main content

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}