mod cli {
    use std::path::Path;
    use std::process::{Command, Stdio};
    use std::time::{SystemTime, Duration};

    use anyhow::Result;
    use test_dir::{TestDir, DirBuilder, FileType};
    use std::fs::File;

    // Bash scripts to pass to -c.
    // Avoid depending on external programs.
    const COUNT_INVOCATIONS: &str = r#"file=${1:?} lines=0; \
                                       printf '%s' '.' >> "$file"; \
                                       read < "$file"; \
                                       printf '%s' "${#REPLY}";"#;
    const PRINT_ARGS: &str = r#"args=("$@"); declare -p args;"#;
    const EXIT_WITH: &str = r#"exit "${1:?}";"#;
    const EXIT_WITH_ENV: &str = r#"exit "${EXIT_WITH:?}";"#;
    const AWAIT_AND_TOUCH: &str = r#"echo awaiting; \
                                     until [[ -e "${1:?}" ]]; do sleep .1; done; \
                                     echo > "${2:?}";"#;

    fn bkt<P: AsRef<Path>>(cache_dir: P) -> Command {
        let test_exe = std::env::current_exe().expect("Could not resolve test location");
        let dir = test_exe
            .parent().expect("Could not resolve test directory")
            .parent().expect("Could not resolve binary directory");
        let mut path = dir.join("bkt");
        if !path.exists() {
            path.set_extension("exe");
        }
        assert!(path.exists(), "Could not find bkt binary in {:?}", dir);
        let mut bkt = Command::new(&path);
        bkt.env("BKT_TMPDIR", cache_dir.as_ref().as_os_str());
        bkt
    }

    #[derive(Eq, PartialEq, Debug)]
    struct CmdResult {
        out: String,
        err: String,
        status: Option<i32>,
    }

    impl From<std::process::Output> for CmdResult {
        fn from(output: std::process::Output) -> Self {
            CmdResult {
                out: std::str::from_utf8(&output.stdout).unwrap().into(),
                err: std::str::from_utf8(&output.stderr).unwrap().into(),
                status: output.status.code()
            }
        }
    }

    fn run(cmd: &mut Command) -> CmdResult {
        cmd.output().unwrap().into()
    }

    fn succeed(cmd: &mut Command) -> String {
        let result = run(cmd);
        if cfg!(feature="debug") {
            if !result.err.is_empty() { eprintln!("stderr:\n{}", result.err); }
        } else {
            // debug writes to stderr, so don't bother checking it in that mode
            assert_eq!(result.err, "");
        }
        assert_eq!(result.status, Some(0));
        result.out
    }

    // Returns once the given file contains different contents than those provided. Panics if the
    // file does not change after ~5s.
    //
    // Note this could return immediately if the file already doesn't contain initial_contents
    // (e.g. if the given contents were wrong) because such a check could race. Do additional
    // checks prior to waiting if needed.
    fn wait_for_contents_to_change<P: AsRef<Path>>(file: P, initial_contents: &str) {
        for _ in 1..50 {
            if std::fs::read_to_string(&file).unwrap() != initial_contents { return; }
            std::thread::sleep(Duration::from_millis(100));
        }
        panic!("Contents of {} did not change", file.as_ref().to_string_lossy());
    }

    fn make_dir_stale<P: AsRef<Path>>(dir: P, age: Duration) -> Result<()> {
        debug_assert!(dir.as_ref().is_dir());
        let desired_time = SystemTime::now() - age;
        let stale_time = filetime::FileTime::from_system_time(desired_time);
        for entry in std::fs::read_dir(dir)? {
            let path = entry?.path();
            let last_modified = std::fs::metadata(&path)?.modified()?;

            if path.is_file() && last_modified > desired_time {
                filetime::set_file_mtime(&path, stale_time)?;
            } else if path.is_dir() {
                make_dir_stale(&path, age)?;
            }
        }
        Ok(())
    }

    fn make_file_stale<P: AsRef<Path>>(file: P, age: Duration) -> Result<()> {
        debug_assert!(file.as_ref().is_file());
        let desired_time = SystemTime::now() - age;
        let stale_time = filetime::FileTime::from_system_time(desired_time);
        filetime::set_file_mtime(&file, stale_time)?;
        Ok(())
    }

    fn join<A: Clone>(beg: &[A], tail: &[A]) -> Vec<A> {
        beg.iter().chain(tail).cloned().collect()
    }

    #[test]
    fn help() {
        let dir = TestDir::temp();
        let out = succeed(bkt(dir.path("cache")).arg("--help"));
        assert!(out.contains("bkt [OPTIONS] -- <COMMAND>..."));
    }

