use gwm::doctor::{Check, DoctorReport};
use gwm::github::BranchLink;
use gwm::json_api::{self, check_status_str, JsonDoctorReport, JsonPath, JsonStatus, JsonWorktree};
use gwm::worktree::{BranchStatus, WorktreeInfo};
use std::path::PathBuf;
use std::time::Duration;
fn sample_worktree() -> WorktreeInfo {
let mut link = BranchLink::empty();
link.issue = Some(42);
link.pr = Some(99);
WorktreeInfo {
name: "feat-38".into(),
id: "feat-38-internal".into(),
path: PathBuf::from("/home/u/cc-worktree/repo/feat-38"),
branch: Some("feat/#38-json-api".into()),
head: Some("abc1234".into()),
is_main: false,
is_locked: false,
is_prunable: false,
status: BranchStatus {
is_dirty: true,
has_upstream: true,
ahead: 2,
behind: 1,
unknown: false,
},
link,
issue_state: None,
pr_state: None,
age: Some(Duration::from_secs(3600)),
}
}
#[test]
fn json_status_projects_every_branch_status_field() {
let bs = BranchStatus {
is_dirty: true,
has_upstream: true,
ahead: 3,
behind: 4,
unknown: false,
};
let dto = JsonStatus::from(&bs);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["is_dirty"], serde_json::json!(true));
assert_eq!(v["has_upstream"], serde_json::json!(true));
assert_eq!(v["ahead"], serde_json::json!(3));
assert_eq!(v["behind"], serde_json::json!(4));
assert_eq!(v["unknown"], serde_json::json!(false));
}
#[test]
fn json_worktree_maps_age_and_link_numbers() {
let w = sample_worktree();
let dto = JsonWorktree::from(&w);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["name"], serde_json::json!("feat-38"));
assert_eq!(v["id"], serde_json::json!("feat-38-internal"));
assert_eq!(v["path"], serde_json::json!("/home/u/cc-worktree/repo/feat-38"));
assert_eq!(v["branch"], serde_json::json!("feat/#38-json-api"));
assert_eq!(v["head"], serde_json::json!("abc1234"));
assert_eq!(v["is_main"], serde_json::json!(false));
assert_eq!(v["age_seconds"], serde_json::json!(3600));
assert_eq!(v["issue"], serde_json::json!(42));
assert_eq!(v["pr"], serde_json::json!(99));
assert_eq!(v["status"]["is_dirty"], serde_json::json!(true));
assert_eq!(v["status"]["ahead"], serde_json::json!(2));
assert_eq!(v["status"]["behind"], serde_json::json!(1));
}
#[test]
fn json_worktree_emits_null_for_absent_optionals() {
let mut w = sample_worktree();
w.age = None;
w.link = BranchLink::empty();
w.branch = None;
w.head = None;
let v = serde_json::to_value(JsonWorktree::from(&w)).unwrap();
assert_eq!(v["age_seconds"], serde_json::Value::Null);
assert_eq!(v["issue"], serde_json::Value::Null);
assert_eq!(v["pr"], serde_json::Value::Null);
assert_eq!(v["branch"], serde_json::Value::Null);
assert_eq!(v["head"], serde_json::Value::Null);
}
#[test]
fn json_path_is_the_name_path_branch_triple() {
let w = sample_worktree();
let dto = JsonPath::from(&w);
let v = serde_json::to_value(&dto).unwrap();
let obj = v.as_object().unwrap();
assert_eq!(obj.len(), 3, "JsonPath must be exactly {{name, path, branch}}");
assert_eq!(v["name"], serde_json::json!("feat-38"));
assert_eq!(v["path"], serde_json::json!("/home/u/cc-worktree/repo/feat-38"));
assert_eq!(v["branch"], serde_json::json!("feat/#38-json-api"));
}
#[test]
fn check_status_strings_are_stable_lowercase() {
use gwm::doctor::CheckStatus;
assert_eq!(check_status_str(&CheckStatus::Ok), "ok");
assert_eq!(check_status_str(&CheckStatus::Warning), "warning");
assert_eq!(check_status_str(&CheckStatus::Failed), "failed");
}
#[test]
fn json_doctor_report_carries_checks_severity_and_exit_code() {
let mut report = DoctorReport::new();
report.checks.push(Check::ok("config", "found .gwm.toml"));
report
.checks
.push(Check::warning("upstream", "no upstream set").with_hint("git push -u"));
let dto = JsonDoctorReport::from(&report);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["checks"][0]["name"], serde_json::json!("config"));
assert_eq!(v["checks"][0]["status"], serde_json::json!("ok"));
assert_eq!(v["checks"][0]["detail"], serde_json::json!("found .gwm.toml"));
assert_eq!(v["checks"][0]["fix_hint"], serde_json::Value::Null);
assert_eq!(v["checks"][1]["status"], serde_json::json!("warning"));
assert_eq!(v["checks"][1]["fix_hint"], serde_json::json!("git push -u"));
assert_eq!(v["severity"], serde_json::json!("warning"));
assert_eq!(v["exit_code"], serde_json::json!(1));
}
#[test]
fn json_doctor_report_failed_wins_exit_two() {
let mut report = DoctorReport::new();
report.checks.push(Check::ok("a", "ok"));
report.checks.push(Check::warning("b", "warn"));
report.checks.push(Check::failed("c", "boom"));
let v = serde_json::to_value(JsonDoctorReport::from(&report)).unwrap();
assert_eq!(v["severity"], serde_json::json!("failed"));
assert_eq!(v["exit_code"], serde_json::json!(2));
}
#[test]
fn json_worktree_serialize_deserialize_round_trips() {
let wt = JsonWorktree {
name: "feat-309".into(),
id: "feat-309".into(),
path: "/wt/feat-309".into(),
branch: Some("feat/#309-daemon-consumer".into()),
head: Some("a".repeat(40)),
is_main: false,
is_locked: false,
is_prunable: false,
status: JsonStatus {
is_dirty: true,
has_upstream: true,
ahead: 2,
behind: 1,
unknown: false,
},
age_seconds: Some(42),
issue: Some(309),
pr: Some(310),
agents: None,
};
let line = serde_json::to_string(&wt).unwrap();
let back: JsonWorktree = serde_json::from_str(&line).unwrap();
assert_eq!(back, wt, "a serialised worktree must decode back identically");
}
#[test]
fn json_worktree_vec_decodes_from_a_daemon_list_result() {
let json = r#"[
{"name":"main","id":"main","path":"/repo","branch":"main","head":null,
"is_main":true,"is_locked":false,"is_prunable":false,
"status":{"is_dirty":false,"has_upstream":false,"ahead":0,"behind":0,"unknown":true},
"age_seconds":null,"issue":null,"pr":null}
]"#;
let wts: Vec<JsonWorktree> = serde_json::from_str(json).unwrap();
assert_eq!(wts.len(), 1);
assert!(wts[0].is_main);
assert!(wts[0].status.unknown);
}
#[test]
fn attach_agents_reuses_detection_within_the_ttl() {
let home = tempfile::TempDir::new().unwrap();
std::env::set_var("GWM_AGENTS_HOME", home.path());
let dir = home
.path()
.join(".codex/sessions")
.join(gwm::agent_sessions::codex_day_dir(std::time::SystemTime::now()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("rollout-cache.jsonl"),
r#"{"type":"session_meta","payload":{"session_id":"cache-1","cwd":"/work/cached"}}
"#,
)
.unwrap();
let row = || JsonWorktree {
name: "cached".into(),
id: "cached".into(),
path: "/work/cached".into(),
branch: Some("main".into()),
head: None,
is_main: false,
is_locked: false,
is_prunable: false,
status: JsonStatus {
is_dirty: false,
has_upstream: false,
ahead: 0,
behind: 0,
unknown: false,
},
age_seconds: None,
issue: None,
pr: None,
agents: None,
};
let reals = vec![std::path::PathBuf::from("/work/cached")];
let mut rows = vec![row()];
json_api::attach_agents(&mut rows, &reals, &[]);
assert!(rows[0].agents.is_some(), "seeded session must be detected");
std::fs::remove_dir_all(home.path().join(".codex")).unwrap();
let mut rows2 = vec![row()];
json_api::attach_agents(&mut rows2, &reals, &[]);
assert!(
rows2[0].agents.is_some(),
"within the TTL the cached detection must be reused (no re-walk)"
);
}
#[cfg(unix)]
#[test]
fn json_worktree_path_stays_the_plain_lossy_absolute_path() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let mk = |bytes: &[u8]| gwm::worktree::WorktreeInfo {
name: "wt".into(),
id: "wt".into(),
path: std::path::PathBuf::from("/repo").join(OsStr::from_bytes(bytes)),
branch: Some("main".into()),
head: None,
is_main: false,
is_locked: false,
is_prunable: false,
status: Default::default(),
link: gwm::github::BranchLink::empty(),
issue_state: None,
pr_state: None,
age: None,
};
let a = JsonWorktree::from(&mk(b"wt-\xff"));
let b = JsonWorktree::from(&mk(b"wt-\xfe"));
assert_eq!(
a.path,
mk(b"wt-\xff").path.to_string_lossy(),
"no suffix, plain lossy value"
);
assert_eq!(
a.path, b.path,
"lossy public paths may collide — the internal key disambiguates"
);
}