1mod filesystem;
8
9pub use filesystem::FilesystemDataStore;
10
11use std::path::{Path, PathBuf};
12
13use crate::Result;
14
15pub trait DataStore: Send + Sync {
33 fn create_data_link(&self, pack: &str, handler: &str, source_file: &Path) -> Result<PathBuf>;
40
41 fn create_user_link(&self, datastore_path: &Path, user_path: &Path) -> Result<()>;
47
48 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 fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool>;
69
70 fn remove_state(&self, pack: &str, handler: &str) -> Result<()>;
74
75 fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool>;
77
78 fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>>;
80
81 fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>>;
83
84 fn write_rendered_file(
96 &self,
97 pack: &str,
98 handler: &str,
99 filename: &str,
100 content: &[u8],
101 ) -> Result<PathBuf>;
102
103 fn write_rendered_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf>;
109
110 fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> std::path::PathBuf;
112}
113
114pub trait CommandRunner: Send + Sync {
120 fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput>;
121}
122
123#[derive(Debug, Clone)]
125pub struct CommandOutput {
126 pub exit_code: i32,
127 pub stdout: String,
128 pub stderr: String,
129}
130
131pub struct ShellCommandRunner {
139 verbose: bool,
140}
141
142impl ShellCommandRunner {
143 pub fn new(verbose: bool) -> Self {
144 Self { verbose }
145 }
146}
147
148pub(crate) fn format_command_for_display(executable: &str, arguments: &[String]) -> String {
149 if arguments.is_empty() {
150 return executable.to_string();
151 }
152
153 let args = arguments
154 .iter()
155 .map(|arg| {
156 if arg.is_empty()
157 || arg.chars().any(char::is_whitespace)
158 || arg.contains('"')
159 || arg.contains('\'')
160 {
161 format!("{arg:?}")
162 } else {
163 arg.clone()
164 }
165 })
166 .collect::<Vec<_>>()
167 .join(" ");
168 format!("{executable} {args}")
169}
170
171pub(crate) fn parse_status_line(line: &str) -> Option<&str> {
178 let s = line.trim_start();
179 let rest = s.strip_prefix('#')?;
180 let rest = rest.trim_start();
181 let msg = rest.strip_prefix("status:")?;
182 Some(msg.trim())
183}
184
185impl CommandRunner for ShellCommandRunner {
186 fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
187 use std::io::{BufRead, BufReader, IsTerminal, Write};
188 use std::process::{Command, Stdio};
189 use std::sync::{Arc, Mutex};
190 use std::thread;
191
192 let mut child = Command::new(executable)
193 .args(arguments)
194 .stdout(Stdio::piped())
195 .stderr(Stdio::piped())
196 .spawn()
197 .map_err(|e| crate::DodotError::CommandFailed {
198 command: format_command_for_display(executable, arguments),
199 exit_code: -1,
200 stderr: e.to_string(),
201 })?;
202
203 let stdout_pipe = child
204 .stdout
205 .take()
206 .expect("piped stdout missing after spawn");
207 let stderr_pipe = child
208 .stderr
209 .take()
210 .expect("piped stderr missing after spawn");
211
212 let tty = std::io::stdout().is_terminal();
215 let dim = if tty { "\x1b[2m" } else { "" };
216 let reset = if tty { "\x1b[0m" } else { "" };
217 let arrow = if tty { "→" } else { "->" };
218
219 let verbose = self.verbose;
220 let stderr_buf = Arc::new(Mutex::new(String::new()));
221
222 fn pop_eol(buf: &mut Vec<u8>) {
228 if buf.last() == Some(&b'\n') {
229 buf.pop();
230 }
231 if buf.last() == Some(&b'\r') {
232 buf.pop();
233 }
234 }
235
236 let stderr_thread = {
239 let buf = stderr_buf.clone();
240 thread::spawn(move || {
241 let mut reader = BufReader::new(stderr_pipe);
242 let host_stderr = std::io::stderr();
243 let mut bytes = Vec::new();
244 loop {
245 bytes.clear();
246 match reader.read_until(b'\n', &mut bytes) {
247 Ok(0) | Err(_) => break,
248 Ok(_) => {
249 pop_eol(&mut bytes);
250 let line = String::from_utf8_lossy(&bytes);
251 {
252 let mut guard = buf.lock().expect("stderr buf poisoned");
253 guard.push_str(&line);
254 guard.push('\n');
255 }
256 if verbose {
257 let mut h = host_stderr.lock();
258 let _ = writeln!(h, "{line}");
259 }
260 }
261 }
262 }
263 })
264 };
265
266 let mut stdout_buf = String::new();
269 {
270 let mut reader = BufReader::new(stdout_pipe);
271 let host_stdout = std::io::stdout();
272 let mut bytes = Vec::new();
273 loop {
274 bytes.clear();
275 match reader.read_until(b'\n', &mut bytes) {
276 Ok(0) | Err(_) => break,
277 Ok(_) => {
278 pop_eol(&mut bytes);
279 let line = String::from_utf8_lossy(&bytes);
280 stdout_buf.push_str(&line);
281 stdout_buf.push('\n');
282
283 if let Some(msg) = parse_status_line(&line) {
284 let mut h = host_stdout.lock();
285 let _ = writeln!(h, "{dim}{arrow}{reset} {msg}");
286 }
287 if verbose {
288 let mut h = host_stdout.lock();
289 let _ = writeln!(h, "{line}");
290 }
291 }
292 }
293 }
294 }
295
296 let _ = stderr_thread.join();
297 let stderr_text = stderr_buf.lock().expect("stderr buf poisoned").clone();
298
299 let status = child.wait().map_err(|e| crate::DodotError::CommandFailed {
300 command: format_command_for_display(executable, arguments),
301 exit_code: -1,
302 stderr: e.to_string(),
303 })?;
304 let exit_code = status.code().unwrap_or(-1);
305
306 if !status.success() {
307 if !verbose && !stderr_text.is_empty() {
310 let host_stderr = std::io::stderr();
311 let mut h = host_stderr.lock();
312 let _ = h.write_all(stderr_text.as_bytes());
313 if !stderr_text.ends_with('\n') {
314 let _ = writeln!(h);
315 }
316 }
317 return Err(crate::DodotError::CommandFailed {
318 command: format_command_for_display(executable, arguments),
319 exit_code,
320 stderr: stderr_text,
321 });
322 }
323
324 Ok(CommandOutput {
325 exit_code,
326 stdout: stdout_buf,
327 stderr: stderr_text,
328 })
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn parse_status_line_matches_no_space() {
338 assert_eq!(parse_status_line("#status: building"), Some("building"));
339 }
340
341 #[test]
342 fn parse_status_line_matches_one_space() {
343 assert_eq!(
344 parse_status_line("# status: downloading installer"),
345 Some("downloading installer")
346 );
347 }
348
349 #[test]
350 fn parse_status_line_matches_extra_whitespace() {
351 assert_eq!(
352 parse_status_line(" # status: compiling "),
353 Some("compiling")
354 );
355 }
356
357 #[test]
358 fn parse_status_line_rejects_plain_comment() {
359 assert_eq!(parse_status_line("# just a comment"), None);
360 }
361
362 #[test]
363 fn parse_status_line_rejects_non_comment() {
364 assert_eq!(parse_status_line("echo status: foo"), None);
365 }
366
367 #[test]
368 fn parse_status_line_rejects_shebang() {
369 assert_eq!(parse_status_line("#!/bin/bash"), None);
371 }
372
373 #[test]
374 fn parse_status_line_returns_empty_message() {
375 assert_eq!(parse_status_line("# status:"), Some(""));
377 }
378
379 #[test]
380 fn shell_runner_streams_and_captures_real_script() {
381 let runner = ShellCommandRunner::new(false);
386 let script = "echo starting; \
387 echo '# status: phase one'; \
388 echo middle; \
389 echo '# status: phase two'; \
390 echo done";
391 let out = runner
392 .run("bash", &["-c".into(), script.into()])
393 .expect("script should succeed");
394 assert!(out.stdout.contains("starting"));
395 assert!(out.stdout.contains("# status: phase one"));
396 assert!(out.stdout.contains("middle"));
397 assert!(out.stdout.contains("# status: phase two"));
398 assert!(out.stdout.contains("done"));
399 assert_eq!(out.exit_code, 0);
400 }
401
402 #[test]
403 fn shell_runner_returns_error_on_nonzero_exit() {
404 let runner = ShellCommandRunner::new(false);
405 let result = runner.run("bash", &["-c".into(), "exit 7".into()]);
406 match result {
407 Err(crate::DodotError::CommandFailed { exit_code, .. }) => {
408 assert_eq!(exit_code, 7);
409 }
410 other => panic!("expected CommandFailed, got {other:?}"),
411 }
412 }
413
414 #[test]
415 fn shell_runner_captures_stderr_in_command_output() {
416 let runner = ShellCommandRunner::new(false);
417 let out = runner
418 .run("bash", &["-c".into(), "echo hello >&2; echo world".into()])
419 .expect("script should succeed");
420 assert!(out.stderr.contains("hello"));
421 assert!(out.stdout.contains("world"));
422 }
423}