cargo-testf 0.1.0

A wrapper for `cargo test` that remembers and runs failed tests
use std::{
    io::{BufRead, BufReader, Error},
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

fn main() {
    let current_dir = std::env::current_dir().expect("get current dir");
    let out_dir = std::env::var("CARGO_TARGET_DIR")
        .map(|p| Path::new(&p).to_path_buf())
        .or(find_out_dir())
        .unwrap_or_default();
    let toml_file = find_cargo_toml()
        .expect("unable to find a Cargo.toml, command must run inside a rust project");
    let result_path = {
        let mut root = out_dir.clone();
        root.push(format!("testf-{}.txt", hash(&toml_file.to_string_lossy())));
        root
    };
    println!(
        "Current Dir: {}\nOut Dir: {}\nCargo file: {}\nResult file: {}",
        current_dir.display(),
        out_dir.display(),
        toml_file.display(),
        result_path.display()
    );

    let mut failed_tests = State::default();
    let mut args = std::env::args().skip(1).collect::<Vec<_>>();

    if result_path.exists() {
        let content =
            std::fs::read_to_string(result_path.clone()).expect("read name of failed tests");
        let failed = content.split(" ").collect::<Vec<_>>();
        if !args.contains(&"--".to_string()) {
            args.push("--".into());
        }
        for f in failed {
            args.push(f.to_string());
        }
        args.push("--exact".to_string());
    }

    let mut cmd = Command::new("cargo")
        .arg("test")
        .args(args)
        .stdout(Stdio::piped())
        .spawn()
        .expect("start cargo run");
    {
        let stdout = cmd.stdout.as_mut().expect("read stdout");
        let stdout_reader = BufReader::new(stdout);
        let stdout_lines = stdout_reader.lines();

        for line in stdout_lines {
            if let Ok(line) = line {
                failed_tests_s(&line, &mut failed_tests);
                println!("{}", line);
            }
        }
    }

    let result = cmd.wait().expect("run cargo test");

    if result.success() {
        if result_path.exists() {
            std::fs::remove_file(result_path).expect("remove result file");
        }
        return;
    }

    let failed = failed_tests.names.join(" ");
    std::fs::write(result_path, failed).expect("write failed tests");
}

fn hash(input: &str) -> u64 {
    const PRIME: u64 = 31;
    let mut hash: u64 = 0;

    for (_, c) in input.chars().enumerate() {
        hash = hash.wrapping_mul(PRIME).wrapping_add(c as u64);
    }

    hash
}

fn find_out_dir() -> std::io::Result<PathBuf> {
    let mut root = std::env::current_dir()?;

    loop {
        let p = root.join("target");
        if p.exists() {
            return Ok(p);
        }
        if !root.pop() {
            return Err(Error::new(
                std::io::ErrorKind::NotFound,
                "Can not find target folder",
            ));
        }
    }
}

fn find_cargo_toml() -> std::io::Result<PathBuf> {
    let mut root = std::env::current_dir()?;

    loop {
        let p = root.join("Cargo.toml");
        if p.exists() {
            return Ok(p);
        }
        if !root.pop() {
            return Err(Error::new(
                std::io::ErrorKind::NotFound,
                "Can not find target folder",
            ));
        }
    }
}

#[derive(Default, Debug)]
struct State {
    names: Vec<String>,
    f_count: u32,
}

fn failed_tests_s(l: &str, state: &mut State) {
    if l == "failures:" {
        state.f_count += 1;
    }

    if state.f_count == 2 && l.starts_with("    ") {
        let name = l.trim().clone();
        if name.len() > 0 {
            state.names.push(name.to_string());
        }
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn works() {
        let output = r#"
running 3 tests
test test::fail ... FAILED
test test::inner_test::fail ... FAILED
test test::pass ... ok

failures:

---- test::fail stdout ----
thread 'test::fail' panicked at 'fail', src/main.rs:18:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- test::inner_test::fail stdout ----
thread 'test::inner_test::fail' panicked at 'fail', src/main.rs:10:13


failures:
    test::fail
    test::inner_test::fail

test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
        "#;
        let mut f = State::default();
        for l in output.lines() {
            failed_tests_s(l, &mut f);
        }

        assert_eq!(f.names, &["test::fail", "test::inner_test::fail"]);
    }
}