ito_test_support/
lib.rs

1//! Test helpers for the Ito workspace.
2//!
3//! This crate provides small utilities used in integration tests and snapshot
4//! tests across the workspace. It is not intended for production code paths.
5
6#![warn(missing_docs)]
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10use std::process::{Command, Output};
11
12/// In-memory mock implementations of domain repository traits for unit testing.
13pub mod mock_repos;
14
15/// PTY helpers for driving interactive commands in tests.
16pub mod pty;
17
18/// Captured output from running a command in tests.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CmdOutput {
21    /// Process exit code (defaults to 1 when unavailable).
22    pub code: i32,
23    /// Captured stdout as UTF-8 (lossy).
24    pub stdout: String,
25    /// Captured stderr as UTF-8 (lossy).
26    pub stderr: String,
27}
28
29impl CmdOutput {
30    /// Return a version with normalized stdout/stderr.
31    ///
32    /// Normalization strips ANSI escapes, converts CRLF to LF, and replaces the
33    /// provided `home` path with `<HOME>` for deterministic snapshots.
34    pub fn normalized(&self, home: &Path) -> CmdOutput {
35        CmdOutput {
36            code: self.code,
37            stdout: normalize_text(&self.stdout, home),
38            stderr: normalize_text(&self.stderr, home),
39        }
40    }
41}
42
43/// Build a [`Command`] used to invoke the Rust candidate binary.
44///
45/// Tests use this to ensure a consistent base configuration before adding
46/// arguments and environment.
47pub fn rust_candidate_command(program: &Path) -> Command {
48    Command::new(program)
49}
50
51/// Executes the Ito candidate binary with the supplied arguments in a deterministic test environment and returns the captured output.
52///
53/// The command is run with a stable set of environment variables (e.g. color and interactivity disabled, HOME and XDG_DATA_HOME set) and with repository-scoped Git environment variables removed to make outputs suitable for snapshot testing.
54///
55/// Returns a `CmdOutput` containing the process exit code, captured `stdout`, and captured `stderr`.
56///
57/// # Examples
58///
59/// ```ignore
60/// use std::path::Path;
61///
62/// // Run the candidate binary and capture output for assertions or snapshots.
63/// let out = run_rust_candidate(
64///     Path::new("target/debug/ito"),
65///     &["--version"],
66///     Path::new("."),
67///     Path::new("/home/example"),
68/// );
69/// assert!(out.stdout.contains("ito"));
70/// ```
71pub fn run_rust_candidate(program: &Path, args: &[&str], cwd: &Path, home: &Path) -> CmdOutput {
72    let program = resolve_candidate_program(program);
73    let mut cmd = rust_candidate_command(&program);
74    cmd.args(args);
75    run_with_env(&mut cmd, cwd, home)
76}
77
78/// Resolve a usable path to an `ito` candidate executable.
79///
80/// Attempts, in order: return `program` if it exists; use `CARGO_BIN_EXE_ito` if it points to an existing path; scan the `deps` directory adjacent to `program` for a file whose name starts with `ito-`, is not a common build artifact (`.d`, `.rlib`, `.rmeta`, `.o`), and appears executable for the current platform; otherwise returns the original `program` path.
81///
82/// # Returns
83///
84/// A `PathBuf` pointing to the resolved candidate executable path; this may be the original `program` if no alternative is found.
85///
86/// # Examples
87///
88/// ```ignore
89/// use std::path::Path;
90/// // If ./target/debug/ito exists this returns that path unchanged.
91/// let p = resolve_candidate_program(Path::new("./target/debug/ito"));
92/// assert!(p.ends_with("ito") || p.ends_with("ito.exe"));
93/// ```
94fn resolve_candidate_program(program: &Path) -> PathBuf {
95    if program.exists() {
96        return program.to_path_buf();
97    }
98
99    if let Some(path) = std::env::var_os("CARGO_BIN_EXE_ito") {
100        let path = PathBuf::from(path);
101        if path.exists() {
102            return path;
103        }
104    }
105
106    let Some(parent) = program.parent() else {
107        return program.to_path_buf();
108    };
109    let deps = parent.join("deps");
110    let Ok(entries) = std::fs::read_dir(&deps) else {
111        return program.to_path_buf();
112    };
113
114    for entry in entries.flatten() {
115        let path = entry.path();
116        if !path.is_file() {
117            continue;
118        }
119        let Some(name) = path.file_name().and_then(|v| v.to_str()) else {
120            continue;
121        };
122        if !name.starts_with("ito-") {
123            continue;
124        }
125        if name.ends_with(".d")
126            || name.ends_with(".rlib")
127            || name.ends_with(".rmeta")
128            || name.ends_with(".o")
129        {
130            continue;
131        }
132        if !is_executable_candidate(&path) {
133            continue;
134        }
135        return path;
136    }
137
138    program.to_path_buf()
139}
140
141/// Returns whether the given path appears to be an executable on the current platform.
142///
143/// On Unix this requires the file to exist and have any executable permission bit set. On non-Unix platforms this accepts files with a case-insensitive `.exe` extension.
144///
145/// # Returns
146///
147/// `true` if the path points to a file that appears executable for the current platform, `false` otherwise.
148///
149/// # Examples
150///
151/// ```ignore
152/// # use std::path::Path;
153/// // Platform-dependent: on Unix this checks executable bits, on non-Unix this accepts `.exe`.
154/// let _ = is_executable_candidate(Path::new("ito"));
155/// ```
156fn is_executable_candidate(path: &Path) -> bool {
157    #[cfg(unix)]
158    {
159        use std::os::unix::fs::PermissionsExt;
160
161        let Ok(metadata) = std::fs::metadata(path) else {
162            return false;
163        };
164        metadata.permissions().mode() & 0o111 != 0
165    }
166
167    #[cfg(not(unix))]
168    {
169        path.extension()
170            .and_then(|ext| ext.to_str())
171            .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
172    }
173}
174
175/// Run a Command with a deterministic, test-friendly environment and return its captured output.
176///
177/// This configures the command's working directory and environment for deterministic test runs
178/// and clears repository-scoped Git environment variables so subprocesses do not inherit host
179/// repository state. The function executes the command and returns a `CmdOutput` containing
180/// the exit code, stdout, and stderr.
181///
182/// # Examples
183///
184/// ```ignore
185/// use std::process::Command;
186/// use std::path::Path;
187///
188/// let mut cmd = Command::new("echo");
189/// cmd.arg("hello");
190/// let out = crate::run_with_env(&mut cmd, Path::new("."), Path::new("/tmp"));
191/// assert!(out.stdout.contains("hello"));
192/// ```
193fn run_with_env(cmd: &mut Command, cwd: &Path, home: &Path) -> CmdOutput {
194    cmd.current_dir(cwd);
195
196    // Determinism knobs.
197    cmd.env("CI", "1");
198    cmd.env("NO_COLOR", "1");
199    cmd.env("ITO_INTERACTIVE", "0");
200    cmd.env("TERM", "dumb");
201    cmd.env("HOME", home);
202    cmd.env("XDG_CONFIG_HOME", home.join(".config"));
203    cmd.env("XDG_DATA_HOME", home);
204
205    // Hooks (for example, git pre-push) can export repository-scoped Git
206    // variables that break tests which create their own temporary repos.
207    // Clear them so each test process resolves Git context from `cwd`.
208    for key in [
209        "GIT_DIR",
210        "GIT_WORK_TREE",
211        "GIT_COMMON_DIR",
212        "GIT_INDEX_FILE",
213        "GIT_OBJECT_DIRECTORY",
214        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
215        "GIT_QUARANTINE_PATH",
216        "GIT_PREFIX",
217    ] {
218        cmd.env_remove(key);
219    }
220
221    let out = cmd
222        .output()
223        .unwrap_or_else(|e| panic!("failed to execute {:?}: {e}", cmd));
224    from_output(out)
225}
226
227fn from_output(out: Output) -> CmdOutput {
228    CmdOutput {
229        code: out.status.code().unwrap_or(1),
230        stdout: bytes_to_string(&out.stdout),
231        stderr: bytes_to_string(&out.stderr),
232    }
233}
234
235fn bytes_to_string(bytes: &[u8]) -> String {
236    String::from_utf8_lossy(bytes).to_string()
237}
238
239/// Normalize text for deterministic snapshots.
240///
241/// This strips ANSI escape codes, converts CRLF to LF, and replaces occurrences
242/// of the provided `home` path with `<HOME>`.
243pub fn normalize_text(input: &str, home: &Path) -> String {
244    let stripped = strip_ansi(input);
245    let newlines = stripped.replace("\r\n", "\n");
246    // Normalize temp HOME paths so snapshots are stable.
247    let home_norm = home.to_string_lossy();
248    newlines.replace(home_norm.as_ref(), "<HOME>")
249}
250
251/// Collect all file bytes under `root`, keyed by normalized relative paths.
252///
253/// Paths are normalized to use `/` separators so snapshots are stable across
254/// platforms.
255pub fn collect_file_bytes(root: &Path) -> BTreeMap<String, Vec<u8>> {
256    fn walk(base: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
257        let Ok(entries) = std::fs::read_dir(dir) else {
258            return;
259        };
260        for e in entries.flatten() {
261            let Ok(ft) = e.file_type() else {
262                continue;
263            };
264            let p = e.path();
265            if ft.is_dir() {
266                walk(base, &p, out);
267                continue;
268            }
269            if !ft.is_file() {
270                continue;
271            }
272            let rel = p
273                .strip_prefix(base)
274                .unwrap_or(&p)
275                .to_string_lossy()
276                .replace('\\', "/");
277            let bytes = std::fs::read(&p).unwrap_or_default();
278            out.insert(rel, bytes);
279        }
280    }
281
282    let mut out: BTreeMap<String, Vec<u8>> = BTreeMap::new();
283    walk(root, root, &mut out);
284    out
285}
286
287/// Replace the contents of `dst` with a recursive copy of `src`.
288///
289/// This is used in tests to reset a working directory to a known state without
290/// needing platform-specific `rm -rf` behavior.
291pub fn reset_dir(dst: &Path, src: &Path) -> std::io::Result<()> {
292    let Ok(entries) = std::fs::read_dir(dst) else {
293        return copy_dir_all(src, dst);
294    };
295    for e in entries.flatten() {
296        let path = e.path();
297        let Ok(ft) = e.file_type() else {
298            continue;
299        };
300        if ft.is_dir() {
301            let _ = std::fs::remove_dir_all(&path);
302        } else {
303            let _ = std::fs::remove_file(&path);
304        }
305    }
306    copy_dir_all(src, dst)
307}
308
309/// Recursively copy `from` to `to`.
310pub fn copy_dir_all(from: &Path, to: &Path) -> std::io::Result<()> {
311    std::fs::create_dir_all(to)?;
312
313    for entry in std::fs::read_dir(from)? {
314        let entry = entry?;
315        let ty = entry.file_type()?;
316        let src = entry.path();
317        let dst = to.join(entry.file_name());
318
319        if ty.is_dir() {
320            copy_dir_all(&src, &dst)?;
321        } else if ty.is_file() {
322            std::fs::copy(&src, &dst)?;
323        }
324    }
325
326    Ok(())
327}
328
329fn strip_ansi(input: &str) -> String {
330    let bytes = strip_ansi_escapes::strip(input.as_bytes());
331    bytes_to_string(&bytes)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use std::path::PathBuf;
338
339    #[test]
340    fn normalize_strips_ansi_and_crlf() {
341        let home = PathBuf::from("/tmp/home");
342        let input = "\u{1b}[31mred\u{1b}[0m\r\nnext\r\n";
343        let out = normalize_text(input, &home);
344        assert_eq!(out, "red\nnext\n");
345    }
346
347    #[test]
348    fn normalize_replaces_home_path() {
349        let home = PathBuf::from("/tmp/some/home");
350        let input = "path=/tmp/some/home/.ito";
351        let out = normalize_text(input, &home);
352        assert_eq!(out, "path=<HOME>/.ito");
353    }
354
355    #[test]
356    fn copy_dir_all_copies_nested_files() {
357        let src = tempfile::tempdir().expect("src");
358        let dst = tempfile::tempdir().expect("dst");
359
360        std::fs::create_dir_all(src.path().join("a/b")).unwrap();
361        std::fs::write(src.path().join("a/b/file.txt"), "hello").unwrap();
362
363        copy_dir_all(src.path(), dst.path()).unwrap();
364
365        let copied = std::fs::read_to_string(dst.path().join("a/b/file.txt")).unwrap();
366        assert_eq!(copied, "hello");
367    }
368}