mod filesystem;
pub use filesystem::FilesystemDataStore;
use std::path::{Path, PathBuf};
use crate::Result;
pub trait DataStore: Send + Sync {
fn create_data_link(&self, pack: &str, handler: &str, source_file: &Path) -> Result<PathBuf>;
fn create_user_link(&self, datastore_path: &Path, user_path: &Path) -> Result<()>;
fn run_and_record(
&self,
pack: &str,
handler: &str,
executable: &str,
arguments: &[String],
sentinel: &str,
force: bool,
) -> Result<()>;
fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool>;
fn remove_state(&self, pack: &str, handler: &str) -> Result<()>;
fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool>;
fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>>;
fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>>;
fn write_rendered_file(
&self,
pack: &str,
handler: &str,
filename: &str,
content: &[u8],
) -> Result<PathBuf>;
fn write_rendered_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf>;
fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> std::path::PathBuf;
}
pub trait CommandRunner: Send + Sync {
fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput>;
}
#[derive(Debug, Clone)]
pub struct CommandOutput {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
pub struct ShellCommandRunner {
verbose: bool,
}
impl ShellCommandRunner {
pub fn new(verbose: bool) -> Self {
Self { verbose }
}
}
pub(crate) fn format_command_for_display(executable: &str, arguments: &[String]) -> String {
if arguments.is_empty() {
return executable.to_string();
}
let args = arguments
.iter()
.map(|arg| {
if arg.is_empty()
|| arg.chars().any(char::is_whitespace)
|| arg.contains('"')
|| arg.contains('\'')
{
format!("{arg:?}")
} else {
arg.clone()
}
})
.collect::<Vec<_>>()
.join(" ");
format!("{executable} {args}")
}
pub(crate) fn parse_status_line(line: &str) -> Option<&str> {
let s = line.trim_start();
let rest = s.strip_prefix('#')?;
let rest = rest.trim_start();
let msg = rest.strip_prefix("status:")?;
Some(msg.trim())
}
impl CommandRunner for ShellCommandRunner {
fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
use std::io::{BufRead, BufReader, IsTerminal, Write};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
let mut child = Command::new(executable)
.args(arguments)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| crate::DodotError::CommandFailed {
command: format_command_for_display(executable, arguments),
exit_code: -1,
stderr: e.to_string(),
})?;
let stdout_pipe = child
.stdout
.take()
.expect("piped stdout missing after spawn");
let stderr_pipe = child
.stderr
.take()
.expect("piped stderr missing after spawn");
let tty = std::io::stdout().is_terminal();
let dim = if tty { "\x1b[2m" } else { "" };
let reset = if tty { "\x1b[0m" } else { "" };
let arrow = if tty { "→" } else { "->" };
let verbose = self.verbose;
let stderr_buf = Arc::new(Mutex::new(String::new()));
fn pop_eol(buf: &mut Vec<u8>) {
if buf.last() == Some(&b'\n') {
buf.pop();
}
if buf.last() == Some(&b'\r') {
buf.pop();
}
}
let stderr_thread = {
let buf = stderr_buf.clone();
thread::spawn(move || {
let mut reader = BufReader::new(stderr_pipe);
let host_stderr = std::io::stderr();
let mut bytes = Vec::new();
loop {
bytes.clear();
match reader.read_until(b'\n', &mut bytes) {
Ok(0) | Err(_) => break,
Ok(_) => {
pop_eol(&mut bytes);
let line = String::from_utf8_lossy(&bytes);
{
let mut guard = buf.lock().expect("stderr buf poisoned");
guard.push_str(&line);
guard.push('\n');
}
if verbose {
let mut h = host_stderr.lock();
let _ = writeln!(h, "{line}");
}
}
}
}
})
};
let mut stdout_buf = String::new();
{
let mut reader = BufReader::new(stdout_pipe);
let host_stdout = std::io::stdout();
let mut bytes = Vec::new();
loop {
bytes.clear();
match reader.read_until(b'\n', &mut bytes) {
Ok(0) | Err(_) => break,
Ok(_) => {
pop_eol(&mut bytes);
let line = String::from_utf8_lossy(&bytes);
stdout_buf.push_str(&line);
stdout_buf.push('\n');
if let Some(msg) = parse_status_line(&line) {
let mut h = host_stdout.lock();
let _ = writeln!(h, "{dim}{arrow}{reset} {msg}");
}
if verbose {
let mut h = host_stdout.lock();
let _ = writeln!(h, "{line}");
}
}
}
}
}
let _ = stderr_thread.join();
let stderr_text = stderr_buf.lock().expect("stderr buf poisoned").clone();
let status = child.wait().map_err(|e| crate::DodotError::CommandFailed {
command: format_command_for_display(executable, arguments),
exit_code: -1,
stderr: e.to_string(),
})?;
let exit_code = status.code().unwrap_or(-1);
if !status.success() {
if !verbose && !stderr_text.is_empty() {
let host_stderr = std::io::stderr();
let mut h = host_stderr.lock();
let _ = h.write_all(stderr_text.as_bytes());
if !stderr_text.ends_with('\n') {
let _ = writeln!(h);
}
}
return Err(crate::DodotError::CommandFailed {
command: format_command_for_display(executable, arguments),
exit_code,
stderr: stderr_text,
});
}
Ok(CommandOutput {
exit_code,
stdout: stdout_buf,
stderr: stderr_text,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_status_line_matches_no_space() {
assert_eq!(parse_status_line("#status: building"), Some("building"));
}
#[test]
fn parse_status_line_matches_one_space() {
assert_eq!(
parse_status_line("# status: downloading installer"),
Some("downloading installer")
);
}
#[test]
fn parse_status_line_matches_extra_whitespace() {
assert_eq!(
parse_status_line(" # status: compiling "),
Some("compiling")
);
}
#[test]
fn parse_status_line_rejects_plain_comment() {
assert_eq!(parse_status_line("# just a comment"), None);
}
#[test]
fn parse_status_line_rejects_non_comment() {
assert_eq!(parse_status_line("echo status: foo"), None);
}
#[test]
fn parse_status_line_rejects_shebang() {
assert_eq!(parse_status_line("#!/bin/bash"), None);
}
#[test]
fn parse_status_line_returns_empty_message() {
assert_eq!(parse_status_line("# status:"), Some(""));
}
#[test]
fn shell_runner_streams_and_captures_real_script() {
let runner = ShellCommandRunner::new(false);
let script = "echo starting; \
echo '# status: phase one'; \
echo middle; \
echo '# status: phase two'; \
echo done";
let out = runner
.run("bash", &["-c".into(), script.into()])
.expect("script should succeed");
assert!(out.stdout.contains("starting"));
assert!(out.stdout.contains("# status: phase one"));
assert!(out.stdout.contains("middle"));
assert!(out.stdout.contains("# status: phase two"));
assert!(out.stdout.contains("done"));
assert_eq!(out.exit_code, 0);
}
#[test]
fn shell_runner_returns_error_on_nonzero_exit() {
let runner = ShellCommandRunner::new(false);
let result = runner.run("bash", &["-c".into(), "exit 7".into()]);
match result {
Err(crate::DodotError::CommandFailed { exit_code, .. }) => {
assert_eq!(exit_code, 7);
}
other => panic!("expected CommandFailed, got {other:?}"),
}
}
#[test]
fn shell_runner_captures_stderr_in_command_output() {
let runner = ShellCommandRunner::new(false);
let out = runner
.run("bash", &["-c".into(), "echo hello >&2; echo world".into()])
.expect("script should succeed");
assert!(out.stderr.contains("hello"));
assert!(out.stdout.contains("world"));
}
}