use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Output, Stdio};
const S: &str = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
const BUILTIN: &str = include_str!("../../default_config.yml");
const CLI_README: &str = include_str!("../README.md");
const CONFIG_ENV: &str = "VELOCI_CONFIG";
fn veloci(args: &[&str], stdin: &str) -> Output {
veloci_with(args, stdin, None)
}
fn veloci_with(args: &[&str], stdin: &str, config_env: Option<&str>) -> Output {
veloci_cmd(args, stdin, config_env, None, None)
}
fn veloci_in(dir: &Path, home: &Path, args: &[&str], stdin: &str) -> Output {
veloci_cmd(args, stdin, None, Some(dir), Some(home))
}
fn veloci_cmd(
args: &[&str],
stdin: &str,
config_env: Option<&str>,
current_dir: Option<&Path>,
home: Option<&Path>,
) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_veloci"));
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_remove(CONFIG_ENV);
if let Some(path) = config_env {
cmd.env(CONFIG_ENV, path);
}
if let Some(dir) = current_dir {
cmd.current_dir(dir);
}
if let Some(home) = home {
cmd.env("HOME", home);
}
let mut child = cmd.spawn().unwrap();
child
.stdin
.take()
.unwrap()
.write_all(stdin.as_bytes())
.unwrap();
child.wait_with_output().unwrap()
}
fn redact(args: &[&str], stdin: &str) -> Output {
veloci(&[&["redact"], args].concat(), stdin)
}
fn list(args: &[&str], stdin: &str) -> Output {
veloci(&[&["list"], args].concat(), stdin)
}
fn stdout(output: &Output) -> String {
String::from_utf8(output.stdout.clone()).unwrap()
}
fn stderr(output: &Output) -> String {
String::from_utf8(output.stderr.clone()).unwrap()
}
fn write_config(dir: &Path, edits: &[(impl AsRef<str>, impl AsRef<str>)], rules: &str) -> String {
write_named_config(dir, "veloci.yml", edits, rules)
}
fn write_named_config(
dir: &Path,
name: &str,
edits: &[(impl AsRef<str>, impl AsRef<str>)],
rules: &str,
) -> String {
let head = BUILTIN
.split_once("\nallow:\n")
.expect("the built-in configuration ends with the rule sections")
.0;
let mut source = head.to_owned();
for (from, to) in edits {
let (from, to) = (from.as_ref(), to.as_ref());
assert!(source.contains(from), "{from:?} is no longer in the file");
source = source.replacen(from, to, 1);
}
source.push('\n');
source.push_str(rules);
let path = dir.join(name);
fs::write(&path, source).unwrap();
path.to_str().unwrap().to_owned()
}
fn rules_config(dir: &Path, rules: &str) -> String {
write_config(dir, NO_EDITS, rules)
}
const NO_EDITS: &[(&str, &str)] = &[];
fn detector_config(dir: &Path, entry: &str) -> String {
write_config(dir, &[extra_detector(entry)], "")
}
fn pii_config(dir: &Path) -> String {
write_config(
dir,
&[extra_detector(
" - pii:email:\n allowlist: [\"noreply@\"]\n - pii:phone\n - pii:address",
)],
"",
)
}
fn extra_detector(entry: &str) -> (String, String) {
(
" - credential_key".to_owned(),
format!(" - credential_key\n\n{entry}"),
)
}
#[test]
fn redacts_stdin() {
let out = redact(&[], &format!("token {S}\n"));
assert!(out.status.success());
assert_eq!(stdout(&out), "token [REDACTED-1]\n");
}
#[test]
fn detects_format_from_file_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, format!("token: {S}\nsession_id: {S}\n")).unwrap();
let out = redact(&[path.to_str().unwrap()], "");
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
stdout(&out),
format!("token: \"[REDACTED-1]\"\nsession_id: {S}\n")
);
}
#[test]
fn raw_skips_format_detection() {
let input = format!("token: {S}\nsession_id: {S}\n");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, &input).unwrap();
let out = redact(&[path.to_str().unwrap(), "--raw"], "");
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
stdout(&out),
"token: [REDACTED-1]\nsession_id: [REDACTED-1]\n"
);
let sniffed = format!(r#"{{"token":"{S}","session_id":"{S}"}}"#);
let out = redact(&["--raw"], &sniffed);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
stdout(&out),
r#"{"token":"[REDACTED-1]","session_id":"[REDACTED-1]"}"#
);
let out = list(&["--json", "--raw"], &sniffed);
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(doc["format"], "text");
let out = redact(&["--raw", "-f", "json"], &sniffed);
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("cannot be used with"));
}
#[test]
fn allow_and_check() {
let dir = tempfile::tempdir().unwrap();
let input = format!("a {S}\nb DB_PASSWORD=hunter2\n");
let out = redact(&["--check"], &input);
assert_eq!(out.status.code(), Some(1));
assert_eq!(stdout(&out), "a [REDACTED-1]\nb DB_PASSWORD=[REDACTED-2]\n");
let one = rules_config(dir.path(), "allow:\n values: [hunter2]\n");
let out = redact(&["--config", &one], &input);
assert_eq!(stdout(&out), "a [REDACTED-1]\nb DB_PASSWORD=hunter2\n");
let both = write_config(
dir.path(),
NO_EDITS,
&format!("allow:\n values: [\"{S}\", hunter2]\n"),
);
let out = redact(&["--check", "--config", &both], &input);
assert_eq!(out.status.code(), Some(0));
assert_eq!(stdout(&out), input);
}
#[test]
fn list_shows_token_start_and_length_but_not_values() {
let dir = tempfile::tempdir().unwrap();
let out = list(&[], &format!("x {S}\n"));
assert!(out.status.success());
let table = stdout(&out);
let lines: Vec<&str> = table.lines().collect();
assert!(lines[0].starts_with("TOKEN"), "{table}");
for column in ["DETECTOR", "START", "LEN", "COUNT", "LOCATION"] {
assert!(lines[0].contains(column), "{table}");
}
assert!(!lines[0].contains("VALUE"), "{table}");
let row: Vec<&str> = lines[1].split_whitespace().collect();
assert_eq!(row[..6], ["[REDACTED-1]", "entropy", "2", "45", "1", "1:3"]);
assert!(!table.contains(S), "{table}");
assert!(out.stderr.is_empty());
let out = list(&["--show-value"], &format!("x {S}\n"));
let table = stdout(&out);
assert!(table.lines().next().unwrap().contains("VALUE"), "{table}");
assert!(table.contains(S), "{table}");
let allowed = write_config(
dir.path(),
NO_EDITS,
&format!("allow:\n values: [\"{S}\"]\n"),
);
let out = list(&["--config", &allowed], &format!("x {S}\n"));
assert!(stdout(&out).lines().nth(1).unwrap().ends_with("allowed"));
let out = list(&[], "nothing here\n");
assert_eq!(stdout(&out), "no redactions\n");
}
#[test]
fn list_check() {
let dir = tempfile::tempdir().unwrap();
let out = list(&["--check"], "DB_PASSWORD=hunter2");
assert_eq!(out.status.code(), Some(1));
let config = rules_config(dir.path(), "allow:\n values: [hunter2]\n");
let out = list(&["--check", "--config", &config], "DB_PASSWORD=hunter2");
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn json_list() {
let dir = tempfile::tempdir().unwrap();
let input = format!("{{\"to\":\"x\",\"token\":\"{S}\",\"again\":\"{S}\"}}");
let out = list(&["--json", "-f", "json"], &input);
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(doc["format"], "json");
let entry = &doc["redactions"][0];
assert_eq!(entry["id"], 1);
assert_eq!(entry["token"], "[REDACTED-1]");
assert_eq!(entry["detector"], "entropy");
assert_eq!(entry["start"], 19);
assert_eq!(entry["length"], 45);
assert_eq!(entry["line"], 1);
assert_eq!(entry["column"], 20);
assert_eq!(entry["field"], "token");
assert_eq!(entry["occurrences"], 2);
assert_eq!(entry["offsets"], serde_json::json!([19, 75]));
assert_eq!(entry["allowed"], false);
assert!(entry.get("value").is_none());
assert!(!stdout(&out).contains(S));
let allowed = write_config(
dir.path(),
NO_EDITS,
&format!("allow:\n values: [\"{S}\"]\n"),
);
let out = list(&["--json", "--show-value", "--config", &allowed], &input);
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(doc["redactions"][0]["value"], S);
assert_eq!(doc["redactions"][0]["allowed"], true);
}
#[test]
fn explicit_format_and_pii() {
let dir = tempfile::tempdir().unwrap();
let config = pii_config(dir.path());
let out = redact(
&["-f", "json", "--config", &config],
r#"{"to":"jane@corp.example","id":"x"}"#,
);
assert_eq!(stdout(&out), r#"{"to":"[REDACTED-1]","id":"x"}"#);
let out = redact(&["-f", "nope"], "");
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("unknown format"));
}
#[test]
fn entropy_thresholds_are_configuration() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"api_key":"production"}"#;
let out = redact(&["-f", "json"], input);
assert_eq!(stdout(&out), input);
let sensitive = write_config(
dir.path(),
&[("sensitive_threshold: 3.5", "sensitive_threshold: 3.0")],
"",
);
let out = redact(&["-f", "json", "--config", &sensitive], input);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), r#"{"api_key":"[REDACTED-1]"}"#);
let ordinary = write_config(dir.path(), &[("threshold: 4.5", "threshold: 3.0")], "");
let out = redact(&["--config", &ordinary], "production ");
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "[REDACTED-1] ");
}
#[test]
fn custom_regex_rules() {
let dir = tempfile::tempdir().unwrap();
let config = detector_config(dir.path(), " - regex:\n patterns: ['ACME_[0-9]{4}']");
let out = redact(&["--config", &config], "id ACME_1234\n");
assert_eq!(stdout(&out), "id [REDACTED-1]\n");
let listed = list(&["--json", "--config", &config], "id ACME_1234\n");
let doc: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
assert_eq!(doc["redactions"][0]["detector"], "regex");
let labelled = write_config(
dir.path(),
&[extra_detector(
" - regex:\n label: team\n patterns: ['TEAM-[0-9]{3}']",
)],
"",
);
let out = redact(&["--config", &labelled], "ref TEAM-123\n");
assert_eq!(stdout(&out), "ref [REDACTED-1]\n");
let listed = list(&["--json", "--config", &labelled], "ref TEAM-123\n");
let doc: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
assert_eq!(doc["redactions"][0]["detector"], "team");
let broken = detector_config(dir.path(), " - regex:\n patterns: ['unclosed(']");
let out = redact(&["--config", &broken], "");
assert_eq!(out.status.code(), Some(2));
assert!(
stderr(&out).contains("does not compile"),
"{}",
stderr(&out)
);
}
#[test]
fn custom_ruleset_replaces_bundled_rules() {
let dir = tempfile::tempdir().unwrap();
let rules = dir.path().join("rules.toml");
fs::write(&rules, "[[rules]]\nid = \"zz\"\nregex = '''ZZ[0-9]{4}'''\n").unwrap();
let config = write_config(
dir.path(),
&[(" - builtin:betterleaks", " - ./rules.toml")],
"",
);
let input = "ZZ1234 ghp_a1b2c1d2e1f2g1h2a1b2c1d2e1f2g1h2a1b2\n";
let out = redact(&["--config", &config], input);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
stdout(&out),
"[REDACTED-1] ghp_a1b2c1d2e1f2g1h2a1b2c1d2e1f2g1h2a1b2\n"
);
let listed = list(&["--json", "--config", &config], input);
let doc: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
assert_eq!(doc["redactions"][0]["detector"], "ruleset:zz");
}
#[test]
fn output_and_in_place() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("in.env");
let output = dir.path().join("out.env");
fs::write(&input, format!("KEY={S}\n")).unwrap();
let out = redact(
&[input.to_str().unwrap(), "-o", output.to_str().unwrap()],
"",
);
assert!(out.status.success());
assert!(out.stdout.is_empty());
assert_eq!(fs::read_to_string(&output).unwrap(), "KEY=[REDACTED-1]\n");
let out = redact(&[input.to_str().unwrap(), "--in-place"], "");
assert!(out.status.success());
let redacted = fs::read_to_string(&input).unwrap();
assert_eq!(redacted, "KEY=[REDACTED-1]\n");
let out = redact(&[input.to_str().unwrap(), "--check"], "");
assert_eq!(out.status.code(), Some(0));
assert_eq!(stdout(&out), redacted);
let out = redact(&["--in-place"], "");
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("--in-place needs a file"));
}
#[test]
fn invalid_structured_input_falls_back_to_text() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broken.json");
fs::write(&path, format!("{{ not json {S}")).unwrap();
let out = redact(&[path.to_str().unwrap()], "");
assert!(out.status.success());
assert!(stderr(&out).contains("treating input as plain text"));
assert_eq!(stdout(&out), "{ not json [REDACTED-1]");
}
#[test]
fn list_formats() {
let out = veloci(&["formats"], "");
let listing = stdout(&out);
for name in [
"json", "jsonl", "yaml", "toml", "xml", "hcl", "ini", "dotenv", "csv",
] {
assert!(
listing.lines().any(|l| l.starts_with(name)),
"{name} missing"
);
}
}
#[test]
fn man_prints_the_cli_readme() {
let out = veloci(&["man"], "");
assert!(out.status.success(), "{}", stderr(&out));
let manual = stdout(&out);
let expected: String = CLI_README
.split_inclusive('\n')
.filter(|line| !line.contains("<img"))
.collect();
assert_eq!(manual, expected);
assert!(!manual.contains("<img"));
assert!(out.stderr.is_empty());
let help = veloci(&["--help"], "");
assert!(help.status.success(), "{}", stderr(&help));
assert!(stdout(&help).contains("man"), "{}", stdout(&help));
}
#[test]
fn subcommand_is_required() {
let out = veloci(&[], "");
assert!(!out.status.success());
let out = veloci(&["--allow-by-key", "x"], "");
assert!(!out.status.success());
}
#[test]
fn rule_options_are_not_command_line_flags() {
for flag in [
"--allow-value",
"--allow-regex",
"--allow-path",
"--disallow-value",
"--disallow-regex",
"--disallow-path",
"--exclude-detector",
"--pii",
"--comments",
"--entropy-threshold",
"--rules-pack",
"--ruleset",
"--strict",
] {
let out = redact(&[flag, "x"], "");
assert_eq!(out.status.code(), Some(2), "{flag} is still accepted");
assert!(
stderr(&out).contains("unexpected argument"),
"{flag}: {}",
stderr(&out)
);
}
}
#[test]
fn comments_are_scanned_only_when_asked() {
let dir = tempfile::tempdir().unwrap();
let input = format!("# token {S}\nkey = \"ok\"\n");
let out = redact(&["-f", "toml"], &input);
assert_eq!(stdout(&out), input);
let config = write_config(dir.path(), &[("comments: false", "comments: true")], "");
let out = redact(&["-f", "toml", "--config", &config], &input);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "# token [REDACTED-1]\nkey = \"ok\"\n");
}
#[test]
fn path_rules() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"users":[{"ssn":"123-45-6789"}],"db":{"note":"hi"}}"#;
let any_ssn = detector_config(dir.path(), " - path:\n paths: [\"**.ssn\"]");
let out = redact(&["-f", "json", "--config", &any_ssn], input);
assert_eq!(
stdout(&out),
r#"{"users":[{"ssn":"[REDACTED-1]"}],"db":{"note":"hi"}}"#
);
let listed = list(&["--json", "-f", "json", "--config", &any_ssn], input);
let doc: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
assert_eq!(doc["redactions"][0]["detector"], "path");
assert_eq!(doc["redactions"][0]["path"], "users.ssn");
let guarded = format!(r#"{{"keep":{{"k":"{S}"}},"other":"{S}"}}"#);
let keep = rules_config(dir.path(), "allow:\n paths: [\"keep.**\"]\n");
let out = redact(&["-f", "json", "--config", &keep], &guarded);
assert_eq!(
stdout(&out),
format!(r#"{{"keep":{{"k":"{S}"}},"other":"[REDACTED-1]"}}"#)
);
}
#[test]
fn value_and_regex_rules() {
let dir = tempfile::tempdir().unwrap();
let value = detector_config(dir.path(), " - value:\n values: [Bluebird]");
let out = redact(&["--config", &value], "codename Bluebird\n");
assert_eq!(stdout(&out), "codename [REDACTED-1]\n");
let both = write_config(
dir.path(),
&[extra_detector(
" - regex:\n patterns: ['ACME-[0-9]{4}']",
)],
"allow:\n regexes: ['ACME-1234']\n",
);
let out = redact(&["--config", &both], "ACME-1234 and ACME-9999\n");
assert_eq!(stdout(&out), "ACME-1234 and [REDACTED-2]\n");
let partial = rules_config(dir.path(), "allow:\n regexes: ['sk-ant']\n");
let out = redact(&["--config", &partial], &format!("x {S}\n"));
assert_eq!(stdout(&out), "x [REDACTED-1]\n");
}
#[test]
fn rule_lists_take_several_entries() {
let dir = tempfile::tempdir().unwrap();
let config = rules_config(
dir.path(),
" - value:\n values: [alpha, gamma]\n - regex:\n patterns: ['A-[0-9]+', 'C-[0-9]+']",
);
let out = redact(&["--config", &config], "alpha beta gamma A-11 B-22 C-33\n");
assert_eq!(
stdout(&out),
"[REDACTED-1] beta [REDACTED-2] [REDACTED-3] B-22 [REDACTED-4]\n"
);
let doc = r#"{"a":"1","b":"2","c":"3"}"#;
let paths = detector_config(dir.path(), " - path:\n paths: [a, c]");
let out = redact(&["-f", "json", "--config", &paths], doc);
assert_eq!(
stdout(&out),
r#"{"a":"[REDACTED-1]","b":"2","c":"[REDACTED-2]"}"#
);
let spared = write_config(
dir.path(),
&[extra_detector(" - path:\n paths: [\"**\"]")],
"allow:\n paths: [a, c]\n",
);
let out = redact(&["-f", "json", "--config", &spared], doc);
assert_eq!(stdout(&out), r#"{"a":"1","b":"[REDACTED-1]","c":"3"}"#);
}
#[test]
fn a_detector_that_is_not_listed_does_not_run() {
let dir = tempfile::tempdir().unwrap();
let input = format!("{S} jane@corp.example\n");
let out = redact(&[], &input);
assert_eq!(stdout(&out), "[REDACTED-1] jane@corp.example\n");
let with_pii = pii_config(dir.path());
let out = redact(&["--config", &with_pii], &input);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "[REDACTED-1] [REDACTED-2]\n");
let unknown = write_config(
dir.path(),
&[(" - credentialed_uri", " - credentialed_url")],
"",
);
let out = redact(&["--config", &unknown], &input);
assert_eq!(out.status.code(), Some(2));
assert!(
stderr(&out).contains("credentialed_url"),
"{}",
stderr(&out)
);
}
#[test]
fn config_replaces_the_builtin_rules() {
let dir = tempfile::tempdir().unwrap();
let config = write_config(
dir.path(),
&[
("comments: false".to_owned(), "comments: true".to_owned()),
extra_detector(
" - path:\n paths: [\"**.customer\"]\n - regex:\n patterns: ['ACME-[0-9]{4}']",
),
],
"allow:\n paths: [\"build.**\"]\n",
);
let input = format!(
"{{\n // ref ACME-1234\n \"customer\": \"Big Co\",\n \"build\": {{\"k\": \"{S}\"}}\n}}"
);
let out = redact(&["--config", &config], &input);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
stdout(&out),
format!(
"{{\n // ref [REDACTED-1]\n \"customer\": \"[REDACTED-2]\",\n \"build\": {{\"k\": \"{S}\"}}\n}}"
)
);
}
#[test]
fn an_incomplete_config_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("partial.yml");
fs::write(&path, "allow:\n values: [hunter2]\n").unwrap();
let out = redact(&["--config", path.to_str().unwrap()], "");
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("formats"), "{}", stderr(&out));
fs::write(&path, "nonsense: 1\n").unwrap();
let out = redact(&["--config", path.to_str().unwrap()], "");
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("unknown field"), "{}", stderr(&out));
}
#[test]
fn config_resolves_rule_paths_relative_to_itself() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("rules")).unwrap();
fs::write(
dir.path().join("rules/team.toml"),
"[[rules]]\nid = \"t\"\nregex = '''TEAM-[0-9]{3}'''\n",
)
.unwrap();
let config = write_config(
dir.path(),
&[(" - builtin:betterleaks", " - rules/team.toml")],
"",
);
let out = Command::new(env!("CARGO_BIN_EXE_veloci"))
.args(["redact", "--config", &config])
.current_dir(std::env::temp_dir())
.env_remove(CONFIG_ENV)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map(|mut child| {
child
.stdin
.take()
.unwrap()
.write_all(b"ref TEAM-123\n")
.unwrap();
child.wait_with_output().unwrap()
})
.unwrap();
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "ref [REDACTED-1]\n");
}
#[test]
fn the_config_subcommand_prints_a_usable_starting_point() {
let out = veloci(&["config", "show"], "");
assert!(out.status.success(), "{}", stderr(&out));
let printed = stdout(&out);
assert_eq!(
printed, BUILTIN,
"the printed configuration is the real one"
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("copy.yml");
fs::write(&path, &printed).unwrap();
let input = format!(r#"{{"api_key":"{S}","session_id":"abc","note":"hi"}}"#);
let with_copy = redact(&["-f", "json", "--config", path.to_str().unwrap()], &input);
let with_builtin = redact(&["-f", "json"], &input);
assert!(with_copy.status.success(), "{}", stderr(&with_copy));
assert_eq!(stdout(&with_copy), stdout(&with_builtin));
}
#[test]
fn config_show_prints_a_given_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mine.yml");
fs::write(&path, "comments: true\n").unwrap();
let out = veloci(&["config", "show", "--config", path.to_str().unwrap()], "");
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "comments: true\n");
let out = veloci_with(&["config", "show"], "", Some(path.to_str().unwrap()));
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "comments: true\n");
let out = veloci(&["config", "show", path.to_str().unwrap()], "");
assert_eq!(out.status.code(), Some(2));
assert!(
stderr(&out).contains("unexpected argument"),
"{}",
stderr(&out)
);
}
#[test]
fn config_location_names_the_file_or_the_builtin() {
let out = veloci(&["config", "location"], "");
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "[builtin-default]\n");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mine.yml");
let out = veloci(
&["config", "location", "--config", path.to_str().unwrap()],
"",
);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), format!("{}\n", path.display()));
let out = veloci_with(&["config", "location"], "", Some(path.to_str().unwrap()));
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), format!("{}\n", path.display()));
}
#[test]
fn config_flag_overrides_the_environment() {
let dir = tempfile::tempdir().unwrap();
let from_env = dir.path().join("from-env.yml");
let from_flag = dir.path().join("from-flag.yml");
fs::write(&from_env, "from-env\n").unwrap();
fs::write(&from_flag, "from-flag\n").unwrap();
let out = veloci_with(
&["config", "show", "--config", from_flag.to_str().unwrap()],
"",
Some(from_env.to_str().unwrap()),
);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "from-flag\n");
let out = veloci_with(
&[
"config",
"location",
"--config",
from_flag.to_str().unwrap(),
],
"",
Some(from_env.to_str().unwrap()),
);
assert_eq!(stdout(&out), format!("{}\n", from_flag.display()));
}
#[test]
fn redact_reads_the_config_environment() {
let env_dir = tempfile::tempdir().unwrap();
let flag_dir = tempfile::tempdir().unwrap();
let env_config = rules_config(env_dir.path(), "allow:\n values: [hunter2]\n");
let input = "DB_PASSWORD=hunter2\n";
let out = veloci_with(&["redact"], input, Some(&env_config));
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), input);
let flag = rules_config(flag_dir.path(), "allow:\n values: []\n");
let out = veloci_with(&["redact", "--config", &flag], input, Some(&env_config));
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "DB_PASSWORD=[REDACTED-1]\n");
}
#[test]
fn discovers_veloci_yml_from_the_current_directory() {
let dir = tempfile::tempdir().unwrap();
let path = rules_config(dir.path(), "allow:\n values: [hunter2]\n");
let input = "DB_PASSWORD=hunter2\n";
let out = veloci_in(dir.path(), dir.path(), &["config", "location"], "");
assert!(out.status.success(), "{}", stderr(&out));
let located = stdout(&out);
assert_eq!(
fs::canonicalize(located.trim()).unwrap(),
fs::canonicalize(&path).unwrap()
);
let out = veloci_in(dir.path(), dir.path(), &["redact"], input);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), input);
let out = veloci_in(dir.path(), dir.path(), &["config", "show"], "");
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), fs::read_to_string(&path).unwrap());
}
#[test]
fn discovers_uppercase_name_by_walking_to_a_parent() {
let dir = tempfile::tempdir().unwrap();
let child = dir.path().join("src");
fs::create_dir(&child).unwrap();
write_named_config(
dir.path(),
"VELOCI.yml",
NO_EDITS,
"allow:\n values: [hunter2]\n",
);
let out = veloci_in(&child, dir.path(), &["config", "location"], "");
assert!(out.status.success(), "{}", stderr(&out));
let located = stdout(&out);
assert_ne!(located, "[builtin-default]\n");
assert!(
located.contains("veloci.yml") || located.contains("VELOCI.yml"),
"{located}"
);
let out = veloci_in(&child, dir.path(), &["redact"], "DB_PASSWORD=hunter2\n");
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "DB_PASSWORD=hunter2\n");
}
#[test]
fn command_line_and_environment_override_discovery() {
let dir = tempfile::tempdir().unwrap();
rules_config(dir.path(), "allow:\n values: [hunter2]\n");
let flag_dir = tempfile::tempdir().unwrap();
let env_dir = tempfile::tempdir().unwrap();
let from_flag = flag_dir.path().join("from-flag.yml");
let from_env = env_dir.path().join("from-env.yml");
fs::write(&from_flag, "from-flag\n").unwrap();
fs::write(&from_env, "from-env\n").unwrap();
let out = veloci_cmd(
&["config", "show", "--config", from_flag.to_str().unwrap()],
"",
None,
Some(dir.path()),
Some(dir.path()),
);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "from-flag\n");
let out = veloci_cmd(
&["config", "show"],
"",
Some(from_env.to_str().unwrap()),
Some(dir.path()),
Some(dir.path()),
);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "from-env\n");
let out = veloci_cmd(
&[
"config",
"location",
"--config",
from_flag.to_str().unwrap(),
],
"",
Some(from_env.to_str().unwrap()),
Some(dir.path()),
Some(dir.path()),
);
assert_eq!(stdout(&out), format!("{}\n", from_flag.display()));
}
#[test]
fn discovery_falls_back_to_the_builtin() {
let dir = tempfile::tempdir().unwrap();
let out = veloci_in(dir.path(), dir.path(), &["config", "location"], "");
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "[builtin-default]\n");
}
#[test]
fn config_validate_accepts_a_usable_file_and_rejects_a_broken_one() {
let out = veloci(&["config", "validate"], "");
assert!(out.status.success(), "{}", stderr(&out));
assert!(stdout(&out).is_empty());
let dir = tempfile::tempdir().unwrap();
let ok = dir.path().join("ok.yml");
fs::write(&ok, BUILTIN).unwrap();
let out = veloci(
&["config", "validate", "--config", ok.to_str().unwrap()],
"",
);
assert!(out.status.success(), "{}", stderr(&out));
let out = veloci_with(&["config", "validate"], "", Some(ok.to_str().unwrap()));
assert!(out.status.success(), "{}", stderr(&out));
let broken = dir.path().join("broken.yml");
fs::write(&broken, "allow:\n values: [hunter2]\n").unwrap();
let out = veloci(
&["config", "validate", "--config", broken.to_str().unwrap()],
"",
);
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("formats"), "{}", stderr(&out));
let unknown = dir.path().join("unknown.yml");
fs::write(&unknown, "nonsense: 1\n").unwrap();
let out = veloci_with(&["config", "validate"], "", Some(unknown.to_str().unwrap()));
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("unknown field"), "{}", stderr(&out));
let invalid = write_config(
dir.path(),
&[extra_detector(" - regex:\n patterns: ['unclosed(']")],
"allow:\n regexes: ['also(']\n",
);
let out = veloci(&["config", "validate", "--config", &invalid], "");
assert_eq!(out.status.code(), Some(2));
let err = stderr(&out);
assert!(err.contains("does not compile"), "{err}");
assert!(err.contains("allow-regex"), "{err}");
}
#[test]
fn config_requires_a_subcommand() {
let out = veloci(&["config"], "");
assert!(!out.status.success());
assert!(stderr(&out).contains("show"), "{}", stderr(&out));
}
#[test]
fn skipped_keys_are_configuration() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"session_id":"abc123"}"#;
let unreachable = detector_config(dir.path(), " - path:\n paths: [session_id]");
let out = redact(&["-f", "json", "--config", &unreachable], input);
assert_eq!(stdout(&out), input);
let reachable = write_config(
dir.path(),
&[
(
r#"skip_key_suffixes: ["signature", "id", "ids"]"#.to_owned(),
r#"skip_key_suffixes: ["signature"]"#.to_owned(),
),
extra_detector(" - path:\n paths: [session_id]"),
],
"",
);
let out = redact(&["-f", "json", "--config", &reachable], input);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), r#"{"session_id":"[REDACTED-1]"}"#);
}
fn agent_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".env"), "DB_PASSWORD=hunter2\n").unwrap();
fs::write(dir.path().join(".env.example"), "DB_PASSWORD=changeme\n").unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
dir
}
fn agent_init(dir: &Path, args: &[&str]) -> Output {
veloci_in(dir, dir, &[&["agent", "init"], args].concat(), "")
}
fn hook_input(cwd: &Path, tool: &str, key: &str, file: &str) -> String {
serde_json::json!({
"hook_event_name": "PreToolUse",
"tool_name": tool,
"cwd": cwd,
"tool_input": { key: file },
})
.to_string()
}
#[test]
fn agent_status_reports_an_unconfigured_project() {
let dir = agent_repo();
let out = veloci_in(dir.path(), dir.path(), &["agent", "status", "--json"], "");
assert!(out.status.success(), "{}", stderr(&out));
let status: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(status["configured"], false);
assert_eq!(status["config"], serde_json::Value::Null);
rules_config(dir.path(), "");
let out = veloci_in(dir.path(), dir.path(), &["agent", "status"], "");
assert!(out.status.success(), "{}", stderr(&out));
assert!(stdout(&out).contains("not configured"), "{}", stdout(&out));
}
#[test]
fn agent_init_writes_a_complete_configuration() {
let dir = agent_repo();
let out = agent_init(
dir.path(),
&[
"--protect",
".env*",
"--protect",
"*.pem",
"--exclude",
".env.example",
"--enforce",
],
);
assert!(out.status.success(), "{}", stderr(&out));
let written = fs::read_to_string(dir.path().join("veloci.yml")).unwrap();
assert!(
written.starts_with(BUILTIN),
"the built-in rules are kept, comments and all"
);
let out = veloci_in(dir.path(), dir.path(), &["agent", "status", "--json"], "");
let status: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(status["configured"], true);
assert_eq!(status["protected"], serde_json::json!([".env*", "*.pem"]));
assert_eq!(status["exclude"], serde_json::json!([".env.example"]));
assert_eq!(status["enforce"], true);
let out = veloci_in(dir.path(), dir.path(), &["redact", ".env"], "");
assert_eq!(stdout(&out), "DB_PASSWORD=[REDACTED-1]\n");
let out = veloci_in(dir.path(), dir.path(), &["config", "validate"], "");
assert!(out.status.success(), "{}", stderr(&out));
}
#[test]
fn agent_init_from_a_subdirectory_writes_at_the_repository_root() {
let dir = agent_repo();
let out = agent_init(&dir.path().join("src"), &["--protect", ".env"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(dir.path().join("veloci.yml").is_file());
assert!(!dir.path().join("src/veloci.yml").exists());
}
#[test]
fn agent_init_appends_to_an_existing_configuration() {
let dir = agent_repo();
rules_config(dir.path(), "allow:\n values: [hunter2]\n");
let out = agent_init(dir.path(), &["--protect", ".env"]);
assert!(out.status.success(), "{}", stderr(&out));
let written = fs::read_to_string(dir.path().join("veloci.yml")).unwrap();
assert!(
written.contains("values: [hunter2]"),
"existing rules are kept"
);
let out = veloci_in(dir.path(), dir.path(), &["redact", ".env"], "");
assert_eq!(stdout(&out), "DB_PASSWORD=hunter2\n");
}
#[test]
fn agent_init_refuses_to_replace_an_agent_section() {
let dir = agent_repo();
assert!(
agent_init(dir.path(), &["--protect", ".env"])
.status
.success()
);
let before = fs::read_to_string(dir.path().join("veloci.yml")).unwrap();
let out = agent_init(dir.path(), &["--protect", "*.pem"]);
assert_eq!(out.status.code(), Some(2));
assert!(
stderr(&out).contains("already has an agent section"),
"{}",
stderr(&out)
);
let after = fs::read_to_string(dir.path().join("veloci.yml")).unwrap();
assert_eq!(before, after);
let out = agent_init(dir.path(), &[]);
assert_eq!(out.status.code(), Some(2), "--protect is required");
}
#[test]
fn agent_check_exits_1_for_protected_files() {
let dir = agent_repo();
assert!(
agent_init(
dir.path(),
&["--protect", ".env*", "--exclude", ".env.example"]
)
.status
.success()
);
let out = veloci_in(
dir.path(),
dir.path(),
&["agent", "check", ".env", ".env.example", "src/main.rs"],
"",
);
assert_eq!(out.status.code(), Some(1));
assert_eq!(stdout(&out), ".env\n");
let out = veloci_in(
dir.path(),
dir.path(),
&["agent", "check", "src/main.rs"],
"",
);
assert_eq!(out.status.code(), Some(0));
assert_eq!(stdout(&out), "");
let elsewhere = tempfile::tempdir().unwrap();
let env = dir.path().join(".env");
let out = veloci_in(
elsewhere.path(),
elsewhere.path(),
&["agent", "check", env.to_str().unwrap()],
"",
);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
}
#[test]
fn agent_check_protects_nothing_without_an_agent_section() {
let dir = agent_repo();
let out = veloci_in(dir.path(), dir.path(), &["agent", "check", ".env"], "");
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
}
#[test]
fn agent_hook_denies_protected_reads_only_when_enforced() {
let dir = agent_repo();
assert!(
agent_init(dir.path(), &["--protect", ".env", "--enforce"])
.status
.success()
);
let other = tempfile::tempdir().unwrap();
for (tool, key, instead) in [
("Read", "file_path", "veloci redact"),
("Grep", "path", "veloci grep"),
] {
let out = veloci_in(
other.path(),
other.path(),
&["agent", "hook"],
&hook_input(dir.path(), tool, key, ".env"),
);
assert!(out.status.success(), "{}", stderr(&out));
let reason = hook_denial(&out).expect("the read is denied");
assert!(reason.contains(instead), "{reason}");
}
let out = veloci_in(
dir.path(),
dir.path(),
&["agent", "hook"],
&hook_input(dir.path(), "Read", "file_path", "src/main.rs"),
);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "", "unprotected files are allowed silently");
let unenforced = agent_repo();
assert!(
agent_init(unenforced.path(), &["--protect", ".env"])
.status
.success()
);
let out = veloci_in(
unenforced.path(),
unenforced.path(),
&["agent", "hook"],
&hook_input(unenforced.path(), "Read", "file_path", ".env"),
);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "");
}
fn hook_denial(out: &Output) -> Option<String> {
if stdout(out).is_empty() {
return None;
}
let decision: serde_json::Value = serde_json::from_str(&stdout(out)).unwrap();
let output = &decision["hookSpecificOutput"];
assert_eq!(output["hookEventName"], "PreToolUse");
assert_eq!(output["permissionDecision"], "deny");
Some(
output["permissionDecisionReason"]
.as_str()
.unwrap()
.to_owned(),
)
}
#[test]
fn agent_hook_denies_searching_a_directory_holding_protected_files() {
let dir = agent_repo();
assert!(
agent_init(dir.path(), &["--protect", ".env", "--enforce"])
.status
.success()
);
let grep = |input: serde_json::Value| {
let input = serde_json::json!({
"hook_event_name": "PreToolUse",
"tool_name": "Grep",
"cwd": dir.path(),
"tool_input": input,
});
veloci_in(
dir.path(),
dir.path(),
&["agent", "hook"],
&input.to_string(),
)
};
let out = grep(serde_json::json!({ "pattern": "DB_", "path": "." }));
assert!(out.status.success(), "{}", stderr(&out));
let reason = hook_denial(&out).expect("the search is denied");
assert!(reason.contains("veloci grep DB_ "), "{reason}");
assert!(
reason.contains(".env"),
"names the protected file: {reason}"
);
let out = grep(serde_json::json!({ "pattern": "DB_" }));
assert!(hook_denial(&out).is_some(), "{}", stderr(&out));
let out = grep(serde_json::json!({ "pattern": "main", "path": "src" }));
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(hook_denial(&out), None, "src holds nothing protected");
fs::write(dir.path().join(".gitignore"), ".env\n").unwrap();
let out = grep(serde_json::json!({ "pattern": "DB_", "path": "." }));
assert_eq!(hook_denial(&out), None);
}
#[test]
fn agent_hook_failures_do_not_block() {
let dir = agent_repo();
let out = veloci_in(dir.path(), dir.path(), &["agent", "hook"], "not json");
assert_eq!(out.status.code(), Some(1));
fs::write(dir.path().join("veloci.yml"), "nonsense: 1\n").unwrap();
let out = veloci_in(
dir.path(),
dir.path(),
&["agent", "hook"],
&hook_input(dir.path(), "Read", "file_path", ".env"),
);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
}
fn grep_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join(".git")).unwrap();
fs::create_dir(root.join("sub")).unwrap();
fs::write(
root.join("sub/config.yml"),
format!("db:\n host: prod.internal\n api_key: \"{S}\"\n user: admin\n"),
)
.unwrap();
fs::write(root.join(".env"), format!("ANTHROPIC_KEY={S}\nOTHER=1\n")).unwrap();
fs::write(root.join("readme.txt"), "nothing here\n").unwrap();
fs::write(root.join(".gitignore"), "ignored.txt\n").unwrap();
fs::write(root.join("ignored.txt"), "api_key in an ignored file\n").unwrap();
dir
}
fn grep_in(dir: &Path, args: &[&str]) -> Output {
veloci_in(dir, dir, &[&["grep"], args].concat(), "")
}
#[test]
fn grep_prints_matches_from_redacted_text() {
let dir = grep_repo();
let out = grep_in(dir.path(), &["-C1", "api_key"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
stdout(&out),
"sub/config.yml-2- host: prod.internal\n\
sub/config.yml:3: api_key: \"[REDACTED-1]\"\n\
sub/config.yml-4- user: admin\n"
);
}
#[test]
fn grep_for_a_secret_finds_nothing() {
let dir = grep_repo();
for args in [
&["--hidden", S][..],
&["--hidden", "-F", "-e", "sk-ant-api03"],
&["-o", "--hidden", "sk-ant-[a-z0-9-]+"],
] {
let out = grep_in(dir.path(), args);
assert_eq!(out.status.code(), Some(1), "{args:?}: {}", stderr(&out));
assert_eq!(stdout(&out), "", "{args:?}");
}
}
#[test]
fn grep_output_never_holds_a_secret() {
let dir = grep_repo();
for args in [
&["--hidden", "-e", "."][..],
&["--hidden", "-o", "-e", "=.*"],
&["--hidden", "--json", "KEY|key"],
&["--hidden", "-v", "zzz"],
] {
let out = grep_in(dir.path(), args);
assert!(out.status.success(), "{args:?}: {}", stderr(&out));
let text = stdout(&out);
assert!(!text.contains(S), "{args:?}: {text}");
assert!(text.contains("[REDACTED-1]"), "{args:?}: {text}");
}
}
#[test]
fn grep_summary_modes() {
let dir = grep_repo();
let out = grep_in(dir.path(), &["-l", "--hidden", "-e", "="]);
assert_eq!(stdout(&out), ".env\n");
let out = grep_in(dir.path(), &["--files-without-match", "sk-ant"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "readme.txt\nsub/config.yml\n");
let out = grep_in(dir.path(), &["-c", "host|user", "sub/config.yml"]);
assert_eq!(stdout(&out), "2\n");
let out = grep_in(dir.path(), &["-q", "host"]);
assert_eq!(out.status.code(), Some(0));
assert_eq!(stdout(&out), "");
let out = grep_in(dir.path(), &["-q", "absent"]);
assert_eq!(out.status.code(), Some(1));
}
#[test]
fn grep_honors_ignore_files_and_hidden_files() {
let dir = grep_repo();
let out = grep_in(dir.path(), &["-l", "-e", "."]);
assert_eq!(stdout(&out), "readme.txt\nsub/config.yml\n");
let out = grep_in(dir.path(), &["-l", "--no-ignore", "ignored"]);
assert_eq!(stdout(&out), "ignored.txt\n");
let out = grep_in(dir.path(), &["-l", "-g", "*.yml", "-e", "."]);
assert_eq!(stdout(&out), "sub/config.yml\n");
}
#[test]
fn grep_reads_standard_input() {
let out = veloci(&["grep", "token", "-"], &format!("token: {S}\nother\n"));
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "1:token: [REDACTED-1]\n");
}
#[test]
fn grep_needs_a_pattern() {
let dir = grep_repo();
let out = grep_in(dir.path(), &[]);
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("no pattern"), "{}", stderr(&out));
}
#[test]
fn grep_reports_unreadable_paths() {
let dir = grep_repo();
let out = grep_in(dir.path(), &["host", "missing.txt", "sub"]);
assert_eq!(out.status.code(), Some(2));
assert_eq!(stdout(&out), "sub/config.yml:2: host: prod.internal\n");
}
#[test]
fn grep_stops_at_a_broken_configuration() {
let dir = grep_repo();
fs::write(dir.path().join("sub/veloci.yml"), "nonsense: 1\n").unwrap();
fs::create_dir(dir.path().join("zzz")).unwrap();
fs::write(dir.path().join("zzz/later.txt"), "host\n").unwrap();
let out = grep_in(dir.path(), &["-e", "."]);
assert_eq!(out.status.code(), Some(2));
assert_eq!(
stdout(&out),
"readme.txt:1:nothing here\n",
"nothing after the failure"
);
let err = stderr(&out);
assert!(err.contains("veloci.yml"), "{err}");
assert_eq!(err.matches("error:").count(), 1, "{err}");
}
#[test]
fn grep_loads_a_configuration_only_when_a_file_using_it_matches() {
let dir = grep_repo();
fs::write(dir.path().join("sub/veloci.yml"), "nonsense: 1\n").unwrap();
let out = grep_in(dir.path(), &["nothing"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "readme.txt:1:nothing here\n");
assert_eq!(stderr(&out), "");
}
#[test]
fn grep_redacts_each_file_by_its_own_configuration() {
let dir = grep_repo();
fs::write(dir.path().join("secret.txt"), format!("api_key: \"{S}\"\n")).unwrap();
write_config(
&dir.path().join("sub"),
NO_EDITS,
&format!("allow:\n values: [\"{S}\"]\n"),
);
let out = grep_in(dir.path(), &["-g", "!veloci.yml", "api_key"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(
stdout(&out),
format!("secret.txt:1:api_key: \"[REDACTED-1]\"\nsub/config.yml:3: api_key: \"{S}\"\n")
);
}
#[test]
fn grep_prints_files_in_path_order() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
let mut want = String::new();
for i in 0..300 {
let name = format!("f{i:03}.env");
fs::write(dir.path().join(&name), format!("KEY={S}\nline {i}\n")).unwrap();
want.push_str(&format!("{name}:1:KEY=[REDACTED-1]\n{name}-2-line {i}\n"));
if i < 299 {
want.push_str("--\n");
}
}
let out = grep_in(dir.path(), &["-A1", "KEY"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), want);
}
#[test]
fn agent_status_suggests_candidates_by_name() {
let dir = agent_repo();
fs::write(dir.path().join(".gitignore"), ".env\n").unwrap();
fs::create_dir_all(dir.path().join("node_modules/pkg")).unwrap();
fs::write(dir.path().join("node_modules/pkg/test.pem"), "").unwrap();
let out = veloci_in(dir.path(), dir.path(), &["agent", "status"], "");
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
assert!(text.contains("not configured"), "{text}");
assert!(text.contains("--protect '.env*'"), "{text}");
assert!(text.contains("--exclude .env.example"), "{text}");
assert!(
!text.contains("*.pem"),
"dependency directories are skipped: {text}"
);
assert!(text.contains("veloci agent init"), "{text}");
assert!(text.contains("veloci agent skill setup"), "{text}");
let out = veloci_in(dir.path(), dir.path(), &["agent", "status", "--json"], "");
let status: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
let env = status["suggested"]
.as_array()
.unwrap()
.iter()
.find(|c| c["pattern"] == ".env*")
.expect(".env* is suggested");
assert_eq!(env["flag"], "--protect");
assert!(env["examples"].as_array().unwrap().contains(&".env".into()));
assert!(
agent_init(dir.path(), &["--protect", ".env"])
.status
.success()
);
let out = veloci_in(dir.path(), dir.path(), &["agent", "status", "--json"], "");
let status: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(status["suggested"], serde_json::json!([]));
}
#[test]
fn agent_init_without_patterns_explains_what_to_do() {
let dir = agent_repo();
let out = agent_init(dir.path(), &[]);
assert_eq!(out.status.code(), Some(2));
let text = stderr(&out);
assert!(text.contains("--protect GLOB"), "{text}");
assert!(
text.contains("--protect '.env*'"),
"lists candidates: {text}"
);
assert!(text.contains("ask the user"), "{text}");
assert!(!dir.path().join("veloci.yml").exists());
let out = veloci(&["agent", "init", "--help"], "");
assert!(out.status.success());
assert!(
stdout(&out).contains(".gitignore conventions"),
"{}",
stdout(&out)
);
}
#[test]
fn agent_skill_prints_the_skills() {
let skill = |args: &[&str]| veloci(&[&["agent", "skill"], args].concat(), "");
let main = include_str!("../skills/veloci/SKILL.md");
let out = skill(&[]);
assert!(out.status.success(), "{}", stderr(&out));
let list = stdout(&out);
for name in ["veloci-setup", "veloci-config", "veloci-share"] {
assert!(list.contains(name), "{list}");
}
assert!(list.contains("veloci agent skill NAME"), "{list}");
assert!(list.contains("veloci agent skill veloci\n"), "{list}");
assert_eq!(stdout(&skill(&["veloci"])), main);
let setup = include_str!("../skills/veloci-setup/SKILL.md");
assert_eq!(stdout(&skill(&["setup"])), setup);
assert_eq!(stdout(&skill(&["veloci-setup"])), setup);
let config = stdout(&skill(&["config"]));
assert!(config.starts_with(include_str!("../skills/veloci-config/SKILL.md")));
assert!(config.contains("# veloci.yml reference"), "{config}");
let out = skill(&["nope"]);
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("veloci-share"), "{}", stderr(&out));
}
#[test]
fn embedded_skills_match_the_plugin() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let plugin = root.join("../plugin/skills");
if !plugin.exists() {
return;
}
fn files(dir: &Path, base: &Path, out: &mut Vec<(String, Vec<u8>)>) {
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
files(&path, base, out);
} else {
let name = path.strip_prefix(base).unwrap().display().to_string();
out.push((name, fs::read(&path).unwrap()));
}
}
}
let (mut want, mut have) = (Vec::new(), Vec::new());
files(&plugin, &plugin, &mut want);
files(&root.join("skills"), &root.join("skills"), &mut have);
want.sort();
have.sort();
let names = |v: &[(String, Vec<u8>)]| v.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>();
assert_eq!(
names(&have),
names(&want),
"cp -R plugin/skills/. cli/skills/"
);
for ((name, a), (_, b)) in have.iter().zip(&want) {
assert!(
a == b,
"cli/skills/{name} differs from plugin/skills; copy it over"
);
}
}
fn scan_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join(".git")).unwrap();
fs::write(root.join(".gitignore"), ".env\n").unwrap();
fs::write(root.join(".env"), format!("ANTHROPIC_API_KEY={S}\n")).unwrap();
fs::create_dir(root.join("config")).unwrap();
fs::write(
root.join("config/settings.yml"),
format!("db:\n host: prod.internal\n api_key: \"{S}\"\n"),
)
.unwrap();
fs::create_dir(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(root.join("src/blob.bin"), format!("\0\0{S}\n")).unwrap();
fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
fs::write(root.join("node_modules/pkg/.env"), format!("KEY={S}\n")).unwrap();
dir
}
fn scan_in(dir: &Path, args: &[&str]) -> Output {
veloci_in(dir, dir, &[&["scan"], args].concat(), "")
}
#[test]
fn scan_lists_files_with_secrets_without_their_values() {
let dir = scan_repo();
let out = scan_in(dir.path(), &[]);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
let text = stdout(&out);
assert!(!text.contains(S), "{text}");
let files: Vec<&str> = text
.lines()
.skip(1)
.map(|line| line.split_whitespace().next().unwrap())
.collect();
assert_eq!(files, [".env", "config/settings.yml"], "{text}");
assert!(stderr(&out).contains("scanned 4 files"), "{}", stderr(&out));
let out = scan_in(dir.path(), &["-l"]);
assert_eq!(out.status.code(), Some(1));
assert_eq!(stdout(&out), ".env\nconfig/settings.yml\n");
}
#[test]
fn scan_prints_json() {
let dir = scan_repo();
let out = scan_in(dir.path(), &["--json", "config"]);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
assert!(!stdout(&out).contains(S));
let report: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
let files = report["files"].as_array().unwrap();
assert_eq!(files.len(), 1, "{report}");
let file = &files[0];
assert_eq!(file["path"], "config/settings.yml");
assert_eq!(file["count"], 1);
assert_eq!(file["protected"], false);
assert_eq!(file["findings"][0]["line"], 3);
assert!(file["findings"][0].get("value").is_none());
assert!(!file["detectors"].as_array().unwrap().is_empty());
assert_eq!(report["scanned"], 1);
}
#[test]
fn scan_show_value_prints_the_secrets() {
let dir = scan_repo();
let out = scan_in(dir.path(), &["--show-value", "config"]);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
let text = stdout(&out);
let header = text.lines().next().unwrap();
assert!(header.contains("VALUE"), "{text}");
assert!(text.contains(S), "{text}");
let out = scan_in(dir.path(), &["--json", "--show-value", "config"]);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
let report: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(report["files"][0]["findings"][0]["value"], S);
}
#[test]
fn allowed_files_are_never_redacted() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join(".git")).unwrap();
fs::create_dir_all(root.join("tests/fixtures")).unwrap();
rules_config(
root,
"allow:\n files:\n - tests/fixtures/\n - \"*.example\"\n",
);
let secret = format!("ANTHROPIC_KEY={S}\n");
fs::write(root.join(".env"), &secret).unwrap();
fs::write(root.join(".env.example"), &secret).unwrap();
fs::write(root.join("tests/fixtures/app.env"), &secret).unwrap();
let run = |args: &[&str]| veloci_in(root, root, args, &secret);
for file in [".env.example", "tests/fixtures/app.env"] {
let out = run(&["redact", file]);
assert!(out.status.success(), "{file}: {}", stderr(&out));
assert_eq!(stdout(&out), secret, "{file}");
let out = run(&["list", "--check", file]);
assert!(out.status.success(), "{file}: {}", stderr(&out));
assert!(stdout(&out).contains("allowed"), "{file}");
}
for args in [&["redact", ".env"][..], &["redact"]] {
let out = run(args);
assert!(!stdout(&out).contains(S), "{args:?}");
}
let out = run(&["scan", "-l"]);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
assert_eq!(stdout(&out), ".env\n");
let out = run(&["grep", "--hidden", "-l", "sk-ant"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), ".env.example\ntests/fixtures/app.env\n");
}
#[test]
fn allowed_file_paths_apply_to_their_files_only() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join(".git")).unwrap();
rules_config(
root,
"allow:\n file_paths:\n - example.yaml#user.name\n - \"example.*.yml#user.name\"\n",
);
let document = format!("user:\n name: \"{S}\"\n token: \"{S}x\"\n");
for file in ["example.yaml", "example.prod.yml", "other.yaml"] {
fs::write(root.join(file), &document).unwrap();
}
let run = |args: &[&str]| veloci_in(root, root, args, &document);
for file in ["example.yaml", "example.prod.yml"] {
let out = run(&["redact", file]);
let text = stdout(&out);
assert!(text.contains(&format!("name: \"{S}\"")), "{file}: {text}");
assert!(!text.contains(&format!("{S}x")), "{file}: {text}");
}
for args in [&["redact", "other.yaml"][..], &["redact"]] {
let text = stdout(&run(args));
assert!(!text.contains(S), "{args:?}: {text}");
}
let out = run(&["grep", "-l", "--fixed-strings", &format!("name: \"{S}\"")]);
assert_eq!(
stdout(&out),
"example.prod.yml\nexample.yaml\n",
"{}",
stderr(&out)
);
let out = run(&["scan", "--json"]);
let report: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
for file in report["files"].as_array().unwrap() {
let expected = if file["path"] == "other.yaml" { 2 } else { 1 };
assert_eq!(file["count"], expected, "{file}");
}
}
#[test]
fn a_file_path_without_a_separator_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let config = rules_config(dir.path(), "allow:\n file_paths: [example.yaml]\n");
let out = veloci(&["config", "validate", "--config", &config], "");
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("FILE#KEY.PATH"), "{}", stderr(&out));
let out = veloci(&["redact", "--config", &config], "x\n");
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn scan_flags_choose_what_is_walked() {
let dir = scan_repo();
let listed = |args: &[&str]| stdout(&scan_in(dir.path(), &[&["-l"], args].concat()));
assert_eq!(listed(&["--skip-hidden"]), "config/settings.yml\n");
assert_eq!(listed(&["--skip-ignored"]), "config/settings.yml\n");
assert_eq!(listed(&["-g", "*.yml"]), "config/settings.yml\n");
assert_eq!(
listed(&["--all-dirs"]),
".env\nconfig/settings.yml\nnode_modules/pkg/.env\n"
);
assert_eq!(listed(&["--max-filesize", "10"]), "");
}
#[test]
fn scan_exits_0_when_nothing_holds_a_secret() {
let dir = scan_repo();
let out = scan_in(dir.path(), &["src"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "");
assert!(stderr(&out).contains("0 with secrets"), "{}", stderr(&out));
}
#[test]
fn scan_honors_the_allow_list() {
let dir = scan_repo();
rules_config(dir.path(), &format!("allow:\n values: [\"{S}\"]\n"));
let out = scan_in(dir.path(), &["-l"]);
assert!(out.status.success(), "{}", stdout(&out));
assert_eq!(stdout(&out), "");
}
#[test]
fn scan_marks_and_can_leave_out_protected_files() {
let dir = scan_repo();
assert!(
agent_init(dir.path(), &["--protect", ".env*"])
.status
.success()
);
let out = scan_in(dir.path(), &[]);
let text = stdout(&out);
let env = text.lines().find(|l| l.starts_with(".env")).unwrap();
assert!(env.ends_with("protected"), "{text}");
let config = text.lines().find(|l| l.starts_with("config/")).unwrap();
assert!(!config.ends_with("protected"), "{text}");
let out = scan_in(dir.path(), &["--unprotected", "-l"]);
assert_eq!(out.status.code(), Some(1));
assert_eq!(stdout(&out), "config/settings.yml\n");
}
#[test]
fn scan_fails_on_a_broken_configuration() {
let dir = scan_repo();
fs::write(dir.path().join("veloci.yml"), "detectors: [\n").unwrap();
let out = scan_in(dir.path(), &[]);
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("veloci.yml"), "{}", stderr(&out));
}
#[test]
fn agent_status_lists_files_whose_contents_hold_secrets() {
let dir = scan_repo();
let out = veloci_in(dir.path(), dir.path(), &["agent", "status"], "");
assert!(out.status.success(), "{}", stderr(&out));
let text = stdout(&out);
assert!(!text.contains(S), "{text}");
assert!(text.contains("contents hold likely secrets"), "{text}");
assert!(text.contains("config/settings.yml"), "{text}");
let out = veloci_in(dir.path(), dir.path(), &["agent", "status", "--json"], "");
let status: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
let paths: Vec<_> = status["secrets"]
.as_array()
.unwrap()
.iter()
.map(|s| s["path"].as_str().unwrap())
.collect();
assert_eq!(paths, [".env", "config/settings.yml"]);
assert!(
agent_init(dir.path(), &["--protect", ".env"])
.status
.success()
);
let out = veloci_in(dir.path(), dir.path(), &["agent", "status", "--json"], "");
let status: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(status["scanned"], true);
assert_eq!(status["secrets"][0]["path"], "config/settings.yml");
assert_eq!(status["secrets"].as_array().unwrap().len(), 1, "{status}");
let out = veloci_in(dir.path(), dir.path(), &["agent", "status"], "");
let text = stdout(&out);
assert!(text.contains("Unprotected files"), "{text}");
assert!(text.contains("config/settings.yml"), "{text}");
}
#[test]
fn agent_status_no_scan_reads_no_contents() {
let dir = scan_repo();
let out = veloci_in(
dir.path(),
dir.path(),
&["agent", "status", "--json", "--no-scan"],
"",
);
assert!(out.status.success(), "{}", stderr(&out));
let status: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(status["scanned"], false);
assert_eq!(status["secrets"], serde_json::json!([]));
assert!(!status["suggested"].as_array().unwrap().is_empty());
let out = veloci_in(
dir.path(),
dir.path(),
&["agent", "status", "--no-scan"],
"",
);
assert!(!stdout(&out).contains("contents hold"), "{}", stdout(&out));
}
#[cfg(unix)]
#[test]
fn protected_files_are_not_read() {
use std::os::unix::fs::PermissionsExt;
let dir = scan_repo();
assert!(
agent_init(dir.path(), &["--protect", ".env"])
.status
.success()
);
let env = dir.path().join(".env");
fs::set_permissions(&env, fs::Permissions::from_mode(0o000)).unwrap();
if fs::read(&env).is_ok() {
return;
}
let out = scan_in(dir.path(), &["--unprotected", "-l"]);
assert_eq!(out.status.code(), Some(1));
assert_eq!(stdout(&out), "config/settings.yml\n");
assert_eq!(stderr(&out), "");
let out = veloci_in(dir.path(), dir.path(), &["agent", "status", "--json"], "");
assert!(out.status.success(), "{}", stderr(&out));
assert!(!stderr(&out).contains(".env"), "{}", stderr(&out));
let out = scan_in(dir.path(), &["-l"]);
assert!(stderr(&out).contains(".env"), "{}", stderr(&out));
}
#[test]
fn a_disabled_detector_runs_only_when_named() {
let dir = scan_repo();
let config = detector_config(
dir.path(),
" - privacy_filter:\n enabled: false\n model_dir: /nonexistent/veloci-model\n",
);
let mut source = fs::read_to_string(&config).unwrap();
source.push_str("\nagent:\n protected: [\".env\"]\n");
fs::write(&config, source).unwrap();
let out = veloci_in(dir.path(), dir.path(), &["agent", "status", "--json"], "");
assert!(out.status.success(), "{}", stderr(&out));
let status: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(status["secrets"][0]["path"], "config/settings.yml");
assert_eq!(scan_in(dir.path(), &[]).status.code(), Some(1));
let named = ["--detector", "privacy_filter"];
let out = veloci_in(
dir.path(),
dir.path(),
&["agent", "status", named[0], named[1]],
"",
);
assert_eq!(out.status.code(), Some(2), "{}", stdout(&out));
assert!(stderr(&out).contains("veloci.yml"), "{}", stderr(&out));
assert_eq!(scan_in(dir.path(), &named).status.code(), Some(2));
let out = scan_in(dir.path(), &["--detector", "privacy_filtr"]);
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("privacy_filtr"), "{}", stderr(&out));
}
fn git_in(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(["-c", "user.name=Test", "-c", "user.email=test@test.invalid"])
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn git_repo() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
fs::create_dir(&repo).unwrap();
git_in(&repo, &["init", "-q"]);
(dir, repo)
}
fn githook(dir: &tempfile::TempDir, repo: &Path) -> Output {
veloci_in(repo, dir.path(), &["githook"], "")
}
#[test]
fn githook_refuses_a_staged_secret_without_showing_it() {
let (dir, repo) = git_repo();
fs::write(repo.join("clean.txt"), "nothing to see\n").unwrap();
fs::create_dir(repo.join("src")).unwrap();
fs::write(
repo.join("src/client.py"),
format!("import os\n\nKEY = \"{S}\"\n"),
)
.unwrap();
git_in(&repo, &["add", "."]);
let out = githook(&dir, &repo);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
let err = stderr(&out);
assert!(err.contains("src/client.py:3"), "{err}");
assert!(!err.contains("clean.txt"), "{err}");
assert!(!err.contains(S), "{err}");
assert!(stdout(&out).is_empty());
}
#[test]
fn githook_passes_a_clean_commit() {
let (dir, repo) = git_repo();
fs::write(repo.join("clean.txt"), "nothing to see\n").unwrap();
git_in(&repo, &["add", "."]);
let out = githook(&dir, &repo);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
git_in(&repo, &["commit", "-q", "-m", "clean"]);
let out = githook(&dir, &repo);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
}
#[test]
fn githook_reports_only_added_lines() {
let (dir, repo) = git_repo();
let old = format!("KEY = \"{S}\"\n");
fs::write(repo.join("app.py"), &old).unwrap();
git_in(&repo, &["add", "."]);
git_in(&repo, &["commit", "-q", "--no-verify", "-m", "old"]);
fs::write(repo.join("app.py"), format!("{old}print('hi')\n")).unwrap();
git_in(&repo, &["add", "."]);
let out = githook(&dir, &repo);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
fs::write(
repo.join("app.py"),
format!("{old}print('hi')\nOTHER = \"{S}\"\n"),
)
.unwrap();
git_in(&repo, &["add", "."]);
let out = githook(&dir, &repo);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
assert!(stderr(&out).contains("app.py:3"), "{}", stderr(&out));
}
#[test]
fn githook_judges_the_staged_content_not_the_working_tree() {
let (dir, repo) = git_repo();
let file = repo.join("app.py");
fs::write(&file, "print('hi')\n").unwrap();
git_in(&repo, &["add", "."]);
fs::write(&file, format!("KEY = \"{S}\"\n")).unwrap();
let out = githook(&dir, &repo);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
git_in(&repo, &["add", "."]);
fs::write(&file, "print('hi')\n").unwrap();
let out = githook(&dir, &repo);
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
}
#[test]
fn githook_applies_the_repository_configuration() {
let (dir, repo) = git_repo();
rules_config(&repo, &format!("allow:\n values: [\"{S}\"]\n"));
fs::write(repo.join("app.py"), format!("KEY = \"{S}\"\n")).unwrap();
git_in(&repo, &["add", "app.py"]);
let out = githook(&dir, &repo);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
}
#[test]
fn githook_fails_outside_a_repository() {
let dir = tempfile::tempdir().unwrap();
let out = veloci_in(dir.path(), dir.path(), &["githook"], "");
assert_eq!(out.status.code(), Some(2), "{}", stderr(&out));
}
#[test]
fn init_writes_the_builtin_configuration_at_the_repository_root() {
let dir = agent_repo();
let out = veloci_in(&dir.path().join("src"), dir.path(), &["init"], "y\n");
assert!(out.status.success(), "{}", stderr(&out));
assert!(
stderr(&out).contains("[Y] yes / [N] no"),
"{}",
stderr(&out)
);
let written = fs::read_to_string(dir.path().join("veloci.yml")).unwrap();
assert_eq!(written, BUILTIN);
}
#[test]
fn init_with_yes_does_not_ask() {
let dir = agent_repo();
let out = veloci_in(dir.path(), dir.path(), &["init", "--yes"], "");
assert!(out.status.success(), "{}", stderr(&out));
assert!(!stderr(&out).contains("[Y] yes"), "{}", stderr(&out));
assert!(dir.path().join("veloci.yml").is_file());
}
#[test]
fn init_writes_nothing_unless_confirmed() {
for answer in ["n\n", "\n", ""] {
let dir = agent_repo();
let out = veloci_in(dir.path(), dir.path(), &["init"], answer);
assert_eq!(out.status.code(), Some(1), "{answer:?}: {}", stderr(&out));
assert!(!dir.path().join("veloci.yml").exists(), "{answer:?}");
}
}
#[test]
fn init_refuses_to_replace_a_configuration() {
let dir = agent_repo();
fs::write(dir.path().join("VELOCI.yml"), "mine\n").unwrap();
let out = veloci_in(dir.path(), dir.path(), &["init", "--yes"], "");
assert_eq!(out.status.code(), Some(2), "{}", stderr(&out));
assert!(stderr(&out).contains("already exists"), "{}", stderr(&out));
assert_eq!(
fs::read_to_string(dir.path().join("VELOCI.yml")).unwrap(),
"mine\n"
);
}