    #[test]
    fn cached() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let args = ["--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];
        let first_result = run(bkt(dir.path("cache")).args(args));

        for _ in 1..3 {
            let subsequent_result = run(bkt(dir.path("cache")).args(args));
            if cfg!(feature="debug") {
                assert_eq!(first_result.status, subsequent_result.status);
                assert_eq!(first_result.out, subsequent_result.out);
            } else {
                assert_eq!(first_result, subsequent_result);
            }
        }
    }

    #[test]
    fn cache_expires() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let args = ["--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];
        let first_result = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(first_result, "1");

        // Slightly stale is still cached
        make_dir_stale(dir.path("cache"), Duration::from_secs(10)).unwrap();
        let subsequent_result = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(first_result, subsequent_result);

        make_dir_stale(dir.path("cache"), Duration::from_secs(120)).unwrap();
        let after_stale_result = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(after_stale_result, "2");

        // Respects BKT_TTL env var (other tests cover --ttl)
        make_dir_stale(dir.path("cache"), Duration::from_secs(10)).unwrap();
        let env_result = succeed(bkt(dir.path("cache")).env("BKT_TTL", "5s").args(args));
        assert_eq!(env_result, "3");
    }

    #[test]
    fn cache_expires_separately() {
        let dir = TestDir::temp();
        let file1 = dir.path("file1");
        let file2 = dir.path("file2");
        let args1 = ["--ttl=10s", "--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file1.to_str().unwrap()];
        let args2 = ["--ttl=20s", "--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file2.to_str().unwrap()];

        // first invocation
        assert_eq!(succeed(bkt(dir.path("cache")).args(args1)), "1");
        assert_eq!(succeed(bkt(dir.path("cache")).args(args2)), "1");

        // second invocation, cached
        assert_eq!(succeed(bkt(dir.path("cache")).args(args1)), "1");
        assert_eq!(succeed(bkt(dir.path("cache")).args(args2)), "1");

        // only shorter TTL is invalidated
        make_dir_stale(dir.path("cache"), Duration::from_secs(15)).unwrap();
        assert_eq!(succeed(bkt(dir.path("cache")).args(args1)), "2");
        assert_eq!(succeed(bkt(dir.path("cache")).args(args2)), "1");
    }

    #[test]
    fn cache_hits_with_different_settings() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let args1 = ["--ttl=10s", "--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];
        let args2 = ["--ttl=20s", "--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];

        // despite different TTLs the invocation is still cached
        assert_eq!(succeed(bkt(dir.path("cache")).args(args1)), "1");
        assert_eq!(succeed(bkt(dir.path("cache")).args(args2)), "1");

        // the provided TTL is respected, though it was cached with a smaller TTL
        make_dir_stale(dir.path("cache"), Duration::from_secs(15)).unwrap();
        assert_eq!(succeed(bkt(dir.path("cache")).args(args2)), "1");

        // However the cache can be invalidated in the background using the older TTL
        make_dir_stale(dir.path("cache"), Duration::from_secs(60)).unwrap(); // ensure the following call triggers a cleanup
        succeed(bkt(dir.path("cache")).args(["--", "bash", "-c", "sleep 1"])); // trigger cleanup via a different command
        assert_eq!(succeed(bkt(dir.path("cache")).args(args1)), "2");
    }

    #[test]
    fn cache_refreshes_in_background() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let args = ["--stale=10s", "--ttl=20s", "--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];
        assert_eq!(succeed(bkt(dir.path("cache")).args(args)), "1");

        make_dir_stale(dir.path("cache"), Duration::from_secs(15)).unwrap();
        assert_eq!(succeed(bkt(dir.path("cache")).args(args)), "1");

        wait_for_contents_to_change(&file, ".");
        assert_eq!(std::fs::read_to_string(&file).unwrap(), "..");
        assert_eq!(succeed(bkt(dir.path("cache")).args(args)), "2");
    }

    #[test]
    fn discard_failures() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let cmd = format!("{} false;", COUNT_INVOCATIONS);
        let args = ["--discard-failures", "--", "bash", "-c", &cmd, "arg0", file.to_str().unwrap()];

        let result = run(bkt(dir.path("cache")).args(args));
        assert_eq!(result.out, "1");
        assert_eq!(result.status, Some(1));

        // Not cached
        let result = run(bkt(dir.path("cache")).args(args));
        assert_eq!(result.out, "2");
        assert_eq!(result.status, Some(1));
    }

    #[test]
    fn discard_failure_cached_separately() {
        let dir = TestDir::temp();

        let allow_args = ["--", "bash", "-c", EXIT_WITH_ENV, "arg0"];
        let discard_args = join(&["--discard-failures"], &allow_args);

        // without separate caches a --discard-failures invocation could return a previously-cached
        // failed result. In 0.5.4 and earlier this would mean result2.status == 14.
        let result1 = run(bkt(dir.path("cache")).args(allow_args).env("EXIT_WITH", "14"));
        assert_eq!(result1.status, Some(14));
        let result2 = run(bkt(dir.path("cache")).args(discard_args).env("EXIT_WITH", "0"));
        assert_eq!(result2.status, Some(0));
    }

    #[test]
    fn discard_failures_in_background() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let cmd = format!("{} ! \"${{FAIL:-false}}\";", COUNT_INVOCATIONS);
        let args = ["--ttl=20s", "--discard-failures", "--", "bash", "-c", &cmd, "arg0", file.to_str().unwrap()];
        let stale_args = join(&["--stale=10s"], &args);

        // Cache result normally
        assert_eq!(succeed(bkt(dir.path("cache")).args(args)), "1");

        // Cause cmd to fail and not be cached
        std::env::set_var("FAIL", "true");

        // returns cached result, but attempts to warm in the background
        make_dir_stale(dir.path("cache"), Duration::from_secs(15)).unwrap();
        assert_eq!(succeed(bkt(dir.path("cache")).args(&stale_args)), "1");

        // Verify command ran
        wait_for_contents_to_change(&file, ".");
        assert_eq!(std::fs::read_to_string(&file).unwrap(), "..");

        // But cached success is still returned
        assert_eq!(succeed(bkt(dir.path("cache")).args(args)), "1");
    }

    #[test]
    fn respects_cache_dir() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let args = ["--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];

        let first_call = succeed(bkt(dir.path("cache")).arg(format!("--cache-dir={}", dir.path("cache").display())).args(args));
        assert_eq!(first_call, "1");
        assert_eq!(first_call, succeed(bkt(dir.path("cache")).arg(format!("--cache-dir={}", dir.path("cache").display())).args(args)));

        let diff_cache = succeed(bkt(dir.path("cache")).arg(format!("--cache-dir={}", dir.path("new-cache").display())).args(args));
        assert_eq!(diff_cache, "2");

        let env_cache = succeed(bkt(dir.path("cache")).env("BKT_CACHE_DIR", dir.path("env-cache").as_os_str()).args(args));
        assert_eq!(env_cache, "3");
    }

    // https://github.com/dimo414/bkt/issues/9
    #[test]
    fn respects_relative_cache() {
        let dir = TestDir::temp();
        let cwd = dir.path("cwd");
        std::fs::create_dir(&cwd).unwrap();
        let file = dir.path("file");
        let args = ["--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];

        let first_call = succeed(bkt(dir.path("unused")).arg("--cache-dir=cache").args(args).current_dir(&cwd));
        assert_eq!(first_call, "1");
        assert_eq!(first_call, succeed(bkt(dir.path("unused")).arg("--cache-dir=cache").args(args).current_dir(&cwd)));
    }

    #[test]
    fn respects_cache_scope() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let args = ["--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];

        let first_call = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(first_call, "1");
        assert_eq!(first_call, succeed(bkt(dir.path("cache")).args(args)));

        let diff_scope = succeed(bkt(dir.path("cache"))
            .arg("--scope=foo").args(args));
        assert_eq!(diff_scope, "2");
        assert_eq!(diff_scope, succeed(bkt(dir.path("cache"))
            .arg("--scope=foo").args(args)));
        assert_eq!(diff_scope, succeed(bkt(dir.path("cache"))
            .env("BKT_SCOPE", "foo").args(args)));
    }

    #[test]
    fn respects_args() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let args = ["--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];

        let first_call = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(first_call, "1");
        assert_eq!(first_call, succeed(bkt(dir.path("cache")).args(args)));

        let diff_args = succeed(bkt(dir.path("cache")).args(args).arg("A B"));
        assert_eq!(diff_args, "2");

        let split_args = succeed(bkt(dir.path("cache")).args(args).args(["A", "B"]));
        assert_eq!(split_args, "3");
    }

    #[test]
    fn respects_cwd() {
        let dir = TestDir::temp()
            .create("dir1", FileType::Dir)
            .create("dir2", FileType::Dir);
        let args = ["--", "bash", "-c", "pwd"];
        let cwd_args = join(&["--cwd"], &args);

        let without_cwd_dir1 = succeed(bkt(dir.path("cache")).args(args).current_dir(dir.path("dir1")));
        let without_cwd_dir2 = succeed(bkt(dir.path("cache")).args(args).current_dir(dir.path("dir2")));
        assert!(without_cwd_dir1.trim().ends_with("/dir1"));
        assert!(without_cwd_dir2.trim().ends_with("/dir1")); // incorrect! cached too eagerly

        let cwd_dir1 = succeed(bkt(dir.path("cache")).args(&cwd_args).current_dir(dir.path("dir1")));
        let cwd_dir2 = succeed(bkt(dir.path("cache")).args(&cwd_args).current_dir(dir.path("dir2")));
        assert!(cwd_dir1.trim().ends_with("/dir1"));
        assert!(cwd_dir2.trim().ends_with("/dir2"));
    }

    #[test]
    #[cfg(not(feature = "debug"))] // See lib's bkt_tests::with_env
    fn respects_env() {
        let dir = TestDir::temp();
        let args = ["--", "bash", "-c", r#"printf 'foo:%s bar:%s baz:%s' "$FOO" "$BAR" "$BAZ""#];
        let env_args = join(&["--env=FOO", "--env=BAR"], &args);

        let without_env = succeed(bkt(dir.path("cache")).args(args)
            .env("FOO", "1").env("BAR", "1").env("BAZ", "1"));
        assert_eq!(without_env, succeed(bkt(dir.path("cache")).args(args)));
        // even if --env is set, if the vars are absent cache still hits earlier call
        assert_eq!(without_env, succeed(bkt(dir.path("cache")).args(&env_args)));

        let env = succeed(bkt(dir.path("cache")).args(&env_args)
            .env("FOO", "2").env("BAR", "2").env("BAZ", "2"));
        assert_eq!(env, "foo:2 bar:2 baz:2");
        let env = succeed(bkt(dir.path("cache")).args(&env_args)
            .env("FOO", "3").env("BAR", "2").env("BAZ", "3"));
        assert_eq!(env, "foo:3 bar:2 baz:3");
        let env = succeed(bkt(dir.path("cache")).args(&env_args)
            .env("FOO", "4").env("BAR", "4").env("BAZ", "4"));
        assert_eq!(env, "foo:4 bar:4 baz:4");
        let env = succeed(bkt(dir.path("cache")).args(&env_args)
            .env("FOO", "2").env("BAR", "2").env("BAZ", "5"));
        assert_eq!(env, "foo:2 bar:2 baz:2"); // BAZ doesn't invalidate cache
    }

    #[test]
    fn respects_modtime() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let watch_file = dir.path("watch");
        let args = ["--modtime", watch_file.to_str().unwrap(), "--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];
        let no_file_result = succeed(bkt(dir.path("cache")).args(args));
        // File absent is cached
        assert_eq!(no_file_result, "1");
        assert_eq!(no_file_result, succeed(bkt(dir.path("cache")).args(args)));

        // create a new file, invalidating cache
        File::create(&watch_file).unwrap();
        let new_file_result = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(new_file_result, "2");
        assert_eq!(new_file_result, succeed(bkt(dir.path("cache")).args(args)));

        // update the modtime, again invalidating the cache
        make_file_stale(&watch_file, Duration::from_secs(10)).unwrap();
        let old_file_result = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(old_file_result, "3");
        assert_eq!(old_file_result, succeed(bkt(dir.path("cache")).args(args)));
    }

    #[test]
    #[cfg(not(feature="debug"))]
    fn no_debug_output() {
        let dir = TestDir::temp();
        let args = ["--", "bash", "-c", "true"];

        // Not cached
        assert_eq!(run(bkt(dir.path("cache")).args(args)),
                   CmdResult { out: "".into(), err: "".into(), status: Some(0) });
        // Cached
        assert_eq!(run(bkt(dir.path("cache")).args(args)),
                   CmdResult { out: "".into(), err: "".into(), status: Some(0) });
    }

    #[test]
    #[cfg(feature="debug")]
    fn debug_output() {
        fn starts_with_bkt(s: &str) -> bool { s.lines().all(|l| l.starts_with("bkt: ")) }

        let miss_debug_re = regex::Regex::new(
            "bkt: state: \nbkt: lookup .* not found\nbkt: cleanup data .*\nbkt: cleanup keys .*\nbkt: store data .*\nbkt: store key .*\n").unwrap();
        let hit_debug_re = regex::Regex::new("bkt: lookup .* found\n").unwrap();

        let dir = TestDir::temp();
        let args = ["--", "bash", "-c", PRINT_ARGS, "arg0"];

        let miss = run(bkt(dir.path("cache")).args(args));
        assert!(starts_with_bkt(&miss.err), "{}", miss.err);
        assert!(miss_debug_re.is_match(&miss.err), "{}", miss.err);

        let hit = run(bkt(dir.path("cache")).args(args));
        assert!(starts_with_bkt(&hit.err), "{}", hit.err);
        assert!(hit_debug_re.is_match(&hit.err), "{}", hit.err);
    }

    #[test]
    fn output_preserved() {
        let dir = TestDir::temp();
        fn same_output(dir: &TestDir, args: &[&str]) {
            let bkt_args = ["--", "bash", "-c", PRINT_ARGS, "arg0"];
            // Second call will be cached
            assert_eq!(
                succeed(bkt(dir.path("cache")).args(bkt_args).args(args)),
                succeed(bkt(dir.path("cache")).args(bkt_args).args(args)));
        }

        same_output(&dir, &[]);
        same_output(&dir, &[""]);
        same_output(&dir, &["a", "b"]);
        same_output(&dir, &["a b"]);
        same_output(&dir, &["a b", "c"]);
    }

    #[test]
    #[cfg(not(feature="debug"))]
    fn sensitive_output() {
        let dir = TestDir::temp();
        let args = ["--", "bash", "-c", r"printf 'foo\0bar'; printf 'bar\0baz\n' >&2"];

        // Not cached
        let output = run(bkt(dir.path("cache")).args(args));
        assert_eq!(output,
                   CmdResult { out: "foo\u{0}bar".into(), err: "bar\u{0}baz\n".into(), status: Some(0) });
        // Cached
        assert_eq!(run(bkt(dir.path("cache")).args(args)), output);
    }

    #[test]
    fn exit_code_preserved() {
        let dir = TestDir::temp();
        let args = ["--", "bash", "-c", EXIT_WITH, "arg0"];

        assert_eq!(run(bkt(dir.path("cache")).args(args).arg("14")).status, Some(14));
        assert_eq!(run(bkt(dir.path("cache")).args(args).arg("14")).status, Some(14));
    }

    #[test]
    fn warm() {
        let dir = TestDir::temp();
        let await_file = dir.path("await");
        let touch_file = dir.path("touch");
        let args = ["--", "bash", "-c", AWAIT_AND_TOUCH, "arg0",
                    await_file.to_str().unwrap(), touch_file.to_str().unwrap()];
        let warm_args = join(&["--warm"], &args);

        let output = succeed(bkt(dir.path("cache")).args(warm_args));
        assert_eq!(output, "");
        assert!(!touch_file.exists());

        File::create(&await_file).unwrap(); // allow the bash process to terminate
        for _ in 0..10 {
            if touch_file.exists() { break; }
            std::thread::sleep(Duration::from_millis(200));
        }
        // This ensures the bash process has almost-completed, but it could still race with bkt actually
        // caching the result and creating a key file. If this proves flaky a more robust check would be
        // to inspect the keys directory.
        assert!(touch_file.exists());

        std::fs::remove_file(&await_file).unwrap(); // process would not terminate if run again
        let output = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(output, "awaiting\n");
    }

    #[test]
    fn force() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let args = ["--", "bash", "-c", COUNT_INVOCATIONS, "arg0", file.to_str().unwrap()];
        let args_force = join(&["--force"], &args);

        let output = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(output, "1");
        let output = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(output, "1");

        let output = succeed(bkt(dir.path("cache")).args(args_force));
        assert_eq!(output, "2");
        let output = succeed(bkt(dir.path("cache")).args(args));
        assert_eq!(output, "2");
    }

    #[test]
    fn concurrent_call_race() {
        let dir = TestDir::temp();
        let file = dir.path("file");
        let slow_count_invocations = format!(r#"sleep "0.5$RANDOM"; {}"#, COUNT_INVOCATIONS);
        let args = ["--", "bash", "-c", &slow_count_invocations, "arg0", file.to_str().unwrap()];
        println!("{:?}", args);

        let proc1 = bkt(dir.path("cache")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
        let proc2 = bkt(dir.path("cache")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
        let result1: CmdResult = proc1.wait_with_output().unwrap().into();
        if !cfg!(feature="debug") { assert_eq!(result1.err, ""); }
        assert_eq!(result1.status, Some(0));
        let result2: CmdResult = proc2.wait_with_output().unwrap().into();
        if !cfg!(feature="debug") { assert_eq!(result2.err, ""); }
        assert_eq!(result2.status, Some(0));

        assert_eq!(std::fs::read_to_string(&file).unwrap(), "..");
        assert!(result1.out == "2" || result2.out == "2"); // arbitrary which completes first
    }
}
