diff --git a/Cargo.lock b/Cargo.lock index de9dfba..4078588 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -217,12 +233,31 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "maestro-daemon" +version = "0.1.0" +dependencies = [ + "chrono", + "maestro-protocol", + "serde", + "serde_json", + "tempfile", + "thiserror", + "uuid", +] + [[package]] name = "maestro-protocol" version = "0.1.0" @@ -294,6 +329,19 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -372,6 +420,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -552,6 +633,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 9b2cf9a..1cfd6e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,11 @@ publish = false [workspace.dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +maestro-protocol = { path = "crates/maestro-protocol" } serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" +thiserror = "1" uuid = { version = "1", features = ["v4", "serde"] } [profile.release] diff --git a/crates/maestro-daemon/Cargo.toml b/crates/maestro-daemon/Cargo.toml new file mode 100644 index 0000000..aa5782f --- /dev/null +++ b/crates/maestro-daemon/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "maestro-daemon" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true +description = "Per-host tmux wrapper, session sidecar store, and git-worktree manager" + +[dependencies] +chrono.workspace = true +maestro-protocol.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs new file mode 100644 index 0000000..09ca684 --- /dev/null +++ b/crates/maestro-daemon/src/lib.rs @@ -0,0 +1,16 @@ +//! Local primitives used by the `maestrod` binary. +//! +//! This crate is intentionally networking-free — it only wraps the OS-level +//! pieces a daemon needs: +//! +//! - [`tmux`]: subprocess wrapper around the `tmux` binary +//! - [`sidecar`]: per-session JSON metadata store on disk +//! - [`worktree`]: `git worktree` add/remove for ephemeral session repos +//! +//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §Daemon. + +#![deny(missing_debug_implementations)] + +pub mod sidecar; +pub mod tmux; +pub mod worktree; diff --git a/crates/maestro-daemon/src/sidecar.rs b/crates/maestro-daemon/src/sidecar.rs new file mode 100644 index 0000000..709d877 --- /dev/null +++ b/crates/maestro-daemon/src/sidecar.rs @@ -0,0 +1,97 @@ +//! Per-session JSON metadata store. +//! +//! On `CreateSession`, the daemon writes a `.json` sidecar so that on +//! restart it can pair each surviving `maestro-` tmux session with its +//! original `cwd`, command, env, metadata, etc. Files are deleted on +//! `KillSession`. Sessions whose sidecar is missing at recovery time are still +//! advertised but with empty metadata (handled by the caller, not here). + +use std::path::{Path, PathBuf}; + +use maestro_protocol::Session; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum SidecarError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), +} + +/// Filesystem-backed session metadata store, rooted at a directory. +#[derive(Debug, Clone)] +pub struct SidecarStore { + dir: PathBuf, +} + +impl SidecarStore { + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + pub fn path_for(&self, id: Uuid) -> PathBuf { + self.dir.join(format!("{id}.json")) + } + + /// Atomically write `session` to `/.json`. Creates the directory + /// if it does not exist. + pub fn write(&self, session: &Session) -> Result<(), SidecarError> { + std::fs::create_dir_all(&self.dir)?; + let final_path = self.path_for(session.id); + let tmp = self.dir.join(format!("{}.json.tmp", session.id)); + let json = serde_json::to_vec_pretty(session)?; + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, &final_path)?; + Ok(()) + } + + /// Read the sidecar for `id`. `Ok(None)` if the file is missing. + pub fn read(&self, id: Uuid) -> Result, SidecarError> { + match std::fs::read(self.path_for(id)) { + Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Delete the sidecar for `id`. No-op if it does not exist. + pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> { + match std::fs::remove_file(self.path_for(id)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } + + /// Read every sidecar JSON in the directory. Files that fail to parse are + /// skipped silently — the daemon will still surface the live tmux session + /// with empty metadata, which matches the spec's recovery behavior. + pub fn load_all(&self) -> Result, SidecarError> { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(&self.dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e.into()), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + if let Ok(session) = serde_json::from_slice::(&bytes) { + out.push(session); + } + } + out.sort_by_key(|s| s.id); + Ok(out) + } +} diff --git a/crates/maestro-daemon/src/tmux.rs b/crates/maestro-daemon/src/tmux.rs new file mode 100644 index 0000000..929d24b --- /dev/null +++ b/crates/maestro-daemon/src/tmux.rs @@ -0,0 +1,217 @@ +//! Subprocess wrapper around the `tmux` binary. +//! +//! All sessions managed through this wrapper are named `maestro-` so that +//! the daemon can recover its own sessions on restart (and ignore foreign ones). + +use std::ffi::{OsStr, OsString}; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use uuid::Uuid; + +/// Prefix used for every tmux session the daemon owns. +pub const SESSION_PREFIX: &str = "maestro-"; + +#[derive(Debug, thiserror::Error)] +pub enum TmuxError { + #[error("failed to spawn tmux: {0}")] + Spawn(#[from] std::io::Error), + #[error("tmux exited with code {code}: {stderr}")] + NonZero { code: i32, stderr: String }, +} + +/// Handle to a tmux server. Cheap to clone. +#[derive(Debug, Clone)] +pub struct Tmux { + binary: PathBuf, + socket_name: Option, + tmpdir: Option, +} + +impl Default for Tmux { + fn default() -> Self { + Self { + binary: PathBuf::from("tmux"), + socket_name: None, + tmpdir: None, + } + } +} + +impl Tmux { + pub fn new() -> Self { + Self::default() + } + + /// Pass `-L ` to every tmux invocation. Useful to isolate test + /// servers from the user's normal tmux session. + pub fn with_socket(mut self, name: impl Into) -> Self { + self.socket_name = Some(name.into()); + self + } + + /// Set `TMUX_TMPDIR` for every tmux invocation. The tmux server keeps its + /// socket under this directory. + pub fn with_tmpdir(mut self, dir: impl Into) -> Self { + self.tmpdir = Some(dir.into()); + self + } + + pub fn socket_name(&self) -> Option<&str> { + self.socket_name.as_deref() + } + + pub fn tmpdir(&self) -> Option<&Path> { + self.tmpdir.as_deref() + } + + fn cmd(&self) -> Command { + let mut c = Command::new(&self.binary); + if let Some(s) = &self.socket_name { + c.arg("-L").arg(s); + } + if let Some(d) = &self.tmpdir { + c.env("TMUX_TMPDIR", d); + } + c + } + + /// `tmux new-session -d -s maestro- -c `. + /// + /// `command` is passed as a single shell-line argument to tmux, which + /// invokes it via `$SHELL -c`. + pub fn new_session(&self, id: Uuid, cwd: &Path, command: &str) -> Result<(), TmuxError> { + let name = session_name(id); + let out = self + .cmd() + .args(["new-session", "-d", "-s", &name, "-c"]) + .arg(cwd) + .arg(command) + .output()?; + check(out) + } + + /// `tmux kill-session -t maestro-`. + pub fn kill_session(&self, id: Uuid) -> Result<(), TmuxError> { + let name = session_name(id); + let out = self.cmd().args(["kill-session", "-t", &name]).output()?; + check(out) + } + + /// `tmux list-sessions -F '#{session_name}'`, filtered to `maestro-*` and + /// parsed back into UUIDs. Returns an empty vector if no tmux server is + /// running (so callers don't need to special-case startup). + pub fn list_sessions(&self) -> Result, TmuxError> { + let out = self + .cmd() + .args(["list-sessions", "-F", "#{session_name}"]) + .output()?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + if is_no_server_error(&stderr) { + return Ok(Vec::new()); + } + return Err(TmuxError::NonZero { + code: out.status.code().unwrap_or(-1), + stderr: stderr.into_owned(), + }); + } + let stdout = String::from_utf8_lossy(&out.stdout); + let mut ids = Vec::new(); + for line in stdout.lines() { + if let Some(rest) = line.strip_prefix(SESSION_PREFIX) { + if let Ok(u) = Uuid::parse_str(rest) { + ids.push(u); + } + } + } + Ok(ids) + } + + /// `tmux send-keys -t maestro- -l `. The `-l` flag tells tmux to + /// treat the payload as literal input (no key-name translation), which is + /// what we want for forwarded PTY input. + pub fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), TmuxError> { + let name = session_name(id); + let mut arg = OsString::new(); + arg.push(OsStr::from_bytes(bytes)); + let out = self + .cmd() + .args(["send-keys", "-t", &name, "-l"]) + .arg(arg) + .output()?; + check(out) + } + + /// `tmux pipe-pane -t maestro- -o ''`. + /// + /// `sink_command` is a shell command that receives pane output on stdin + /// (e.g. `cat >> /tmp/sess.log`). The `-o` flag makes the call idempotent: + /// if a pipe is already running on the pane, this is a no-op. + pub fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError> { + let name = session_name(id); + let out = self + .cmd() + .args(["pipe-pane", "-t", &name, "-o", sink_command]) + .output()?; + check(out) + } + + /// `tmux resize-window -t maestro- -x -y `. + pub fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> { + let name = session_name(id); + let out = self + .cmd() + .args([ + "resize-window", + "-t", + &name, + "-x", + &cols.to_string(), + "-y", + &rows.to_string(), + ]) + .output()?; + check(out) + } + + /// `tmux kill-server`. Best-effort cleanup used mostly by tests. + pub fn kill_server(&self) -> Result<(), TmuxError> { + let out = self.cmd().arg("kill-server").output()?; + if out.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&out.stderr); + if is_no_server_error(&stderr) { + return Ok(()); + } + Err(TmuxError::NonZero { + code: out.status.code().unwrap_or(-1), + stderr: stderr.into_owned(), + }) + } +} + +/// True if tmux's stderr indicates "there is no tmux server running on this +/// socket" (multiple phrasings across versions). +fn is_no_server_error(stderr: &str) -> bool { + stderr.contains("no server running") + || stderr.contains("no sessions") + || stderr.contains("error connecting") +} + +pub fn session_name(id: Uuid) -> String { + format!("{SESSION_PREFIX}{id}") +} + +fn check(out: Output) -> Result<(), TmuxError> { + if out.status.success() { + Ok(()) + } else { + Err(TmuxError::NonZero { + code: out.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }) + } +} diff --git a/crates/maestro-daemon/src/worktree.rs b/crates/maestro-daemon/src/worktree.rs new file mode 100644 index 0000000..b5ffa63 --- /dev/null +++ b/crates/maestro-daemon/src/worktree.rs @@ -0,0 +1,91 @@ +//! `git worktree` helper used when a `CreateSession` request includes a +//! `worktree` spec. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum WorktreeError { + #[error("failed to spawn git: {0}")] + Spawn(#[from] std::io::Error), + #[error("git exited with code {code}: {stderr}")] + NonZero { code: i32, stderr: String }, +} + +/// Generates a unique path under `root` and runs `git worktree add` rooted at +/// `base_repo`. Each call appends a UUID-suffixed directory so concurrent +/// `add()`s never collide. +#[derive(Debug, Clone)] +pub struct WorktreeManager { + root: PathBuf, +} + +impl WorktreeManager { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// `git -C worktree add -b `. + /// Returns the path of the new worktree on success. + pub fn add(&self, base_repo: &Path, branch: &str) -> Result { + std::fs::create_dir_all(&self.root)?; + let path = self.generate_path(branch); + let out = Command::new("git") + .arg("-C") + .arg(base_repo) + .args(["worktree", "add", "-b", branch]) + .arg(&path) + .output()?; + check(out)?; + Ok(path) + } + + /// `git -C worktree remove --force `. + pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> { + let out = Command::new("git") + .arg("-C") + .arg(base_repo) + .args(["worktree", "remove", "--force"]) + .arg(path) + .output()?; + check(out) + } + + fn generate_path(&self, branch: &str) -> PathBuf { + let slug = sanitize_branch_for_path(branch); + let unique = Uuid::new_v4().simple(); + self.root.join(format!("{slug}-{unique}")) + } +} + +fn sanitize_branch_for_path(branch: &str) -> String { + let mut out = String::with_capacity(branch.len()); + for c in branch.chars() { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + out.push(c); + } else if c == '/' || c == '.' { + out.push('-'); + } + } + if out.is_empty() { + out.push_str("wt"); + } + out +} + +fn check(out: Output) -> Result<(), WorktreeError> { + if out.status.success() { + Ok(()) + } else { + Err(WorktreeError::NonZero { + code: out.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }) + } +} diff --git a/crates/maestro-daemon/tests/common/mod.rs b/crates/maestro-daemon/tests/common/mod.rs new file mode 100644 index 0000000..d31cf0d --- /dev/null +++ b/crates/maestro-daemon/tests/common/mod.rs @@ -0,0 +1,88 @@ +//! Shared helpers for the maestro-daemon integration tests. +//! +//! Each integration test file compiles as its own binary and only uses a +//! subset of these helpers, so `dead_code` warnings are spurious here. +#![allow(dead_code)] + +use std::path::Path; +use std::process::Command; +use std::thread; +use std::time::{Duration, Instant}; + +use maestro_daemon::tmux::Tmux; +use tempfile::TempDir; +use uuid::Uuid; + +/// An isolated tmux instance scoped to a temp `TMUX_TMPDIR` and unique socket +/// name. Drops kill the server so leftover sockets don't accumulate. +pub struct TmuxFixture { + pub tmux: Tmux, + _home: TempDir, + _tmpdir: TempDir, +} + +impl TmuxFixture { + pub fn new() -> Self { + let home = TempDir::new().expect("tempdir HOME"); + let tmpdir = TempDir::new().expect("tempdir TMUX_TMPDIR"); + let socket = format!("maestro-test-{}", Uuid::new_v4().simple()); + let tmux = Tmux::new() + .with_socket(socket) + .with_tmpdir(tmpdir.path().to_path_buf()); + Self { + tmux, + _home: home, + _tmpdir: tmpdir, + } + } +} + +impl Drop for TmuxFixture { + fn drop(&mut self) { + let _ = self.tmux.kill_server(); + } +} + +/// Wait until `cond` returns `true`, polling every 25ms. Panics on timeout. +pub fn wait_for(timeout: Duration, mut cond: impl FnMut() -> bool) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if cond() { + return; + } + thread::sleep(Duration::from_millis(25)); + } + panic!("timed out after {timeout:?} waiting for condition"); +} + +/// `git init` a fresh repo in `dir` with one commit so it can host worktrees. +pub fn init_repo(dir: &Path) { + run(Command::new("git") + .arg("-C") + .arg(dir) + .args(["init", "-q", "-b", "main"])); + run(Command::new("git") + .arg("-C") + .arg(dir) + .args(["config", "user.email", "test@example.com"])); + run(Command::new("git") + .arg("-C") + .arg(dir) + .args(["config", "user.name", "Test"])); + std::fs::write(dir.join("README"), b"hello\n").unwrap(); + run(Command::new("git").arg("-C").arg(dir).args(["add", "."])); + run(Command::new("git") + .arg("-C") + .arg(dir) + .args(["commit", "-q", "-m", "initial"])); +} + +fn run(cmd: &mut Command) { + let out = cmd.output().expect("git spawn"); + assert!( + out.status.success(), + "git command failed: {} stderr={}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); +} diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs new file mode 100644 index 0000000..b8a58a0 --- /dev/null +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -0,0 +1,107 @@ +//! Round-trip and recovery tests for the on-disk sidecar session store. + +mod common; + +use std::collections::HashMap; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use maestro_daemon::sidecar::SidecarStore; +use maestro_protocol::{Session, SessionStatus}; +use serde_json::Value; +use tempfile::TempDir; +use uuid::Uuid; + +fn sample(id: Uuid, daemon_id: &str) -> Session { + Session { + id, + daemon_id: daemon_id.to_string(), + cwd: PathBuf::from("/tmp/work"), + command: "bash".into(), + env: HashMap::from([("FOO".into(), "bar".into())]), + metadata: HashMap::from([("label".into(), Value::String("personal/maestro".into()))]), + status: SessionStatus::Running, + created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + } +} + +#[test] +fn write_then_read_round_trip() { + let dir = TempDir::new().unwrap(); + let store = SidecarStore::new(dir.path()); + let id = Uuid::new_v4(); + let s = sample(id, "laptop"); + + store.write(&s).expect("write"); + let loaded = store.read(id).expect("read"); + assert_eq!(loaded.as_ref(), Some(&s)); + + let path = store.path_for(id); + assert!(path.exists()); + assert!(path.ends_with(format!("{id}.json"))); +} + +#[test] +fn read_missing_returns_none() { + let dir = TempDir::new().unwrap(); + let store = SidecarStore::new(dir.path()); + assert!(store.read(Uuid::new_v4()).unwrap().is_none()); +} + +#[test] +fn delete_removes_file_and_is_idempotent() { + let dir = TempDir::new().unwrap(); + let store = SidecarStore::new(dir.path()); + let id = Uuid::new_v4(); + let s = sample(id, "laptop"); + + store.write(&s).unwrap(); + assert!(store.path_for(id).exists()); + + store.delete(id).unwrap(); + assert!(!store.path_for(id).exists()); + + // second delete is a no-op + store.delete(id).unwrap(); +} + +#[test] +fn load_all_recovers_every_sidecar_and_skips_garbage() { + let dir = TempDir::new().unwrap(); + let store = SidecarStore::new(dir.path()); + + let s1 = sample(Uuid::new_v4(), "laptop"); + let s2 = sample(Uuid::new_v4(), "dev-vm"); + store.write(&s1).unwrap(); + store.write(&s2).unwrap(); + + // garbage files in the directory should be ignored + std::fs::write(dir.path().join("not-json.txt"), b"hello").unwrap(); + std::fs::write(dir.path().join("broken.json"), b"{not json}").unwrap(); + + let mut loaded = store.load_all().expect("load_all"); + loaded.sort_by_key(|s| s.daemon_id.clone()); + let mut expected = vec![s1, s2]; + expected.sort_by_key(|s| s.daemon_id.clone()); + assert_eq!(loaded, expected); +} + +#[test] +fn load_all_returns_empty_when_directory_missing() { + let dir = TempDir::new().unwrap(); + let store = SidecarStore::new(dir.path().join("does-not-exist")); + assert!(store.load_all().unwrap().is_empty()); +} + +#[test] +fn write_creates_parent_directory() { + let dir = TempDir::new().unwrap(); + let nested = dir.path().join("a/b/c"); + let store = SidecarStore::new(&nested); + let s = sample(Uuid::new_v4(), "laptop"); + store.write(&s).expect("write"); + assert!(nested.exists()); + assert!(store.path_for(s.id).exists()); +} diff --git a/crates/maestro-daemon/tests/tmux.rs b/crates/maestro-daemon/tests/tmux.rs new file mode 100644 index 0000000..9184755 --- /dev/null +++ b/crates/maestro-daemon/tests/tmux.rs @@ -0,0 +1,128 @@ +//! Integration tests for the tmux wrapper against a real `tmux` binary. +//! +//! Each test spins up an isolated tmux server (unique socket name + temp +//! `TMUX_TMPDIR`) so they can run in parallel and never touch the user's +//! actual tmux session. + +mod common; + +use std::process::Command; +use std::time::Duration; + +use common::{wait_for, TmuxFixture}; +use tempfile::TempDir; +use uuid::Uuid; + +#[test] +fn create_list_kill_round_trip() { + let fx = TmuxFixture::new(); + let cwd = TempDir::new().unwrap(); + let id = Uuid::new_v4(); + + assert!(fx.tmux.list_sessions().unwrap().is_empty()); + + fx.tmux + .new_session(id, cwd.path(), "sleep 30") + .expect("new_session"); + + wait_for(Duration::from_secs(3), || { + fx.tmux.list_sessions().unwrap().contains(&id) + }); + + fx.tmux.kill_session(id).expect("kill_session"); + + wait_for(Duration::from_secs(3), || { + !fx.tmux.list_sessions().unwrap().contains(&id) + }); +} + +#[test] +fn list_sessions_ignores_foreign_names() { + let fx = TmuxFixture::new(); + let cwd = TempDir::new().unwrap(); + let maestro_id = Uuid::new_v4(); + + // Drop a non-maestro session onto the same tmux server. list_sessions + // should still report only the maestro-prefixed ones. + let out = Command::new("tmux") + .arg("-L") + .arg(fx.tmux.socket_name().unwrap()) + .env("TMUX_TMPDIR", fx.tmux.tmpdir().unwrap()) + .args(["new-session", "-d", "-s", "foreign", "-c"]) + .arg(cwd.path()) + .arg("sleep 30") + .output() + .expect("raw tmux"); + assert!(out.status.success(), "raw tmux failed: {out:?}"); + + fx.tmux + .new_session(maestro_id, cwd.path(), "sleep 30") + .expect("new_session"); + + wait_for(Duration::from_secs(3), || { + fx.tmux.list_sessions().unwrap() == vec![maestro_id] + }); +} + +#[test] +fn send_keys_and_pipe_pane_capture_output() { + let fx = TmuxFixture::new(); + let cwd = TempDir::new().unwrap(); + let sink = TempDir::new().unwrap(); + let sink_path = sink.path().join("out.log"); + let id = Uuid::new_v4(); + + fx.tmux + .new_session(id, cwd.path(), "bash --noprofile --norc") + .expect("new_session"); + + wait_for(Duration::from_secs(3), || { + fx.tmux.list_sessions().unwrap().contains(&id) + }); + + let sink_command = format!("cat >> {}", shell_quote(&sink_path.display().to_string())); + fx.tmux.pipe_pane(id, &sink_command).expect("pipe_pane"); + + fx.tmux + .send_keys(id, b"echo MAESTRO_MARKER_BANG\n") + .expect("send_keys"); + + wait_for(Duration::from_secs(5), || { + std::fs::read_to_string(&sink_path) + .map(|s| s.contains("MAESTRO_MARKER_BANG")) + .unwrap_or(false) + }); + + fx.tmux.kill_session(id).expect("kill_session"); +} + +#[test] +fn resize_window_succeeds() { + let fx = TmuxFixture::new(); + let cwd = TempDir::new().unwrap(); + let id = Uuid::new_v4(); + + fx.tmux + .new_session(id, cwd.path(), "sleep 30") + .expect("new_session"); + + wait_for(Duration::from_secs(3), || { + fx.tmux.list_sessions().unwrap().contains(&id) + }); + + fx.tmux.resize_window(id, 120, 40).expect("resize_window"); + + fx.tmux.kill_session(id).expect("kill_session"); +} + +#[test] +fn list_sessions_returns_empty_when_no_server() { + let fx = TmuxFixture::new(); + let sessions = fx.tmux.list_sessions().expect("list_sessions"); + assert!(sessions.is_empty()); +} + +fn shell_quote(s: &str) -> String { + let escaped = s.replace('\'', r#"'\''"#); + format!("'{escaped}'") +} diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs new file mode 100644 index 0000000..1087ac6 --- /dev/null +++ b/crates/maestro-daemon/tests/worktree.rs @@ -0,0 +1,103 @@ +//! Worktree-manager integration tests against a real `git` binary and a +//! tmpdir-backed source repo. + +mod common; + +use std::path::PathBuf; +use std::process::Command; + +use common::init_repo; +use maestro_daemon::worktree::WorktreeManager; +use tempfile::TempDir; + +fn worktree_branches(repo: &PathBuf) -> Vec { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(["worktree", "list", "--porcelain"]) + .output() + .expect("git worktree list"); + assert!(out.status.success()); + let s = String::from_utf8_lossy(&out.stdout); + s.lines() + .filter_map(|l| l.strip_prefix("branch refs/heads/").map(String::from)) + .collect() +} + +#[test] +fn add_creates_worktree_on_new_branch() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let wt_root = TempDir::new().unwrap(); + let mgr = WorktreeManager::new(wt_root.path()); + + let path = mgr.add(&repo, "feat/example").expect("add worktree"); + assert!(path.exists()); + assert!(path.join("README").exists()); + assert!(path.starts_with(wt_root.path())); + + let branches = worktree_branches(&repo); + assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}"); +} + +#[test] +fn add_twice_generates_distinct_paths() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let wt_root = TempDir::new().unwrap(); + let mgr = WorktreeManager::new(wt_root.path()); + + let p1 = mgr.add(&repo, "feat/a").expect("add a"); + let p2 = mgr.add(&repo, "feat/b").expect("add b"); + assert_ne!(p1, p2); + assert!(p1.exists()); + assert!(p2.exists()); +} + +#[test] +fn remove_prunes_worktree() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let wt_root = TempDir::new().unwrap(); + let mgr = WorktreeManager::new(wt_root.path()); + + let path = mgr.add(&repo, "feat/gone").expect("add"); + assert!(path.exists()); + + mgr.remove(&repo, &path).expect("remove"); + assert!(!path.exists()); + + let branches = worktree_branches(&repo); + // The branch itself still exists, but the worktree should be gone — only + // the base repo's main branch should show in `worktree list`. + assert_eq!(branches, vec!["main".to_string()]); +} + +#[test] +fn add_fails_when_branch_already_exists() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + // pre-create the branch + let status = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["branch", "feat/dup"]) + .status() + .unwrap(); + assert!(status.success()); + + let wt_root = TempDir::new().unwrap(); + let mgr = WorktreeManager::new(wt_root.path()); + + let err = mgr.add(&repo, "feat/dup").unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("git exited"), "{msg}"); +}