1#![warn(missing_docs)]
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10use std::process::{Command, Output};
11
12pub mod mock_repos;
14
15pub mod pty;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CmdOutput {
21 pub code: i32,
23 pub stdout: String,
25 pub stderr: String,
27}
28
29impl CmdOutput {
30 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
43pub fn rust_candidate_command(program: &Path) -> Command {
48 Command::new(program)
49}
50
51pub 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
78fn 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
141fn 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
175fn run_with_env(cmd: &mut Command, cwd: &Path, home: &Path) -> CmdOutput {
194 cmd.current_dir(cwd);
195
196 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 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
239pub fn normalize_text(input: &str, home: &Path) -> String {
244 let stripped = strip_ansi(input);
245 let newlines = stripped.replace("\r\n", "\n");
246 let home_norm = home.to_string_lossy();
248 newlines.replace(home_norm.as_ref(), "<HOME>")
249}
250
251pub 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
287pub 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
309pub 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}