use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;
fn write_valid_config(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let json = serde_json::json!({
"github_username": "cyrusae",
"tangled_username": "atdot.fyi",
"origin_preference": "github",
});
std::fs::write(path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
}
fn run_init(args: &[&str], config_path: &Path, work_dir: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_entangle"))
.arg("init")
.args(args)
.env("ENTANGLE_CONFIG_PATH", config_path)
.env("ENTANGLE_SKIP_REMOTE_CHECK", "1")
.current_dir(work_dir)
.output()
.expect("failed to spawn entangle init")
}
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 setup_dirs() -> (TempDir, PathBuf, PathBuf) {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.json");
let work_dir = dir.path().join("work");
std::fs::create_dir(&work_dir).unwrap();
write_valid_config(&config_path);
(dir, config_path, work_dir)
}
#[test]
fn fresh_init_creates_dot_git_directory() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle"], &config_path, &work_dir);
assert!(
output.status.success(),
"init must succeed\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
assert!(
work_dir.join(".git").exists(),
".git must exist after fresh init"
);
}
#[test]
fn fresh_init_prints_initialization_message() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle"], &config_path, &work_dir);
let out = stdout(&output);
assert!(
out.contains("nitializ"),
"fresh init must print initialization message\nstdout: {out}"
);
}
#[test]
fn second_init_does_not_print_initialization_message() {
let (_dir, config_path, work_dir) = setup_dirs();
run_init(&["entangle"], &config_path, &work_dir);
let output = run_init(&["entangle"], &config_path, &work_dir);
assert!(
output.status.success(),
"second init must succeed\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(
!out.contains("nitializ"),
"second run must not print initialization message\nstdout: {out}"
);
}
#[test]
fn second_init_exits_successfully() {
let (_dir, config_path, work_dir) = setup_dirs();
run_init(&["entangle"], &config_path, &work_dir);
let output = run_init(&["entangle"], &config_path, &work_dir);
assert!(output.status.success(), "second init must exit 0");
}
#[test]
fn missing_config_exits_nonzero() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("nonexistent.json");
let work_dir = dir.path().join("work");
std::fs::create_dir(&work_dir).unwrap();
let output = run_init(&["entangle"], &config_path, &work_dir);
assert!(
!output.status.success(),
"must exit non-zero when config is missing"
);
}
#[test]
fn missing_config_mentions_setup_in_output() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("nonexistent.json");
let work_dir = dir.path().join("work");
std::fs::create_dir(&work_dir).unwrap();
let output = run_init(&["entangle"], &config_path, &work_dir);
let err = stderr(&output);
assert!(
err.contains("setup"),
"error output must mention 'setup'\nstderr: {err}"
);
}
#[test]
fn missing_config_does_not_create_git_repo() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("nonexistent.json");
let work_dir = dir.path().join("work");
std::fs::create_dir(&work_dir).unwrap();
run_init(&["entangle"], &config_path, &work_dir);
assert!(
!work_dir.join(".git").exists(),
".git must not be created when config is missing"
);
}
#[test]
fn suggests_gitignore_when_absent() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle"], &config_path, &work_dir);
let out = stdout(&output);
assert!(
out.contains(".gitignore"),
"must suggest adding .gitignore when absent\nstdout: {out}"
);
}
#[test]
fn no_gitignore_suggestion_when_present() {
let (_dir, config_path, work_dir) = setup_dirs();
std::fs::write(work_dir.join(".gitignore"), b"target/\n*.tmp\n").unwrap();
let output = run_init(&["entangle"], &config_path, &work_dir);
let out = stdout(&output);
assert!(
!out.contains("Add a .gitignore"),
".gitignore suggestion must not appear when file already exists\nstdout: {out}"
);
}
#[test]
fn suggests_readme_when_absent() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle"], &config_path, &work_dir);
let out = stdout(&output);
assert!(
out.contains("README.md"),
"must suggest adding README.md when absent\nstdout: {out}"
);
}
#[test]
fn no_readme_suggestion_when_present() {
let (_dir, config_path, work_dir) = setup_dirs();
std::fs::write(work_dir.join("README.md"), b"# My Project\n").unwrap();
let output = run_init(&["entangle"], &config_path, &work_dir);
let out = stdout(&output);
assert!(
!out.contains("Add a README.md"),
"README.md suggestion must not appear when file already exists\nstdout: {out}"
);
}
#[test]
fn output_contains_github_url() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle"], &config_path, &work_dir);
let out = stdout(&output);
assert!(
out.contains("github.com"),
"output must show the GitHub URL\nstdout: {out}"
);
}
#[test]
fn output_contains_tangled_url() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle"], &config_path, &work_dir);
let out = stdout(&output);
assert!(
out.contains("tangled.org"),
"output must show the Tangled URL\nstdout: {out}"
);
}
#[test]
fn output_contains_repo_name() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["my-project"], &config_path, &work_dir);
let out = stdout(&output);
assert!(
out.contains("my-project"),
"output must include the repo name\nstdout: {out}"
);
}
#[test]
fn alias_accepted_and_appears_in_mirror_url() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle", "my-alias"], &config_path, &work_dir);
assert!(
output.status.success(),
"must succeed with valid alias\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(
out.contains("my-alias"),
"alias must appear in the mirror URL\nstdout: {out}"
);
}
#[test]
fn invalid_repo_name_exits_nonzero() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["-bad-name"], &config_path, &work_dir);
assert!(
!output.status.success(),
"invalid repo name must exit non-zero"
);
}
#[test]
fn invalid_repo_name_does_not_create_git_repo() {
let (_dir, config_path, work_dir) = setup_dirs();
run_init(&["-bad-name"], &config_path, &work_dir);
assert!(
!work_dir.join(".git").exists(),
".git must not be created when repo name is invalid"
);
}
#[test]
fn init_with_three_positional_args_exits_nonzero() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(
&["entangle", "my-alias", "extra-arg"],
&config_path,
&work_dir,
);
assert!(
!output.status.success(),
"init with 3+ positional args must exit non-zero\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let err = stderr(&output);
assert!(
!err.is_empty(),
"stderr must contain an error message for the unexpected argument"
);
}
#[test]
fn init_uppercase_repo_name_is_normalised() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["ENTANGLE"], &config_path, &work_dir);
assert!(
output.status.success(),
"uppercase repo name must succeed after normalisation\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(
out.contains("entangle"),
"output must use the normalised (lowercase) repo name\nstdout: {out}"
);
assert!(
!out.contains("ENTANGLE"),
"output must not contain the un-normalised uppercase name\nstdout: {out}"
);
}
fn append_origin_remote(work_dir: &Path, fetch_url: &str, push_urls: &[&str]) {
use std::io::Write as _;
let cfg = work_dir.join(".git").join("config");
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&cfg)
.expect("must open .git/config");
writeln!(f, "\n[remote \"origin\"]").unwrap();
writeln!(f, "\turl = {fetch_url}").unwrap();
writeln!(f, "\tfetch = +refs/heads/*:refs/remotes/origin/*").unwrap();
for u in push_urls {
writeln!(f, "\tpushurl = {u}").unwrap();
}
}
fn read_origin_push_urls(work_dir: &Path) -> Vec<String> {
let cfg = work_dir.join(".git").join("config");
let content = std::fs::read_to_string(cfg).expect("must read .git/config");
let mut in_section = false;
let mut push_urls = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_section = trimmed == "[remote \"origin\"]";
continue;
}
if in_section
&& trimmed.starts_with("pushurl =")
&& let Some(val) = trimmed.split('=').nth(1)
{
push_urls.push(val.trim().to_string());
}
}
push_urls
}
#[test]
fn early_exit_when_both_push_urls_already_configured() {
let (_dir, config_path, work_dir) = setup_dirs();
let first = run_init(&["entangle"], &config_path, &work_dir);
assert!(first.status.success(), "first init must succeed");
append_origin_remote(
&work_dir,
"git@github.com:cyrusae/entangle.git",
&[
"git@github.com:cyrusae/entangle.git",
"git@tangled.org:atdot.fyi/entangle",
],
);
let output = run_init(&["entangle"], &config_path, &work_dir);
assert!(
output.status.success(),
"must exit 0 when already configured\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(
out.contains("already configured"),
"output must mention 'already configured'\nstdout: {out}"
);
}
#[test]
fn init_with_only_mirror_push_url_configured_adds_origin_push_url() {
let (_dir, config_path, work_dir) = setup_dirs();
gix::init(&work_dir).expect("gix::init must succeed");
append_origin_remote(
&work_dir,
"git@github.com:cyrusae/entangle.git",
&["git@tangled.org:atdot.fyi/entangle"],
);
let output = run_init(&["entangle"], &config_path, &work_dir);
assert!(
output.status.success(),
"init must succeed on partial config"
);
let push_urls = read_origin_push_urls(&work_dir);
assert_eq!(
push_urls,
vec![
"git@tangled.org:atdot.fyi/entangle",
"git@github.com:cyrusae/entangle.git",
]
);
}
#[test]
fn init_with_only_origin_push_url_configured_adds_mirror_push_url() {
let (_dir, config_path, work_dir) = setup_dirs();
gix::init(&work_dir).expect("gix::init must succeed");
append_origin_remote(
&work_dir,
"git@github.com:cyrusae/entangle.git",
&["git@github.com:cyrusae/entangle.git"],
);
let output = run_init(&["entangle"], &config_path, &work_dir);
assert!(
output.status.success(),
"init must succeed on partial config"
);
let push_urls = read_origin_push_urls(&work_dir);
assert_eq!(
push_urls,
vec![
"git@github.com:cyrusae/entangle.git",
"git@tangled.org:atdot.fyi/entangle",
]
);
}
#[test]
fn quiet_flag_suppresses_informational_stdout() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle", "-q"], &config_path, &work_dir);
assert!(
output.status.success(),
"quiet init must succeed\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(
!out.contains("Tip:"),
"quiet mode must suppress README/gitignore tips\nstdout: {out}"
);
assert!(
!out.contains("Configuring remotes"),
"quiet mode must suppress URL preview\nstdout: {out}"
);
assert!(
!out.contains("Git repository"),
"quiet mode must suppress git-init status\nstdout: {out}"
);
}
#[test]
fn verbose_flag_not_needed_for_default_output() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle"], &config_path, &work_dir);
assert!(output.status.success(), "default init must succeed");
let out = stdout(&output);
assert!(
out.contains("Configuring remotes"),
"default (verbose) mode must include URL preview\nstdout: {out}"
);
}
#[test]
fn debug_flag_emits_debug_lines() {
let (_dir, config_path, work_dir) = setup_dirs();
let output = run_init(&["entangle", "--debug"], &config_path, &work_dir);
assert!(
output.status.success(),
"debug init must succeed\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(
out.contains("[debug]"),
"debug mode must emit [debug] lines\nstdout: {out}"
);
assert!(
out.contains("Configuring remotes"),
"debug mode must include verbose URL preview\nstdout: {out}"
);
}
#[test]
fn config_verbosity_preference_quiet_suppresses_output() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.json");
let work_dir = dir.path().join("work");
std::fs::create_dir(&work_dir).unwrap();
let json = serde_json::json!({
"github_username": "cyrusae",
"tangled_username": "atdot.fyi",
"origin_preference": "github",
"verbosity_preference": "quiet",
});
std::fs::write(&config_path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
let output = run_init(&["entangle"], &config_path, &work_dir);
assert!(
output.status.success(),
"quiet-config init must succeed\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(
!out.contains("Configuring remotes"),
"quiet config preference must suppress URL preview\nstdout: {out}"
);
}
#[cfg(unix)]
mod pty_overwrite_tests {
use super::*;
use rexpect::session::spawn_command;
const TIMEOUT_MS: Option<u64> = Some(10_000);
fn setup_with_gitlab_origin() -> (TempDir, PathBuf, PathBuf) {
let (dir, config_path, work_dir) = setup_dirs();
gix::init(&work_dir).expect("gix::init must succeed");
append_origin_remote(&work_dir, "git@gitlab.com:someone/something.git", &[]);
(dir, config_path, work_dir)
}
fn make_cmd(args: &[&str], config_path: &Path, work_dir: &Path) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_entangle"));
cmd.arg("init");
for a in args {
cmd.arg(a);
}
cmd.env("ENTANGLE_CONFIG_PATH", config_path);
cmd.env("ENTANGLE_SKIP_REMOTE_CHECK", "1");
cmd.current_dir(work_dir);
cmd
}
#[test]
fn replace_yes_continues_to_url_preview() {
let (_dir, config_path, work_dir) = setup_with_gitlab_origin();
let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");
p.exp_string("Replace it with").unwrap();
p.send_line("").unwrap();
p.exp_string("github.com").unwrap();
p.exp_eof().unwrap();
}
#[test]
fn replace_no_proceed_yes_shows_warning_and_continues() {
let (_dir, config_path, work_dir) = setup_with_gitlab_origin();
let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");
p.exp_string("Replace it with").unwrap();
p.send("n").unwrap();
p.flush().unwrap();
p.exp_string("anyway").unwrap();
p.send_line("").unwrap();
p.exp_string("Note").unwrap();
p.exp_eof().unwrap();
}
fn read_git_config(work_dir: &Path) -> String {
std::fs::read_to_string(work_dir.join(".git").join("config"))
.expect("must be able to read .git/config")
}
#[test]
fn ctrl_c_at_replace_prompt_leaves_git_config_unchanged() {
let (_dir, config_path, work_dir) = setup_with_gitlab_origin();
let config_before = read_git_config(&work_dir);
let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");
p.exp_string("Replace it with").unwrap();
p.send_control('c').unwrap();
let _ = p.exp_eof();
assert_eq!(
config_before,
read_git_config(&work_dir),
".git/config must be unchanged after Ctrl+C at the replace prompt"
);
}
#[test]
fn ctrl_c_at_proceed_prompt_leaves_git_config_unchanged() {
let (_dir, config_path, work_dir) = setup_with_gitlab_origin();
let config_before = read_git_config(&work_dir);
let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");
p.exp_string("Replace it with").unwrap();
p.send("n").unwrap();
p.flush().unwrap();
p.exp_string("anyway").unwrap();
p.send_control('c').unwrap();
let _ = p.exp_eof();
assert_eq!(
config_before,
read_git_config(&work_dir),
".git/config must be unchanged after Ctrl+C at the proceed-anyway prompt"
);
}
#[test]
fn replace_no_proceed_no_prints_cancelled_and_exits() {
let (_dir, config_path, work_dir) = setup_with_gitlab_origin();
let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");
p.exp_string("Replace it with").unwrap();
p.send("n").unwrap();
p.flush().unwrap();
p.exp_string("anyway").unwrap();
p.send("n").unwrap();
p.flush().unwrap();
p.exp_string("cancelled").unwrap();
p.exp_eof().unwrap();
}
#[test]
fn interactive_init_prompts_for_repo_and_alias() {
let (_dir, config_path, work_dir) = setup_dirs();
let cmd = make_cmd(&[], &config_path, &work_dir);
let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");
p.exp_string("Repository name").unwrap();
p.send_line("my-interactive-project").unwrap();
p.exp_string("Alias on mirror forge").unwrap();
p.send_line("my-mirror-alias").unwrap();
p.exp_string("Configuring remotes for 'my-interactive-project'")
.unwrap();
p.exp_string("git@github.com:cyrusae/my-interactive-project.git")
.unwrap();
p.exp_string("git@tangled.org:atdot.fyi/my-mirror-alias")
.unwrap();
p.exp_eof().unwrap();
assert!(
work_dir.join(".git").exists(),
"git repository must be initialized"
);
let git_config = read_git_config(&work_dir);
assert!(
git_config.contains("url = git@github.com:cyrusae/my-interactive-project.git"),
"git config must contain correct fetch URL"
);
assert!(
git_config.contains("pushurl = git@tangled.org:atdot.fyi/my-mirror-alias"),
"git config must contain Tangled mirror push URL"
);
assert!(
git_config.contains("pushurl = git@github.com:cyrusae/my-interactive-project.git"),
"git config must contain GitHub origin push URL"
);
}
}