mod support;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use serde_json::Value;
use support::World;
#[test]
fn daemon_start_is_idempotent_and_uses_one_process() {
let world = World::configured();
let first = world.start_daemon();
let second = world.start_daemon();
assert_eq!(first["data"]["started"], true);
assert_eq!(second["data"]["started"], false);
assert_eq!(first["data"]["pid"], second["data"]["pid"]);
assert_eq!(first["data"]["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn foreground_start_cannot_bypass_the_single_daemon_lock() {
let world = World::configured();
let daemon = world.start_daemon();
let competing = world.sloop(&["daemon", "--foreground"]);
assert!(competing.status.success());
assert!(competing.stdout.is_empty());
assert!(competing.stderr.is_empty());
let status = world.sloop(&["status"]);
assert!(status.status.success());
assert_eq!(
World::json_stdout(&status)["data"]["daemon"]["pid"],
daemon["data"]["pid"]
);
}
#[test]
fn alternate_runtime_roots_cannot_bypass_the_state_database_lock() {
let world = World::configured();
let daemon = world.start_daemon();
let alternate_runtime = tempfile::tempdir().expect("create alternate runtime");
let competing = world.sloop_with_runtime(&["status"], alternate_runtime.path());
assert!(competing.status.success());
assert_eq!(
World::json_stdout(&competing)["data"]["daemon"]["pid"],
daemon["data"]["pid"]
);
let foreground =
world.sloop_with_runtime(&["daemon", "--foreground"], alternate_runtime.path());
assert!(foreground.status.success());
let status = world.sloop(&["status"]);
assert!(status.status.success());
assert_eq!(
World::json_stdout(&status)["data"]["daemon"]["pid"],
daemon["data"]["pid"]
);
}
#[test]
fn status_uses_the_real_socket_and_dispatcher() {
let world = World::configured();
let daemon = world.start_daemon();
let nested = world.root().join("src/nested");
fs::create_dir_all(&nested).expect("create nested directory");
let output = world.sloop_in(&nested, &["status"]);
assert!(output.status.success());
assert!(output.stderr.is_empty());
let response = World::json_stdout(&output);
assert!(response["id"].as_str().unwrap().starts_with("req-"));
assert_eq!(response["data"]["daemon"]["pid"], daemon["data"]["pid"]);
assert_eq!(response["data"]["daemon"]["paused"], false);
assert_eq!(response["data"]["gate"]["active_agents"], 0);
assert_eq!(response["data"]["gate"]["max_agents"], 1);
assert_eq!(response["data"]["runs"], serde_json::json!([]));
}
#[test]
fn operator_socket_is_private() {
let world = World::configured();
world.start_daemon();
let mode = fs::metadata(world.operator_socket())
.expect("operator socket metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn machine_local_files_live_outside_the_repository() {
let world = World::configured();
let daemon = world.start_daemon();
assert_eq!(
daemon["data"]["socket"],
world.operator_socket().to_string_lossy().as_ref()
);
assert_eq!(
daemon["data"]["state_dir"],
world.state_dir().to_string_lossy().as_ref()
);
assert_eq!(
daemon["data"]["log"],
world.daemon_log().to_string_lossy().as_ref()
);
assert!(world.db_path().is_file());
assert!(world.lock_path().is_file());
assert!(world.daemon_log().is_file());
for directory in [world.state_dir(), world.runtime_dir()] {
let mode = fs::metadata(directory).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
}
assert!(!world.root().join(".sloop").exists());
}
#[test]
fn malformed_requests_return_errors_without_stopping_the_daemon() {
let world = World::configured();
world.start_daemon();
let malformed = world.operator_exchange("{");
assert_eq!(malformed["ok"], false);
assert_eq!(malformed["error"]["code"], "invalid_request");
let output = world.sloop(&["status"]);
assert!(output.status.success());
assert_eq!(World::json_stdout(&output)["ok"], true);
}
#[test]
fn unknown_verbs_and_versions_are_structured_socket_errors() {
let world = World::configured();
world.start_daemon();
let unknown =
world.operator_exchange(r#"{"v":1,"id":"req-x","verb":"merge","args":{},"token":null}"#);
assert_eq!(unknown["error"]["code"], "unknown_verb");
let version =
world.operator_exchange(r#"{"v":99,"id":"req-y","verb":"status","args":{},"token":null}"#);
assert_eq!(version["error"]["code"], "unsupported_version");
}
#[test]
fn daemon_log_is_ndjson_and_separate_from_cli_output() {
let world = World::configured();
world.start_daemon();
let output = world.sloop(&["status"]);
assert!(output.status.success());
let log = fs::read_to_string(world.daemon_log()).expect("read daemon log");
let records: Vec<Value> = log
.lines()
.map(|line| serde_json::from_str(line).expect("daemon log line is JSON"))
.collect();
assert!(
records
.iter()
.any(|record| record["event"] == "daemon_started")
);
assert!(
records
.iter()
.any(|record| record["event"] == "request_handled")
);
}
#[test]
fn malformed_config_fails_before_daemon_start() {
let world = World::configured();
fs::write(
world.root().join(".agents/sloop/config.yaml"),
"version: 9\n",
)
.expect("write invalid config");
let output = world.sloop(&["daemon"]);
assert!(!output.status.success());
assert!(output.stdout.is_empty());
let response: Value = serde_json::from_slice(&output.stderr).expect("error is JSON");
assert_eq!(response["error"]["code"], "invalid_arguments");
assert!(!world.operator_socket().exists());
}
#[test]
fn invalid_id_prefix_fails_before_daemon_start_with_the_configuration_key() {
let world = World::configured();
fs::write(
world.root().join(".agents/sloop/config.yaml"),
"version: 1\nids:\n project_prefix: 'not valid'\n",
)
.expect("write invalid config");
let output = world.sloop(&["daemon"]);
assert!(!output.status.success());
let response: Value = serde_json::from_slice(&output.stderr).expect("error is JSON");
assert_eq!(response["error"]["code"], "invalid_arguments");
assert!(
response["error"]["message"]
.as_str()
.expect("error message")
.contains("ids.project_prefix")
);
assert!(!world.operator_socket().exists());
}
#[test]
fn startup_stamps_idless_projects_in_sorted_order_without_rewriting_explicit_projects() {
let world = World::configured();
let projects = world.root().join(".agents/sloop/projects");
let default_path = projects.join("default.md");
let default_content = fs::read_to_string(&default_path).expect("read default project");
fs::write(
projects.join("zeta.md"),
"---\ntitle: Zeta\n---\nZeta body\n",
)
.expect("write zeta project");
fs::write(projects.join("alpha.md"), "# Alpha body\n").expect("write alpha project");
world.start_daemon();
let alpha = fs::read_to_string(projects.join("alpha.md")).expect("read alpha project");
let zeta = fs::read_to_string(projects.join("zeta.md")).expect("read zeta project");
assert!(alpha.starts_with("---\nid: PROJ-1\n---\n"), "{alpha}");
assert!(zeta.contains("id: PROJ-2\n"), "{zeta}");
assert!(!alpha.contains("project:"));
assert!(!zeta.contains("project:"));
assert_eq!(
fs::read_to_string(default_path).expect("reread default project"),
default_content
);
let ticket = world.write_ticket("alpha-work.md", "# Alpha work\n");
let output = world.sloop(&[
"post",
ticket.to_str().expect("UTF-8 ticket path"),
"--project",
"PROJ-1",
"--manual",
]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
World::json_stdout(&output)["data"]["ticket"]["project"],
"PROJ-1"
);
}
#[test]
fn startup_uses_the_repository_project_prefix() {
let world = World::configured();
fs::write(
world.root().join(".agents/sloop/config.yaml"),
"version: 1\nids:\n project_prefix: TEAM\n",
)
.expect("write configured project prefix");
let project = world.root().join(".agents/sloop/projects/team.md");
fs::write(&project, "---\ntitle: Team\n---\n").expect("write team project");
world.start_daemon();
assert!(
fs::read_to_string(project)
.expect("read stamped project")
.contains("id: TEAM-1")
);
}
#[test]
fn user_scheduler_defaults_apply_when_the_repository_omits_them() {
let world = World::configured();
fs::write(
world.root().join(".agents/sloop/config.yaml"),
"version: 1\n",
)
.expect("write repository config");
let user_config = world.root().join("home/.config/sloop");
fs::create_dir_all(&user_config).expect("create user config directory");
fs::write(
user_config.join("config.yaml"),
"version: 1\ndefaults:\n scheduler:\n max_parallel_tasks: 4\n",
)
.expect("write user config");
world.start_daemon();
let status = world.sloop(&["status"]);
assert!(status.status.success());
assert_eq!(World::json_stdout(&status)["data"]["gate"]["max_agents"], 4);
}
#[test]
fn repository_scheduler_values_override_user_defaults() {
let world = World::configured();
let user_config = world.root().join("home/.config/sloop");
fs::create_dir_all(&user_config).expect("create user config directory");
fs::write(
user_config.join("config.yaml"),
"version: 1\ndefaults:\n scheduler:\n max_parallel_tasks: 4\n",
)
.expect("write user config");
world.start_daemon();
let status = world.sloop(&["status"]);
assert!(status.status.success());
assert_eq!(World::json_stdout(&status)["data"]["gate"]["max_agents"], 1);
}
#[test]
fn user_configuration_cannot_supply_repository_id_prefixes() {
let world = World::configured();
let user_config = world.root().join("home/.config/sloop");
fs::create_dir_all(&user_config).expect("create user config directory");
fs::write(
user_config.join("config.yaml"),
"version: 1\nids:\n ticket_prefix: USER\n project_prefix: PERSONAL\n",
)
.expect("write user config");
let project = world.root().join(".agents/sloop/projects/other.md");
fs::write(&project, "# Other\n").expect("write project");
let ticket = world.write_ticket("other.md", "# Other work\n");
world.start_daemon();
let output = world.sloop(&[
"post",
ticket.to_str().expect("UTF-8 ticket path"),
"--manual",
]);
assert_eq!(
World::json_stdout(&output)["data"]["ticket"]["id"],
"TICK-1"
);
assert!(
fs::read_to_string(project)
.expect("read stamped project")
.contains("id: PROJ-1")
);
}
#[test]
fn user_configuration_cannot_supply_repository_directories() {
let world = World::configured();
fs::write(
world.root().join(".agents/sloop/config.yaml"),
"version: 1\n",
)
.expect("write repository config");
let user_config = world.root().join("home/.config/sloop");
fs::create_dir_all(&user_config).expect("create user config directory");
fs::write(
user_config.join("config.yaml"),
concat!(
"version: 1\n",
"worktree_dir: /tmp/user-worktrees\n",
"project_dir: /tmp/user-projects\n",
"ticket_dir: /tmp/user-tickets\n",
),
)
.expect("write user config");
let output = world.sloop(&["daemon"]);
assert!(
output.status.success(),
"repository defaults should ignore user directory settings: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn status_renders_a_human_summary_without_the_json_flag() {
let world = World::configured();
world.start_daemon();
let output = world.sloop_plain(&["status"]);
assert!(output.status.success());
let text = String::from_utf8(output.stdout).expect("stdout is UTF-8");
assert!(
serde_json::from_str::<Value>(&text).is_err(),
"default status output must not be JSON: {text}"
);
assert!(text.contains("ready"), "ticket counts are shown: {text}");
assert!(text.contains("agents"), "the gate is shown: {text}");
}