mod common;
use common::{init_project, run_commands, today};
use serde_json::Value;
use std::fs;
use std::process::Command;
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::{Duration, Instant};
#[test]
fn test_write_command_creates_lock_file() {
let temp_dir = init_project();
let _date = today();
let output = run_commands(
temp_dir.path(),
&[&["work", "new", "Test work item", "--active"]],
);
assert!(output.contains("Created work item"));
let lock_path = temp_dir.path().join("gov/.govctl.lock");
assert!(
lock_path.exists(),
"Lock file should exist after write command"
);
}
#[test]
fn test_sequential_write_commands_succeed() {
let temp_dir = init_project();
let _date = today();
let output = run_commands(
temp_dir.path(),
&[
&["work", "new", "First work item"],
&["work", "new", "Second work item"],
],
);
assert!(output.contains("Created work item"));
assert!(output.contains("exit: 0"));
}
#[test]
fn test_lock_file_location() {
let temp_dir = init_project();
let lock_path = temp_dir.path().join("gov/.govctl.lock");
let _ = fs::remove_file(&lock_path);
run_commands(temp_dir.path(), &[&["rfc", "new", "Test RFC"]]);
assert!(
lock_path.exists(),
"Lock file should be created under gov root"
);
}
#[test]
fn test_read_commands_no_lock() {
let temp_dir = init_project();
let _date = today();
let lock_path = temp_dir.path().join("gov/.govctl.lock");
let _ = fs::remove_file(&lock_path);
run_commands(temp_dir.path(), &[&["status"]]);
assert!(
!lock_path.exists(),
"Read commands should not create lock file"
);
run_commands(temp_dir.path(), &[&["check"]]);
assert!(
!lock_path.exists(),
"Read commands should not create lock file"
);
}
#[test]
fn test_lock_timeout_configurable() {
let temp_dir = init_project();
let config_path = temp_dir.path().join("gov/config.toml");
let config_content = r#"[project]
name = "test-project"
[paths]
docs_output = "docs"
[concurrency]
lock_timeout_secs = 1
"#;
fs::write(&config_path, config_content).unwrap();
let output = run_commands(temp_dir.path(), &[&["work", "new", "Test"]]);
assert!(output.contains("Created work item"));
}
#[test]
fn test_lock_released_after_write() {
let temp_dir = init_project();
let lock_path = temp_dir.path().join("gov/.govctl.lock");
let _ = fs::remove_file(&lock_path);
run_commands(temp_dir.path(), &[&["work", "new", "Test"]]);
assert!(lock_path.exists(), "Lock file should exist");
let start = Instant::now();
let output = run_commands(temp_dir.path(), &[&["work", "new", "Test2"]]);
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"Second write should succeed immediately, took {:?}",
elapsed
);
assert!(output.contains("Created work item"));
}
fn kill_and_wait(mut child: std::process::Child, timeout: Duration) {
let _ = child.kill();
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) => {
thread::sleep(Duration::from_millis(50));
}
Err(_) => return,
}
}
let _ = child.kill();
}
fn create_config_with_timeout(temp_dir: &std::path::Path, timeout_secs: u64) {
let config_path = temp_dir.join("gov/config.toml");
let config_content = format!(
r#"[project]
name = "test-project"
[paths]
docs_output = "docs"
[concurrency]
lock_timeout_secs = {}
"#,
timeout_secs
);
fs::write(&config_path, config_content).unwrap();
}
#[test]
fn test_concurrent_write_blocked_by_lock() {
let temp_dir = init_project();
create_config_with_timeout(temp_dir.path(), 1);
let work_dir = temp_dir.path().join("gov/work");
fs::create_dir_all(&work_dir).unwrap();
let work_file = work_dir.join("2026-01-01-test-item.toml");
fs::write(
&work_file,
r#"[govctl]
schema = 1
id = "WI-2026-01-01-001"
title = "Test Item"
status = "queue"
created = "2026-01-01"
[content]
description = "Test"
acceptance_criteria = []
"#,
)
.unwrap();
let holder = Command::new(env!("CARGO_BIN_EXE_govctl"))
.args(["work", "delete", "WI-2026-01-01-001"])
.current_dir(temp_dir.path())
.env("NO_COLOR", "1")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("Failed to start lock holder");
thread::sleep(Duration::from_millis(500));
let start = Instant::now();
let result = Command::new(env!("CARGO_BIN_EXE_govctl"))
.args(["work", "new", "Should timeout"])
.current_dir(temp_dir.path())
.env("NO_COLOR", "1")
.output()
.expect("Failed to run govctl");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(10),
"Command should have timed out quickly, but took {:?}",
elapsed
);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("Another govctl write command is in progress")
|| stderr.contains("Timed out"),
"Expected timeout error, got: {}",
stderr
);
kill_and_wait(holder, Duration::from_secs(2));
}
#[test]
fn test_write_succeeds_after_lock_released() {
let temp_dir = init_project();
create_config_with_timeout(temp_dir.path(), 30);
let work_dir = temp_dir.path().join("gov/work");
fs::create_dir_all(&work_dir).unwrap();
let work_file = work_dir.join("2026-01-01-test-item.toml");
fs::write(
&work_file,
r#"[govctl]
schema = 1
id = "WI-2026-01-01-001"
title = "Test Item"
status = "queue"
created = "2026-01-01"
[content]
description = "Test"
acceptance_criteria = []
"#,
)
.unwrap();
let result = Command::new(env!("CARGO_BIN_EXE_govctl"))
.args(["work", "delete", "WI-2026-01-01-001", "-f"])
.current_dir(temp_dir.path())
.env("NO_COLOR", "1")
.status()
.expect("Failed to run govctl");
assert!(result.success(), "Delete should succeed");
let start = Instant::now();
let output = run_commands(temp_dir.path(), &[&["work", "new", "After release"]]);
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"Write should succeed immediately, took {:?}",
elapsed
);
assert!(output.contains("Created work item"));
}
#[test]
fn test_write_command_without_init_reports_missing_gov_root() {
let temp_dir = tempfile::TempDir::new().expect("temp dir");
let output = run_commands(temp_dir.path(), &[&["work", "new", "Needs init"]]);
assert!(output.contains("exit: 1"), "output: {}", output);
assert!(output.contains("error[E0502]"), "output: {}", output);
assert!(
output.contains("Run 'govctl init' first"),
"output: {}",
output
);
}
#[test]
fn test_concurrent_tick_commands_persist_all_acceptance_criteria_updates() {
let temp_dir = init_project();
create_config_with_timeout(temp_dir.path(), 30);
let today = today();
let wi_id = format!("WI-{today}-001");
let create_output = run_commands(
temp_dir.path(),
&[&["work", "new", "Concurrent tick persistence", "--active"]],
);
assert!(
create_output.contains(&wi_id),
"expected work item id in output: {create_output}"
);
let setup_output = run_commands(
temp_dir.path(),
&[
&[
"work",
"add",
wi_id.as_str(),
"acceptance_criteria",
"test: criterion one",
],
&[
"work",
"add",
wi_id.as_str(),
"acceptance_criteria",
"test: criterion two",
],
&[
"work",
"add",
wi_id.as_str(),
"acceptance_criteria",
"test: criterion three",
],
],
);
assert!(
setup_output.contains("exit: 0"),
"setup output: {setup_output}"
);
let barrier = Arc::new(Barrier::new(3));
let mut handles = Vec::new();
for index in 0..3 {
let dir = temp_dir.path().to_path_buf();
let wi_id = wi_id.clone();
let barrier = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
barrier.wait();
Command::new(env!("CARGO_BIN_EXE_govctl"))
.args([
"work",
"edit",
&wi_id,
&format!("acceptance_criteria[{index}]"),
"--tick",
"done",
])
.current_dir(dir)
.env("NO_COLOR", "1")
.env("GOVCTL_DEFAULT_OWNER", "@test-user")
.output()
.expect("failed to run concurrent tick command")
}));
}
for handle in handles {
let output = handle.join().expect("tick thread panicked");
assert!(
output.status.success(),
"concurrent tick failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let get_output = Command::new(env!("CARGO_BIN_EXE_govctl"))
.args(["work", "show", &wi_id, "-o", "json"])
.current_dir(temp_dir.path())
.env("NO_COLOR", "1")
.env("GOVCTL_DEFAULT_OWNER", "@test-user")
.output()
.expect("failed to read work item json");
assert!(
get_output.status.success(),
"work show failed: stdout={} stderr={}",
String::from_utf8_lossy(&get_output.stdout),
String::from_utf8_lossy(&get_output.stderr)
);
let work: Value = serde_json::from_slice(&get_output.stdout).expect("valid work item json");
let criteria = work["content"]["acceptance_criteria"]
.as_array()
.expect("acceptance_criteria array");
let done_count = criteria
.iter()
.filter(|item| item["status"] == "done")
.count();
assert_eq!(
done_count,
3,
"expected all criteria to persist as done, got:\n{}",
String::from_utf8_lossy(&get_output.stdout)
);
}