use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;
fn run_set(args: &[&str], config_path: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_entangle"))
.arg("set")
.args(args)
.env("ENTANGLE_CONFIG_PATH", config_path)
.output()
.expect("failed to spawn entangle set")
}
fn stdout(o: &Output) -> String {
String::from_utf8_lossy(&o.stdout).into_owned()
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
fn fresh_config() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.json");
(dir, config_path)
}
fn read_config(path: &Path) -> serde_json::Value {
let content = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("could not read config at {}: {e}", path.display()));
serde_json::from_str(&content)
.unwrap_or_else(|e| panic!("config not valid JSON: {e}\ncontent: {content}"))
}
#[test]
fn set_gh_user_creates_config_with_github_username() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["gh-user", "cyrusae"], &config_path);
assert!(
out.status.success(),
"set gh-user must succeed\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
assert!(config_path.exists(), "config file must be created by set");
let cfg = read_config(&config_path);
assert_eq!(
cfg["github_username"], "cyrusae",
"github_username must match the supplied value"
);
assert!(
cfg["tangled_username"].is_null(),
"tangled_username must not be written by set gh-user"
);
}
#[test]
fn set_tngl_user_creates_config_with_tangled_username() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["tngl-user", "atdot.fyi"], &config_path);
assert!(
out.status.success(),
"set tngl-user must succeed\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
let cfg = read_config(&config_path);
assert_eq!(cfg["tangled_username"], "atdot.fyi");
assert!(cfg["github_username"].is_null());
}
#[test]
fn set_origin_creates_config_with_origin_preference() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["origin", "github"], &config_path);
assert!(
out.status.success(),
"set origin must succeed\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
let cfg = read_config(&config_path);
assert_eq!(cfg["origin_preference"], "github");
}
#[test]
fn set_github_user_long_alias_accepted() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["github-user", "cyrusae"], &config_path);
assert!(
out.status.success(),
"long alias 'github-user' must be accepted\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
let cfg = read_config(&config_path);
assert_eq!(cfg["github_username"], "cyrusae");
}
#[test]
fn set_tangled_user_long_alias_accepted() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["tangled-user", "atdot.fyi"], &config_path);
assert!(
out.status.success(),
"long alias 'tangled-user' must be accepted\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
let cfg = read_config(&config_path);
assert_eq!(cfg["tangled_username"], "atdot.fyi");
}
#[test]
fn set_origin_gh_alias_stores_github() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["origin", "gh"], &config_path);
assert!(out.status.success(), "set origin gh must succeed");
let cfg = read_config(&config_path);
assert_eq!(
cfg["origin_preference"], "github",
"alias 'gh' must be stored as canonical 'github'"
);
}
#[test]
fn set_origin_tngl_alias_stores_tangled() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["origin", "tngl"], &config_path);
assert!(out.status.success(), "set origin tngl must succeed");
let cfg = read_config(&config_path);
assert_eq!(
cfg["origin_preference"], "tangled",
"alias 'tngl' must be stored as canonical 'tangled'"
);
}
#[test]
fn set_gh_user_normalises_mixed_case() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["gh-user", "CyrusAE"], &config_path);
assert!(
out.status.success(),
"mixed-case username must succeed (sanitized before validation)\
\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
let cfg = read_config(&config_path);
assert_eq!(
cfg["github_username"], "cyrusae",
"username must be lowercased before saving"
);
}
#[test]
fn set_tngl_user_normalises_mixed_case() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["tngl-user", "AtDot.FYI"], &config_path);
assert!(
out.status.success(),
"mixed-case Tangled username must succeed"
);
let cfg = read_config(&config_path);
assert_eq!(cfg["tangled_username"], "atdot.fyi");
}
#[test]
fn set_origin_normalises_mixed_case() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["origin", "GitHub"], &config_path);
assert!(out.status.success(), "mixed-case 'GitHub' must be accepted");
let cfg = read_config(&config_path);
assert_eq!(cfg["origin_preference"], "github");
}
#[test]
fn sequential_set_calls_accumulate_all_fields() {
let (_dir, config_path) = fresh_config();
run_set(&["gh-user", "cyrusae"], &config_path);
run_set(&["tngl-user", "atdot.fyi"], &config_path);
run_set(&["origin", "github"], &config_path);
let cfg = read_config(&config_path);
assert_eq!(cfg["github_username"], "cyrusae");
assert_eq!(cfg["tangled_username"], "atdot.fyi");
assert_eq!(cfg["origin_preference"], "github");
}
#[test]
fn set_gh_user_does_not_clobber_existing_tangled_username() {
let (_dir, config_path) = fresh_config();
run_set(&["gh-user", "old-name"], &config_path);
run_set(&["tngl-user", "atdot.fyi"], &config_path);
let out = run_set(&["gh-user", "cyrusae"], &config_path);
assert!(out.status.success(), "update must succeed");
let cfg = read_config(&config_path);
assert_eq!(
cfg["github_username"], "cyrusae",
"github_username must be updated"
);
assert_eq!(
cfg["tangled_username"], "atdot.fyi",
"tangled_username must remain unchanged"
);
}
#[test]
fn set_gh_user_prints_confirmation_to_stdout() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["gh-user", "cyrusae"], &config_path);
assert!(out.status.success(), "must succeed");
let out_str = stdout(&out);
assert!(
!out_str.is_empty(),
"stdout must contain a confirmation message"
);
assert!(
out_str.contains("cyrusae"),
"confirmation must mention the set value: {out_str}"
);
}
#[test]
fn set_gh_user_without_value_exits_nonzero() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["gh-user"], &config_path);
assert!(
!out.status.success(),
"set gh-user with no value must exit non-zero"
);
let err = stderr(&out);
assert!(
!err.is_empty(),
"stderr must contain an error for missing value"
);
}
#[test]
fn set_tngl_user_without_value_exits_nonzero() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["tngl-user"], &config_path);
assert!(
!out.status.success(),
"set tngl-user with no value must exit non-zero"
);
let err = stderr(&out);
assert!(
!err.is_empty(),
"stderr must contain an error for missing value"
);
}
#[test]
fn set_origin_without_value_exits_nonzero() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["origin"], &config_path);
assert!(
!out.status.success(),
"set origin with no value must exit non-zero"
);
let err = stderr(&out);
assert!(
!err.is_empty(),
"stderr must contain an error for missing value"
);
}
#[test]
fn set_with_no_args_exits_nonzero() {
let (_dir, config_path) = fresh_config();
let out = run_set(&[], &config_path);
assert!(
!out.status.success(),
"set with no arguments must exit non-zero"
);
let err = stderr(&out);
assert!(
!err.is_empty(),
"stderr must contain an error for missing key"
);
}
#[test]
fn set_with_no_args_does_not_create_config() {
let (_dir, config_path) = fresh_config();
run_set(&[], &config_path);
assert!(
!config_path.exists(),
"config must not be created when set receives no arguments"
);
}
#[test]
fn set_gh_user_invalid_username_exits_nonzero() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["gh-user", "-invalid-leading-hyphen"], &config_path);
assert!(
!out.status.success(),
"invalid GitHub username must exit non-zero"
);
let err = stderr(&out);
assert!(
!err.is_empty(),
"stderr must contain a validation error message"
);
}
#[test]
fn set_gh_user_invalid_username_does_not_write_config() {
let (_dir, config_path) = fresh_config();
run_set(&["gh-user", "-invalid-leading-hyphen"], &config_path);
assert!(
!config_path.exists(),
"config must not be created when validation fails"
);
}
#[test]
fn set_tngl_user_invalid_username_exits_nonzero() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["tngl-user", "nodot"], &config_path);
assert!(
!out.status.success(),
"invalid Tangled username must exit non-zero"
);
let err = stderr(&out);
assert!(
!err.is_empty(),
"stderr must contain a validation error for invalid Tangled username"
);
}
#[test]
fn set_origin_unknown_value_exits_nonzero() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["origin", "gitlab"], &config_path);
assert!(
!out.status.success(),
"unknown origin 'gitlab' must exit non-zero"
);
let err = stderr(&out);
assert!(!err.is_empty(), "stderr must describe the accepted values");
}
#[test]
fn set_origin_unknown_value_does_not_write_config() {
let (_dir, config_path) = fresh_config();
run_set(&["origin", "gitlab"], &config_path);
assert!(
!config_path.exists(),
"config must not be created for an unrecognised origin value"
);
}
#[test]
fn set_gh_user_dangerous_char_exits_nonzero() {
let (_dir, config_path) = fresh_config();
let out = run_set(&["gh-user", "cyrus$ae"], &config_path);
assert!(
!out.status.success(),
"dangerous character in username must exit non-zero"
);
}
#[test]
#[cfg(unix)]
fn set_readonly_config_dir_exits_nonzero_with_error() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let config_dir = dir.path().join("cfg");
std::fs::create_dir(&config_dir).unwrap();
let mut perms = std::fs::metadata(&config_dir).unwrap().permissions();
perms.set_mode(0o555);
std::fs::set_permissions(&config_dir, perms).unwrap();
let config_path = config_dir.join("config.json");
let out = run_set(&["gh-user", "cyrusae"], &config_path);
let mut perms = std::fs::metadata(&config_dir).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&config_dir, perms).unwrap();
assert!(
!out.status.success(),
"set must fail when the config directory is not writable"
);
let err = stderr(&out);
assert!(
err.contains("Error"),
"stderr must contain an error message: {err}"
);
assert!(
!config_path.exists(),
"config file must not be created when the directory is read-only"
);
}