use std::{process::Command, str};
use serde_json::Value;
use tempfile::TempDir;
use super::{assert_json_recovery_advice_fields, heddle, heddle_output};
const EX_DATAERR: i32 = 65;
#[test]
fn piped_status_with_no_output_flag_renders_text() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init should succeed");
let output =
heddle_output(&["status"], Some(temp.path())).expect("status with no --output flag");
assert!(
output.status.success(),
"default status under a pipe should succeed: stdout={}; stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = str::from_utf8(&output.stdout).expect("stdout utf8");
let trimmed = stdout.trim_start();
assert!(
!trimmed.starts_with('{') && !trimmed.starts_with('['),
"piped default status must stay text, not JSON: {stdout}"
);
assert!(
serde_json::from_str::<Value>(stdout).is_err(),
"piped default status must not parse as JSON: {stdout}"
);
}
#[test]
fn piped_status_with_explicit_json_flag_emits_json() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init should succeed");
let stdout = heddle(&["status", "--output", "json"], Some(temp.path()))
.expect("status --output json should succeed under a pipe");
let parsed: Value = serde_json::from_str(&stdout)
.unwrap_or_else(|err| panic!("JSON parse failed: {err}: {stdout}"));
assert!(
parsed.is_object(),
"status --output json should emit a JSON object: {stdout}"
);
}
#[test]
fn output_auto_flag_errors_at_parse_with_helpful_message() {
let output = heddle_output(&["--output", "auto", "status"], None)
.expect("heddle should run even when args reject");
assert!(
!output.status.success(),
"--output auto should fail at parse: stdout={}; stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_lower = stderr.to_ascii_lowercase();
assert!(
stderr_lower.contains("auto"),
"parse error should name the rejected value 'auto': {stderr}"
);
assert!(
stderr_lower.contains("text") && stderr_lower.contains("json"),
"parse error should list the remaining valid values 'text' and 'json': {stderr}"
);
}
#[test]
fn repo_config_with_output_format_auto_errors_with_typed_envelope() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init should succeed");
let config_path = temp.path().join(".heddle").join("config.toml");
let existing = std::fs::read_to_string(&config_path).expect("repo config exists after init");
let mutated = existing.replace("[output]\n", "[output]\nformat = \"auto\"\n");
assert_ne!(
mutated, existing,
"test fixture should mutate the [output] section: {existing}"
);
std::fs::write(&config_path, &mutated).expect("write mutated config");
let text_out =
heddle_output(&["status"], Some(temp.path())).expect("status should run with bad config");
assert!(
!text_out.status.success(),
"status with output.format='auto' must fail loudly: stdout={}; stderr={}",
String::from_utf8_lossy(&text_out.stdout),
String::from_utf8_lossy(&text_out.stderr)
);
let stderr = String::from_utf8_lossy(&text_out.stderr);
assert!(
stderr.contains("output.format") && stderr.contains("'auto'"),
"text envelope should name the field and the rejected value: {stderr}"
);
assert!(
stderr.contains("'text'") && stderr.contains("'json'"),
"text envelope should list the valid values: {stderr}"
);
assert!(
stderr.contains("Next:"),
"text envelope should carry a typed Next: line: {stderr}"
);
let json_out = heddle_output(&["--output", "json", "status"], Some(temp.path()))
.expect("status --output json should run with bad config");
assert!(
!json_out.status.success(),
"status with output.format='auto' must fail under --output json too"
);
let stderr_json_text = String::from_utf8_lossy(&json_out.stderr);
let last_line = stderr_json_text
.lines()
.rfind(|line| line.trim_start().starts_with('{'))
.unwrap_or_else(|| panic!("expected a JSON envelope on stderr; got: {stderr_json_text}"));
let envelope: Value = serde_json::from_str(last_line.trim())
.unwrap_or_else(|err| panic!("stderr JSON envelope should parse: {err}: {last_line}"));
assert_eq!(
envelope["kind"], "invalid_repo_config_output_format",
"JSON envelope kind should classify the field-specific failure: {envelope}"
);
assert!(
envelope["error"]
.as_str()
.is_some_and(|err| err.contains("output.format") && err.contains("'auto'")),
"JSON envelope error message should name field and value: {envelope}"
);
assert_json_recovery_advice_fields(&envelope, "repo config output.format=auto");
let expected_path = config_path
.canonicalize()
.expect("repo config path canonicalizes");
let expected_path = expected_path.display().to_string();
let hint = envelope["hint"]
.as_str()
.expect("envelope.hint should be a string");
assert!(
hint.contains(&expected_path),
"hint should cite the actual repo config path {expected_path}: hint={hint}"
);
assert!(
hint.contains("output.format"),
"hint should name the field: {hint}"
);
assert!(
hint.contains("'text'") && hint.contains("'json'"),
"hint should list the valid values: {hint}"
);
let stderr_text_after = String::from_utf8_lossy(&text_out.stderr);
assert!(
stderr_text_after.contains(&expected_path),
"text envelope should also surface the path so the user knows which file to edit: stderr={stderr_text_after}"
);
}
#[test]
fn user_config_with_output_format_auto_via_heddle_config_env_errors_with_typed_envelope() {
let temp = TempDir::new().unwrap();
let bad_user_config = temp.path().join("user-config.toml");
std::fs::write(&bad_user_config, "[output]\nformat = \"auto\"\n")
.expect("write bad user config");
let text_out = run_with_bad_user_config(&bad_user_config, None, &["status"]);
assert!(
!text_out.status.success(),
"status with user output.format='auto' must fail loudly: stdout={}; stderr={}",
String::from_utf8_lossy(&text_out.stdout),
String::from_utf8_lossy(&text_out.stderr)
);
let stderr = String::from_utf8_lossy(&text_out.stderr);
assert_typed_output_format_envelope(&stderr, "HEDDLE_CONFIG user config");
let json_out =
run_with_bad_user_config(&bad_user_config, None, &["--output", "json", "status"]);
assert!(
!json_out.status.success(),
"status --output json with user output.format='auto' must fail too: stderr={}",
String::from_utf8_lossy(&json_out.stderr)
);
let envelope = parse_envelope(&json_out.stderr);
assert_eq!(
envelope["kind"], "invalid_repo_config_output_format",
"JSON envelope kind should classify the field-specific failure: {envelope}"
);
assert!(
envelope["error"]
.as_str()
.is_some_and(|err| err.contains("output.format") && err.contains("'auto'")),
"JSON envelope error message should name field and value: {envelope}"
);
assert_json_recovery_advice_fields(&envelope, "user config HEDDLE_CONFIG output.format=auto");
let expected_path = bad_user_config
.canonicalize()
.expect("HEDDLE_CONFIG path canonicalizes");
let expected_path = expected_path.display().to_string();
let hint = envelope["hint"]
.as_str()
.expect("envelope.hint should be a string");
assert!(
hint.contains(&expected_path),
"hint should cite the HEDDLE_CONFIG path {expected_path}: hint={hint}"
);
assert!(
!hint.contains(".heddle/config.toml"),
"hint must not point at the repo config when the bad value is in the user config: hint={hint}"
);
let stderr_text_after = String::from_utf8_lossy(&text_out.stderr);
assert!(
stderr_text_after.contains(&expected_path),
"text envelope should also surface the HEDDLE_CONFIG path so the user knows which file to edit: stderr={stderr_text_after}"
);
}
#[test]
fn user_config_with_output_format_auto_via_home_path_errors_with_typed_envelope() {
let temp = TempDir::new().unwrap();
let fake_home = temp.path();
let config_path = fake_home.join(".config").join("heddle").join("config.toml");
std::fs::create_dir_all(config_path.parent().unwrap()).expect("mkdir config parent");
std::fs::write(&config_path, "[output]\nformat = \"auto\"\n").expect("write bad home config");
let text_out = run_with_home_user_config(fake_home, None, &["status"]);
assert!(
!text_out.status.success(),
"status with HOME user output.format='auto' must fail loudly: stdout={}; stderr={}",
String::from_utf8_lossy(&text_out.stdout),
String::from_utf8_lossy(&text_out.stderr)
);
let stderr = String::from_utf8_lossy(&text_out.stderr);
assert_typed_output_format_envelope(&stderr, "HOME-based user config");
let expected_path = config_path
.canonicalize()
.expect("HOME-based user config path canonicalizes");
let expected_path = expected_path.display().to_string();
assert!(
stderr.contains(&expected_path),
"text envelope should cite the HOME-based config path {expected_path}: stderr={stderr}"
);
let json_out = run_with_home_user_config(fake_home, None, &["--output", "json", "status"]);
assert!(
!json_out.status.success(),
"status --output json with HOME user output.format='auto' must fail too: stderr={}",
String::from_utf8_lossy(&json_out.stderr)
);
let envelope = parse_envelope(&json_out.stderr);
let hint = envelope["hint"]
.as_str()
.expect("envelope.hint should be a string");
assert!(
hint.contains(&expected_path),
"JSON envelope hint should cite the HOME-based config path {expected_path}: hint={hint}"
);
assert!(
!hint.contains(".heddle/config.toml"),
"hint must not point at the repo config when the bad value is in the HOME user config: hint={hint}"
);
}
#[test]
fn repo_config_output_format_auto_exits_data_err() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init should succeed");
let config_path = temp.path().join(".heddle").join("config.toml");
let existing = std::fs::read_to_string(&config_path).expect("repo config exists after init");
let mutated = existing.replace("[output]\n", "[output]\nformat = \"auto\"\n");
std::fs::write(&config_path, &mutated).expect("write mutated config");
let out = heddle_output(&["status"], Some(temp.path())).expect("status runs with bad config");
assert_eq!(
out.status.code(),
Some(EX_DATAERR),
"repo config output.format='auto' must exit EX_DATAERR (65), not EX_IOERR (74): stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let json_out = heddle_output(&["--output", "json", "status"], Some(temp.path()))
.expect("status --output json runs with bad config");
assert_eq!(
json_out.status.code(),
Some(EX_DATAERR),
"JSON-mode exit code must also be EX_DATAERR (65): stderr={}",
String::from_utf8_lossy(&json_out.stderr)
);
let envelope = parse_envelope(&json_out.stderr);
assert_eq!(
envelope["exit_code"].as_u64(),
Some(EX_DATAERR as u64),
"JSON envelope exit_code must report EX_DATAERR (65): {envelope}"
);
}
#[test]
fn user_config_heddle_config_output_format_auto_exits_data_err() {
let temp = TempDir::new().unwrap();
let bad_user_config = temp.path().join("user-config.toml");
std::fs::write(&bad_user_config, "[output]\nformat = \"auto\"\n")
.expect("write bad user config");
let out = run_with_bad_user_config(&bad_user_config, None, &["status"]);
assert_eq!(
out.status.code(),
Some(EX_DATAERR),
"HEDDLE_CONFIG output.format='auto' must exit EX_DATAERR (65), not EX_IOERR (74): stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let json_out =
run_with_bad_user_config(&bad_user_config, None, &["--output", "json", "status"]);
assert_eq!(
json_out.status.code(),
Some(EX_DATAERR),
"JSON-mode exit code must also be EX_DATAERR (65): stderr={}",
String::from_utf8_lossy(&json_out.stderr)
);
let envelope = parse_envelope(&json_out.stderr);
assert_eq!(
envelope["exit_code"].as_u64(),
Some(EX_DATAERR as u64),
"JSON envelope exit_code must report EX_DATAERR (65): {envelope}"
);
}
#[test]
fn user_config_home_output_format_auto_exits_data_err() {
let temp = TempDir::new().unwrap();
let fake_home = temp.path();
let config_path = fake_home.join(".config").join("heddle").join("config.toml");
std::fs::create_dir_all(config_path.parent().unwrap()).expect("mkdir config parent");
std::fs::write(&config_path, "[output]\nformat = \"auto\"\n").expect("write bad home config");
let out = run_with_home_user_config(fake_home, None, &["status"]);
assert_eq!(
out.status.code(),
Some(EX_DATAERR),
"HOME user output.format='auto' must exit EX_DATAERR (65), not EX_IOERR (74): stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let json_out = run_with_home_user_config(fake_home, None, &["--output", "json", "status"]);
assert_eq!(
json_out.status.code(),
Some(EX_DATAERR),
"JSON-mode exit code must also be EX_DATAERR (65): stderr={}",
String::from_utf8_lossy(&json_out.stderr)
);
let envelope = parse_envelope(&json_out.stderr);
assert_eq!(
envelope["exit_code"].as_u64(),
Some(EX_DATAERR as u64),
"JSON envelope exit_code must report EX_DATAERR (65): {envelope}"
);
}
fn run_with_bad_user_config(
config_path: &std::path::Path,
cwd: Option<&std::path::Path>,
args: &[&str],
) -> std::process::Output {
let temp;
let dir = match cwd {
Some(dir) => dir.to_path_buf(),
None => {
temp = TempDir::new().expect("tempdir for cwd");
temp.path().to_path_buf()
}
};
Command::new(env!("CARGO_BIN_EXE_heddle"))
.args(args)
.current_dir(&dir)
.env("HEDDLE_CONFIG", config_path)
.output()
.expect("spawn heddle")
}
fn run_with_home_user_config(
home: &std::path::Path,
cwd: Option<&std::path::Path>,
args: &[&str],
) -> std::process::Output {
let temp;
let dir = match cwd {
Some(dir) => dir.to_path_buf(),
None => {
temp = TempDir::new().expect("tempdir for cwd");
temp.path().to_path_buf()
}
};
Command::new(env!("CARGO_BIN_EXE_heddle"))
.args(args)
.current_dir(&dir)
.env_remove("HEDDLE_CONFIG")
.env_remove("XDG_CONFIG_HOME")
.env("HOME", home)
.output()
.expect("spawn heddle")
}
fn assert_typed_output_format_envelope(stderr: &str, context: &str) {
assert!(
stderr.contains("output.format") && stderr.contains("'auto'"),
"{context}: text envelope should name the field and the rejected value: {stderr}"
);
assert!(
stderr.contains("'text'") && stderr.contains("'json'"),
"{context}: text envelope should list the valid values: {stderr}"
);
assert!(
stderr.contains("Next:"),
"{context}: text envelope should carry a typed Next: line: {stderr}"
);
assert!(
!stderr.contains("TOML parse error"),
"{context}: raw TOML parse error must not leak past the typed envelope: {stderr}"
);
}
fn parse_envelope(stderr_bytes: &[u8]) -> Value {
let stderr = String::from_utf8_lossy(stderr_bytes);
let line = stderr
.lines()
.rfind(|line| line.trim_start().starts_with('{'))
.unwrap_or_else(|| panic!("expected JSON envelope on stderr; got: {stderr}"));
serde_json::from_str(line.trim())
.unwrap_or_else(|err| panic!("stderr JSON envelope should parse: {err}: {line}"))
}
#[test]
fn output_mode_auto_variant_is_absent_from_source() {
let cli_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let repo_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates dir")
.join("repo")
.join("src");
for root in [cli_src, repo_src] {
for entry in walkdir(&root) {
if entry.extension().and_then(|ext| ext.to_str()) != Some("rs") {
continue;
}
let contents = std::fs::read_to_string(&entry).expect("read rs file");
assert!(
!contents.contains("OutputMode::Auto"),
"{} still references OutputMode::Auto",
entry.display()
);
assert!(
!contents.contains("OutputFormat::Auto"),
"{} still references OutputFormat::Auto",
entry.display()
);
}
}
}
fn walkdir(root: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let read = match std::fs::read_dir(&dir) {
Ok(read) => read,
Err(_) => continue,
};
for entry in read.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else {
out.push(path);
}
}
}
out
}