use std::path::PathBuf;
use std::sync::Mutex;
use crate::commands::first_run::needs_first_run_clone;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
use crate::commands::guided::{
CwdProject, NestedFallbackAction, NonGitFallbackPlan, classify_cwd_project, cwd_owns_git_entry,
derive_project, fallback_protected, format_project_context_row, github_host,
guard_managed_sessions, inplace_self_relaunch_hint, is_github_remote,
ls_tree_reports_tracked_dir, managed_pane_settle_pending_message, nested_fallback_action,
nested_guard_notice, non_github_refusal_message, plan_non_git_fallback, print_non_tty_hint,
print_project_context, tty_gate, untracked_ancestor_message,
};
use crate::commands::guided_launch::spawn_progress_message;
use crate::commands::guided_resume::{
ResumeAction, is_zombie, needs_restart, panes_prove_session_dead, parse_pane_probes,
plan_resume, resume_classification_state, session_runtime_live,
};
use trusty_mpm::daemon::orphan_gc::AlwaysIdleProbe;
use crate::commands::session_picker::{PickerDecision, parse_picker_choice};
#[test]
fn guided_picker_bare_enter_no_sessions_launches_new() {
assert_eq!(
parse_picker_choice("", &[], false),
PickerDecision::LaunchNew
);
assert_eq!(
parse_picker_choice(" \t", &[], false),
PickerDecision::LaunchNew
);
}
#[test]
fn guided_picker_bare_enter_live_session_resumes_first() {
assert_eq!(
parse_picker_choice("", &picker_fixture(1, &[]), false),
PickerDecision::Resume(0)
);
assert_eq!(
parse_picker_choice(" ", &picker_fixture(3, &[]), false),
PickerDecision::Resume(0)
);
}
#[test]
fn guided_picker_bare_enter_stopped_session_requires_confirm() {
assert_eq!(
parse_picker_choice("", &picker_fixture(1, &[]), true),
PickerDecision::ConfirmRestart(0)
);
assert_eq!(
parse_picker_choice(" ", &picker_fixture(3, &[]), true),
PickerDecision::ConfirmRestart(0)
);
}
#[test]
fn guided_picker_bare_enter_unresumable_session_blocked() {
assert_eq!(
parse_picker_choice("", &picker_fixture(1, &[true]), true),
PickerDecision::Unresumable(0)
);
assert_eq!(
parse_picker_choice(" ", &picker_fixture(3, &[true, false, false]), true),
PickerDecision::Unresumable(0)
);
}
#[test]
fn guided_picker_numeric_unresumable_session_blocked() {
assert_eq!(
parse_picker_choice("2", &picker_fixture(3, &[false, true, false]), false),
PickerDecision::Unresumable(1)
);
assert_eq!(
parse_picker_choice("1", &picker_fixture(3, &[false, true, false]), false),
PickerDecision::Resume(0)
);
}
#[test]
fn guided_picker_numeric_deleted_slot_blocked() {
let sessions = vec![
at_slot(make_session("s1", "active", None), 1),
deleted_slot(2),
at_slot(make_session("s3", "active", None), 3),
];
assert_eq!(
parse_picker_choice("2", &sessions, false),
PickerDecision::SlotDeleted(1)
);
assert_eq!(
parse_picker_choice("3", &sessions, false),
PickerDecision::Resume(2)
);
}
#[test]
fn guided_picker_bare_enter_on_deleted_first_slot_blocked() {
let sessions = vec![
deleted_slot(1),
at_slot(make_session("s2", "active", None), 2),
];
assert_eq!(
parse_picker_choice("", &sessions, false),
PickerDecision::SlotDeleted(0)
);
}
#[test]
fn guided_picker_delete_prefix_on_deleted_slot_blocked() {
let sessions = vec![
at_slot(make_session("s1", "active", None), 1),
deleted_slot(2),
];
assert_eq!(
parse_picker_choice("d2", &sessions, false),
PickerDecision::SlotDeleted(1)
);
}
#[test]
fn guided_picker_launch_new_uses_highest_slot_with_gaps() {
let sessions = vec![
at_slot(make_session("s1", "active", None), 1),
deleted_slot(5),
];
assert_eq!(
parse_picker_choice("6", &sessions, false),
PickerDecision::LaunchNew
);
assert_eq!(
parse_picker_choice("3", &sessions, false),
PickerDecision::Unrecognised
);
}
#[test]
fn guided_picker_delete_parses_d_prefix() {
assert_eq!(
parse_picker_choice("d1", &picker_fixture(1, &[]), false),
PickerDecision::Delete(0)
);
assert_eq!(
parse_picker_choice("d2", &picker_fixture(3, &[]), true),
PickerDecision::Delete(1)
);
assert_eq!(
parse_picker_choice("D3\n", &picker_fixture(3, &[]), false),
PickerDecision::Delete(2)
);
assert_eq!(
parse_picker_choice("d 2", &picker_fixture(3, &[]), false),
PickerDecision::Delete(1)
);
}
#[test]
fn guided_picker_delete_out_of_range_unrecognised() {
assert_eq!(
parse_picker_choice("d4", &picker_fixture(3, &[]), false),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("d0", &picker_fixture(3, &[]), false),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("dx", &picker_fixture(3, &[]), false),
PickerDecision::Unrecognised
);
}
#[test]
fn guided_picker_q_returns_quit() {
assert_eq!(parse_picker_choice("q", &[], false), PickerDecision::Quit);
assert_eq!(
parse_picker_choice("q", &picker_fixture(3, &[]), true),
PickerDecision::Quit
);
}
#[test]
fn guided_picker_q_uppercase_returns_quit() {
assert_eq!(
parse_picker_choice("Q", &picker_fixture(2, &[]), false),
PickerDecision::Quit
);
assert_eq!(parse_picker_choice("Q\n", &[], false), PickerDecision::Quit);
}
#[test]
fn guided_picker_numeric_valid_resumes() {
assert_eq!(
parse_picker_choice("1", &picker_fixture(1, &[]), true),
PickerDecision::Resume(0)
);
assert_eq!(
parse_picker_choice("1", &picker_fixture(3, &[]), true),
PickerDecision::Resume(0)
);
assert_eq!(
parse_picker_choice("2", &picker_fixture(3, &[]), false),
PickerDecision::Resume(1)
);
assert_eq!(
parse_picker_choice("3", &picker_fixture(3, &[]), false),
PickerDecision::Resume(2)
);
assert_eq!(
parse_picker_choice("2\n", &picker_fixture(3, &[]), false),
PickerDecision::Resume(1)
);
}
#[test]
fn guided_picker_numeric_launch_new() {
assert_eq!(
parse_picker_choice("1", &[], false),
PickerDecision::LaunchNew
);
assert_eq!(
parse_picker_choice("4", &picker_fixture(3, &[]), false),
PickerDecision::LaunchNew
);
}
#[test]
fn guided_picker_stale_daemon_slots_render_distinct_incrementing_choices() {
let sessions = vec![
make_session("s1", "active", None),
make_session("s2", "active", None),
make_session("s3", "active", None),
];
assert_eq!(
parse_picker_choice("1", &sessions, false),
PickerDecision::Resume(0)
);
assert_eq!(
parse_picker_choice("2", &sessions, false),
PickerDecision::Resume(1)
);
assert_eq!(
parse_picker_choice("3", &sessions, false),
PickerDecision::Resume(2)
);
assert_eq!(
parse_picker_choice("4", &sessions, false),
PickerDecision::LaunchNew
);
}
#[test]
fn guided_picker_stale_daemon_numeric_choice_resolves_correct_row() {
let sessions = vec![
make_session("s1", "active", None),
make_session("s2", "active", None),
make_session("s3", "active", None),
];
assert_eq!(
parse_picker_choice("2", &sessions, false),
PickerDecision::Resume(1),
"typing 2 must resolve to the second row, not always the first"
);
assert_ne!(
parse_picker_choice("2", &sessions, false),
PickerDecision::Resume(0)
);
}
#[test]
fn guided_picker_out_of_range_unrecognised() {
assert_eq!(
parse_picker_choice("5", &picker_fixture(3, &[]), false),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("100", &picker_fixture(1, &[]), false),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("0", &picker_fixture(3, &[]), false),
PickerDecision::Unrecognised
);
}
#[test]
fn guided_picker_non_numeric_unrecognised() {
assert_eq!(
parse_picker_choice("abc", &picker_fixture(2, &[]), false),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("exit", &[], false),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("1a", &picker_fixture(3, &[]), false),
PickerDecision::Unrecognised
);
}
#[test]
fn guided_picker_ls_returns_list_sessions() {
assert_eq!(
parse_picker_choice("ls", &picker_fixture(3, &[]), false),
PickerDecision::ListSessions
);
assert_eq!(
parse_picker_choice("LS", &picker_fixture(3, &[]), false),
PickerDecision::ListSessions
);
assert_eq!(
parse_picker_choice("ls\n", &[], false),
PickerDecision::ListSessions
);
assert_eq!(
parse_picker_choice(" ls ", &picker_fixture(1, &[]), false),
PickerDecision::ListSessions
);
}
#[test]
fn guided_picker_list_returns_list_sessions() {
assert_eq!(
parse_picker_choice("list", &picker_fixture(3, &[]), false),
PickerDecision::ListSessions
);
assert_eq!(
parse_picker_choice("List", &picker_fixture(3, &[]), false),
PickerDecision::ListSessions
);
assert_eq!(
parse_picker_choice("LIST\n", &[], false),
PickerDecision::ListSessions
);
}
#[test]
fn guided_picker_ls_does_not_shadow_launch_new_or_delete_prefix() {
assert_eq!(
parse_picker_choice("list1", &picker_fixture(3, &[]), false),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("lsx", &picker_fixture(3, &[]), false),
PickerDecision::Unrecognised
);
}
#[test]
fn guided_non_tty_gate_returns_false_skips_stdin() {
let result = tty_gate(false, "owner/repo", &PathBuf::from("/ws/owner/repo"), &[]);
assert!(
!result,
"non-TTY gate must return false (no picker) for empty sessions"
);
}
#[test]
fn guided_tty_gate_returns_true_for_tty() {
let result = tty_gate(true, "owner/repo", &PathBuf::from("/ws/owner/repo"), &[]);
assert!(
result,
"TTY gate must return true so caller runs the picker"
);
}
#[test]
fn guided_non_tty_gate_returns_false_with_sessions() {
let sessions = vec![make_session("tm-api-1", "running", None)];
let result = tty_gate(false, "owner/repo", &PathBuf::from("/ws"), &sessions);
assert!(
!result,
"non-TTY gate must return false regardless of session count"
);
}
#[test]
fn guided_derive_project_returns_none_for_non_git_dir() {
let tmp = std::env::temp_dir();
let non_git = tmp.join("trusty_test_non_git_dir_1705");
std::fs::create_dir_all(&non_git).ok();
let result = derive_project(&non_git);
assert!(
result.is_none(),
"expected None for non-git dir, got {result:?}"
);
}
#[test]
fn guided_derive_project_rejects_non_github_remote() {
let tmp = tempdir_with_name("trusty_test_non_github_remote_1705");
let ok = git_init_quiet(&tmp);
if !ok {
return; }
git_remote_add(&tmp, "https://gitlab.com/owner/repo.git");
let result = derive_project(&tmp);
assert!(
result.is_none(),
"expected None for non-GitHub remote (gitlab), got {result:?}"
);
}
#[test]
fn guided_derive_project_accepts_github_https_remote() {
let tmp = tempdir_with_name("trusty_test_github_https_1705");
let ok = git_init_with_commit(&tmp);
if !ok {
return;
}
git_remote_add(&tmp, "https://github.com/owner/my-repo.git");
let result = derive_project(&tmp);
match result {
Some((source_id, workspace, git_root)) => {
assert_eq!(source_id, "owner/my-repo");
assert!(
!workspace.as_os_str().is_empty(),
"workspace must be non-empty"
);
assert_ne!(workspace, tmp, "workspace must differ from live checkout");
let canonical_root = git_root.canonicalize().unwrap_or(git_root);
let canonical_tmp = tmp.canonicalize().unwrap_or(tmp.clone());
assert_eq!(
canonical_root, canonical_tmp,
"git_root must be the repo root"
);
}
None => panic!("expected Some for GitHub HTTPS remote, got None"),
}
}
#[test]
fn guided_derive_project_accepts_github_ssh_remote() {
let tmp = tempdir_with_name("trusty_test_github_ssh_1705");
let ok = git_init_with_commit(&tmp);
if !ok {
return;
}
git_remote_add(&tmp, "git@github.com:owner/my-repo.git");
let result = derive_project(&tmp);
match result {
Some((source_id, _workspace, _git_root)) => {
assert_eq!(source_id, "owner/my-repo");
}
None => panic!("expected Some for GitHub SSH remote, got None"),
}
}
#[test]
fn guided_derive_project_returns_some_from_subdir() {
let tmp = tempdir_with_name("trusty_test_subdir_1705");
let ok = git_init_with_commit(&tmp);
if !ok {
return;
}
git_remote_add(&tmp, "https://github.com/owner/my-repo.git");
let subdir = tmp.join("src").join("lib");
std::fs::create_dir_all(&subdir).unwrap();
git_track_dir(&tmp, "src/lib");
let result = derive_project(&subdir);
match result {
Some((source_id, _workspace, git_root)) => {
assert_eq!(source_id, "owner/my-repo");
let canonical_root = git_root.canonicalize().unwrap_or(git_root);
let canonical_tmp = tmp.canonicalize().unwrap_or(tmp.clone());
assert_eq!(
canonical_root, canonical_tmp,
"git_root from subdir must be repo root, not the nested dir"
);
}
None => panic!("expected Some when calling derive_project from a subdir"),
}
}
#[test]
fn guided_ls_tree_reports_tracked_dir_true_for_named_entry() {
assert!(ls_tree_reports_tracked_dir("CTO\n"));
assert!(ls_tree_reports_tracked_dir("src/lib\n"));
}
#[test]
fn guided_ls_tree_reports_tracked_dir_false_for_empty() {
assert!(!ls_tree_reports_tracked_dir(""));
}
#[test]
fn guided_ls_tree_reports_tracked_dir_false_for_blank_lines() {
assert!(!ls_tree_reports_tracked_dir("\n \n\t\n"));
}
#[test]
fn guided_untracked_ancestor_message_names_root() {
let msg = untracked_ancestor_message(std::path::Path::new("/Users/masa/Duetto"));
assert!(
msg.contains("/Users/masa/Duetto"),
"message must name the enclosing git root: {msg}"
);
assert!(
msg.contains("not part of it"),
"message must explain the untracked-ancestor reason: {msg}"
);
}
#[test]
fn guided_untracked_ancestor_message_does_not_claim_launch() {
let msg = untracked_ancestor_message(std::path::Path::new("/repo"));
assert!(
msg.contains("not launching that project"),
"message must state the project was not launched: {msg}"
);
}
#[test]
fn guided_classify_cwd_not_git_for_plain_dir() {
let tmp = tempdir_with_name("trusty_test_classify_plain_2534");
if find_git_root_via_cli(&tmp).is_some() {
return;
}
assert!(matches!(classify_cwd_project(&tmp), CwdProject::NotGit));
}
#[test]
fn guided_classify_cwd_usable_for_repo_root() {
let tmp = tempdir_with_name("trusty_test_classify_root_2534");
if !git_init_with_commit(&tmp) {
return;
}
let canonical = tmp.canonicalize().unwrap_or_else(|_| tmp.clone());
match classify_cwd_project(&canonical) {
CwdProject::Usable(root) => {
let root = root.canonicalize().unwrap_or(root);
assert_eq!(root, canonical, "repo root must classify as Usable(root)");
}
_ => panic!("repo root must be Usable"),
}
}
#[test]
fn guided_classify_cwd_usable_for_tracked_subdir() {
let tmp = tempdir_with_name("trusty_test_classify_tracked_2534");
if !git_init_with_commit(&tmp) {
return;
}
git_track_dir(&tmp, "src/lib");
let subdir = tmp.join("src").join("lib");
match classify_cwd_project(&subdir) {
CwdProject::Usable(_) => {}
_ => panic!("tracked subdir must be Usable"),
}
}
#[test]
fn guided_classify_cwd_untracked_for_uncommitted_subdir() {
let tmp = tempdir_with_name("trusty_test_classify_untracked_2534");
if !git_init_with_commit(&tmp) {
return;
}
let untracked = tmp.join("notes");
std::fs::create_dir_all(&untracked).unwrap();
match classify_cwd_project(&untracked) {
CwdProject::UntrackedInsideAncestor(_) => {}
CwdProject::Usable(_) => {
panic!("untracked subdir must NOT be trusted as part of the ancestor repo")
}
CwdProject::NotGit => panic!("subdir is inside a git tree — must not be NotGit"),
}
}
#[test]
fn guided_derive_project_returns_none_for_untracked_ancestor_subdir() {
let tmp = tempdir_with_name("trusty_test_derive_untracked_2534");
if !git_init_with_commit(&tmp) {
return;
}
git_remote_add(&tmp, "https://github.com/owner/my-repo.git");
let untracked = tmp.join("notes");
std::fs::create_dir_all(&untracked).unwrap();
assert!(
derive_project(&untracked).is_none(),
"derive_project must return None for an untracked ancestor subdir"
);
}
#[test]
fn guided_cwd_owns_git_entry_true_for_dir() {
let tmp = tempdir_with_name("trusty_test_owns_git_dir_2542");
std::fs::create_dir_all(tmp.join(".git")).unwrap();
assert!(cwd_owns_git_entry(&tmp));
}
#[test]
fn guided_cwd_owns_git_entry_true_for_pointer_file() {
let tmp = tempdir_with_name("trusty_test_owns_git_file_2542");
std::fs::write(tmp.join(".git"), b"gitdir: ./moved-away\n").unwrap();
assert!(cwd_owns_git_entry(&tmp));
}
#[test]
fn guided_cwd_owns_git_entry_false_for_dotgit_prefixed_sibling() {
let tmp = tempdir_with_name("trusty_test_owns_git_prefixed_2542");
std::fs::create_dir_all(tmp.join(".git.hotstats-backup-20260713")).unwrap();
assert!(
!cwd_owns_git_entry(&tmp),
"a `.git`-prefixed sibling must NOT be treated as owning a repo"
);
}
#[test]
fn guided_cwd_owns_git_entry_false_when_absent() {
let tmp = tempdir_with_name("trusty_test_owns_git_absent_2542");
assert!(!cwd_owns_git_entry(&tmp));
}
#[test]
fn guided_classify_cwd_own_git_wins_over_ancestor() {
let outer = tempdir_with_name("trusty_test_own_git_wins_2542");
if !git_init_with_commit(&outer) {
return; }
let inner = outer.join("inner");
std::fs::create_dir_all(inner.join(".git")).unwrap();
if let Some(walked) = find_git_root_via_cli(&inner) {
let walked = walked.canonicalize().unwrap_or(walked);
let outer_c = outer.canonicalize().unwrap_or_else(|_| outer.clone());
assert_eq!(
walked, outer_c,
"precondition: git walks up to the ancestor"
);
}
match classify_cwd_project(&inner) {
CwdProject::Usable(root) => {
let root = root.canonicalize().unwrap_or(root);
let inner_c = inner.canonicalize().unwrap_or_else(|_| inner.clone());
assert_eq!(
root, inner_c,
"must resolve to cwd's OWN repo, not the ancestor"
);
}
other => panic!("cwd owning a `.git` must be Usable(cwd), got {other:?}"),
}
}
#[test]
fn guided_classify_cwd_own_git_ignores_dotgit_prefixed_sibling() {
let outer = tempdir_with_name("trusty_test_own_git_sibling_2542");
if !git_init_with_commit(&outer) {
return;
}
std::fs::create_dir_all(outer.join(".git.backup-20260713")).unwrap();
let inner = outer.join("inner");
std::fs::create_dir_all(inner.join(".git")).unwrap();
match classify_cwd_project(&inner) {
CwdProject::Usable(root) => {
let root = root.canonicalize().unwrap_or(root);
let inner_c = inner.canonicalize().unwrap_or_else(|_| inner.clone());
assert_eq!(root, inner_c);
}
other => panic!("inner repo must be Usable(inner), got {other:?}"),
}
}
#[test]
fn guided_classify_cwd_own_git_worktree_pointer_file() {
let outer = tempdir_with_name("trusty_test_own_git_ptr_2542");
if !git_init_with_commit(&outer) {
return;
}
let inner = outer.join("wt");
std::fs::create_dir_all(&inner).unwrap();
std::fs::write(inner.join(".git"), b"gitdir: /somewhere/else\n").unwrap();
match classify_cwd_project(&inner) {
CwdProject::Usable(root) => {
let root = root.canonicalize().unwrap_or(root);
let inner_c = inner.canonicalize().unwrap_or_else(|_| inner.clone());
assert_eq!(root, inner_c);
}
other => panic!("cwd with a `.git` pointer file must be Usable(cwd), got {other:?}"),
}
}
#[test]
fn guided_classify_cwd_untracked_ancestor_still_refuses_without_own_git() {
let outer = tempdir_with_name("trusty_test_untracked_still_refuses_2542");
if !git_init_with_commit(&outer) {
return;
}
let untracked = outer.join("notes");
std::fs::create_dir_all(&untracked).unwrap();
assert!(
!cwd_owns_git_entry(&untracked),
"precondition: untracked dir owns no `.git`"
);
match classify_cwd_project(&untracked) {
CwdProject::UntrackedInsideAncestor(_) => {}
other => panic!("#2534 case must still refuse the ancestor, got {other:?}"),
}
}
fn find_git_root_via_cli(dir: &PathBuf) -> Option<PathBuf> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()
.filter(|o| o.status.success())?;
let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!root.is_empty()).then(|| PathBuf::from(root))
}
#[test]
fn guided_print_project_context_does_not_panic_no_sessions() {
print_project_context(
"owner/repo",
&PathBuf::from("/home/user/repos/owner/repo"),
&[],
);
}
#[test]
fn guided_print_project_context_does_not_panic_with_sessions() {
let sessions = vec![make_session(
"tm-frontend-1",
"running",
Some("2026-06-25T12:00:00Z"),
)];
print_project_context(
"owner/repo",
&PathBuf::from("/home/user/repos/owner/repo"),
&sessions,
);
}
#[test]
fn guided_format_project_context_row_leads_with_index_no_tm_prefix() {
let s = make_session("tm-frontend-1", "running", Some("2026-06-25T12:00:00Z"));
let row = format_project_context_row(0, &s);
assert_eq!(
row,
"[1] tm-frontend-1 state=running last=2026-06-25T12:00:00Z"
);
assert!(!row.starts_with("tm:"), "row must not carry the tm: prefix");
}
#[test]
fn guided_format_project_context_row_missing_activity_uses_em_dash() {
let s = make_session("tm-api-2", "stopped", None);
let row = format_project_context_row(4, &s);
assert_eq!(row, "[5] tm-api-2 state=stopped last=—");
}
#[test]
fn guided_print_non_tty_hint_does_not_panic_no_sessions() {
print_non_tty_hint("owner/repo", &[]);
}
#[test]
fn guided_print_non_tty_hint_does_not_panic_with_sessions() {
let sessions = vec![make_session("tm-api-2", "stopped", None)];
print_non_tty_hint("owner/repo", &sessions);
}
#[tokio::test]
async fn guided_fallback_non_git_dir_calls_launch_path() {
let tmp = tempdir_with_name("trusty_test_fallback_nongit_1705");
let client = reqwest::Client::new();
let result = fallback_protected(&client, "http://127.0.0.1:19999", &tmp).await;
if let Err(e) = result {
let msg = e.to_string();
assert!(
!msg.contains("live git checkout"),
"non-git dir should NOT trigger live-checkout guard; got: {msg}"
);
}
}
fn tempdir_with_name(name: &str) -> PathBuf {
let tmp = std::env::temp_dir().join(name);
if tmp.exists() {
std::fs::remove_dir_all(&tmp).ok();
}
std::fs::create_dir_all(&tmp).expect("create temp dir");
tmp
}
fn git_init_quiet(dir: &PathBuf) -> bool {
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn git_init_with_commit(dir: &PathBuf) -> bool {
if !git_init_quiet(dir) {
return false;
}
for (k, v) in [("user.email", "test@example.com"), ("user.name", "Test")] {
std::process::Command::new("git")
.args(["config", k, v])
.current_dir(dir)
.status()
.ok();
}
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "init", "-q"])
.current_dir(dir)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn git_remote_add(dir: &PathBuf, url: &str) {
std::process::Command::new("git")
.args(["remote", "add", "origin", url])
.current_dir(dir)
.status()
.ok();
}
fn git_track_dir(repo: &PathBuf, rel: &str) {
let dir = repo.join(rel);
std::fs::create_dir_all(&dir).expect("create tracked subdir");
std::fs::write(dir.join(".keep"), b"").expect("write .keep");
std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(repo)
.status()
.ok();
std::process::Command::new("git")
.args(["commit", "-m", "track", "-q"])
.current_dir(repo)
.status()
.ok();
}
fn make_session(
name: &str,
state: &str,
last_activity_at: Option<&str>,
) -> trusty_mpm::client::ManagedSessionSummary {
trusty_mpm::client::ManagedSessionSummary {
id: format!("{name}-id"),
name: name.to_string(),
state: state.to_string(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: None,
created_at: None,
last_activity_at: last_activity_at.map(str::to_owned),
pending_decision: None,
proposed_default: None,
source_id: None,
task: None,
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
stale_assets_unchecked: false,
attached: false,
slot: 0,
deleted: false,
}
}
fn make_session_with_persisted_state(
name: &str,
displayed_state: &str,
persisted_state: &str,
) -> trusty_mpm::client::ManagedSessionSummary {
trusty_mpm::client::ManagedSessionSummary {
persisted_state: Some(persisted_state.to_string()),
..make_session(name, displayed_state, None)
}
}
fn picker_fixture(
count: usize,
unresumable: &[bool],
) -> Vec<trusty_mpm::client::ManagedSessionSummary> {
(0..count)
.map(|i| {
let mut s = make_session(&format!("s{}", i + 1), "active", None);
s.slot = (i + 1) as u32;
s.unresumable = unresumable.get(i).copied().unwrap_or(false);
s
})
.collect()
}
fn deleted_slot(slot: u32) -> trusty_mpm::client::ManagedSessionSummary {
let mut s = make_session("gone", "deleted", None);
s.slot = slot;
s.deleted = true;
s
}
fn at_slot(
mut s: trusty_mpm::client::ManagedSessionSummary,
slot: u32,
) -> trusty_mpm::client::ManagedSessionSummary {
s.slot = slot;
s
}
#[test]
fn guided_resume_needs_restart_stopped() {
assert!(needs_restart("stopped"), "stopped state must need restart");
}
#[test]
fn guided_resume_needs_restart_errored() {
assert!(needs_restart("errored"), "errored state must need restart");
}
#[test]
fn guided_resume_no_restart_active() {
assert!(
!needs_restart("active"),
"active state must not need daemon restart"
);
}
#[test]
fn guided_resume_no_restart_provisioning() {
assert!(
!needs_restart("provisioning"),
"provisioning must not need restart"
);
}
#[test]
fn guided_resume_no_restart_decommissioned() {
assert!(
!needs_restart("decommissioned"),
"decommissioned must not need restart"
);
}
#[test]
fn guided_resume_is_zombie_active_no_tmux() {
assert!(
is_zombie("active", false),
"active + no tmux must be detected as zombie"
);
}
#[test]
fn guided_resume_is_zombie_provisioning_no_tmux() {
assert!(
is_zombie("provisioning", false),
"provisioning + no tmux must be detected as zombie"
);
}
#[test]
fn guided_resume_not_zombie_stopped_no_tmux() {
assert!(
!is_zombie("stopped", false),
"stopped + no tmux is a restart case, not a zombie"
);
}
#[test]
fn guided_resume_not_zombie_errored_no_tmux() {
assert!(
!is_zombie("errored", false),
"errored + no tmux is a restart case, not a zombie"
);
}
#[test]
fn guided_resume_not_zombie_active_with_tmux() {
assert!(
!is_zombie("active", true),
"active + live tmux is the normal attach path, not a zombie"
);
}
#[test]
fn guided_resume_plan_active_live_tmux_attaches() {
assert_eq!(
plan_resume(
"active", true, true
),
ResumeAction::Attach,
"active + live tmux + live runtime must attach directly"
);
}
#[test]
fn guided_resume_plan_active_live_tmux_dead_runtime_reconciles_then_restarts() {
assert_eq!(
plan_resume(
"active", true, false
),
ResumeAction::ReconcileThenRestart
);
}
#[test]
fn guided_resume_plan_provisioning_live_tmux_dead_runtime_reconciles_then_restarts() {
assert_eq!(
plan_resume(
"provisioning",
true,
false
),
ResumeAction::ReconcileThenRestart,
"provisioning + live tmux + dead runtime must reconcile then restart"
);
}
#[test]
fn guided_resume_plan_stopped_live_tmux_dead_runtime_still_plain_restarts() {
assert_eq!(
plan_resume(
"stopped", true, false
),
ResumeAction::Restart,
"stopped + dead runtime must still take the plain Restart path"
);
}
#[test]
fn plan_resume_refuses_terminal_states_even_with_dead_runtime() {
for state in ["deleted", "decommissioned"] {
assert_eq!(
plan_resume(
state, true, false
),
ResumeAction::Terminal,
"{state} + dead runtime must still be refused as Terminal"
);
}
}
#[test]
fn session_runtime_live_assumes_live_for_blank_session_name() {
assert!(
session_runtime_live(""),
"an empty session name must fail OPEN (assume the runtime is live)"
);
assert!(
session_runtime_live(" "),
"a whitespace-only session name is no evidence either — must fail OPEN"
);
}
#[test]
fn parse_pane_probes_reads_every_pane() {
let panes = parse_pane_probes("tm-apex-01\tzsh\t41234\ntm-apex-01\tclaude\t41999\n")
.expect("a well-formed two-pane listing must parse");
assert_eq!(panes.len(), 2, "both panes must be returned");
assert_eq!(panes[0].session_name, "tm-apex-01");
assert_eq!(panes[0].pane_current_command, "zsh");
assert_eq!(panes[0].pane_pid, Some(41234));
assert_eq!(panes[1].pane_current_command, "claude");
assert_eq!(panes[1].pane_pid, Some(41999));
}
#[test]
fn parse_pane_probes_rejects_non_numeric_pid() {
assert_eq!(
parse_pane_probes("tm-apex-01\tzsh\tnotanumber\n"),
None,
"a non-numeric pid must read as CANNOT DETERMINE (None), never as a \
one-gate dead verdict"
);
}
#[test]
fn parse_pane_probes_rejects_missing_pid() {
assert_eq!(
parse_pane_probes("tm-apex-01\tzsh\n"),
None,
"a row missing the pid column must yield None"
);
}
#[test]
fn parse_pane_probes_rejects_smeared_row() {
assert_eq!(
parse_pane_probes("tm-apex-01_zsh_41234\n"),
None,
"a smeared single-field row must yield None, never a guessed split"
);
}
#[test]
fn parse_pane_probes_rejects_extra_fields() {
assert_eq!(
parse_pane_probes("tm-apex-01\tzsh\t41234\textra\n"),
None,
"an over-long row must yield None"
);
}
#[test]
fn parse_pane_probes_rejects_empty_or_blank_listing() {
assert_eq!(parse_pane_probes(""), None, "empty output must yield None");
assert_eq!(
parse_pane_probes(" \n\n"),
None,
"whitespace-only output must yield None"
);
}
#[test]
fn parse_pane_probes_rejects_empty_command_field() {
assert_eq!(
parse_pane_probes("tm-apex-01\t\t41234\n"),
None,
"an empty command field must yield None"
);
}
#[test]
fn panes_prove_session_dead_when_every_pane_is_a_bare_shell() {
let panes = parse_pane_probes("tm-apex-01\tzsh\t41234\ntm-apex-01\tbash\t41999\n")
.expect("listing must parse");
assert!(
panes_prove_session_dead("tm-apex-01", &panes, &AlwaysIdleProbe),
"a session whose every pane is a bare shell must be proven dead"
);
}
#[test]
fn panes_prove_session_dead_refuses_when_a_sibling_pane_is_live() {
let panes = parse_pane_probes("tm-apex-01\tzsh\t41234\ntm-apex-01\tclaude\t41999\n")
.expect("listing must parse");
assert!(
!panes_prove_session_dead("tm-apex-01", &panes, &AlwaysIdleProbe),
"a sibling pane still running the agent must block the dead verdict"
);
}
#[test]
fn panes_prove_session_dead_refuses_a_prefix_matched_listing() {
let panes = parse_pane_probes("tm-apex-01\tzsh\t41234\n").expect("listing must parse");
assert!(
!panes_prove_session_dead("tm-apex", &panes, &AlwaysIdleProbe),
"a prefix-matched listing describes ANOTHER session — it must never \
convict ours, whose own panes we have not actually seen"
);
}
#[test]
fn panes_prove_session_dead_refuses_a_wholly_foreign_listing() {
let panes = parse_pane_probes("tm-other-99\tzsh\t41234\n").expect("listing must parse");
assert!(
!panes_prove_session_dead("tm-apex-01", &panes, &AlwaysIdleProbe),
"a listing belonging entirely to another session must not prove ours dead"
);
}
#[test]
fn panes_prove_session_dead_ignores_foreign_rows_beside_our_own() {
let panes = parse_pane_probes("tm-other-99\tclaude\t41000\ntm-apex-01\tzsh\t41234\n")
.expect("listing must parse");
assert!(
panes_prove_session_dead("tm-apex-01", &panes, &AlwaysIdleProbe),
"our own idle pane decides; a live pane in an unrelated session is not ours"
);
}
#[test]
fn guided_resume_plan_stopped_restarts() {
assert_eq!(
plan_resume(
"stopped", false, true
),
ResumeAction::Restart,
"stopped must select the plain Restart path"
);
}
#[test]
fn guided_resume_plan_errored_restarts() {
assert_eq!(
plan_resume(
"errored", false, true
),
ResumeAction::Restart,
"errored must select the plain Restart path"
);
}
#[test]
fn guided_resume_plan_active_no_tmux_reconciles_then_restarts() {
assert_eq!(
plan_resume(
"active", false, true
),
ResumeAction::ReconcileThenRestart,
"active + no tmux must reconcile (auto-stop) then restart"
);
}
#[test]
fn guided_resume_plan_provisioning_no_tmux_reconciles_then_restarts() {
assert_eq!(
plan_resume(
"provisioning",
false,
true
),
ResumeAction::ReconcileThenRestart,
"provisioning + no tmux must reconcile then restart"
);
}
#[test]
fn plan_resume_refuses_terminal_states() {
for state in ["deleted", "decommissioned"] {
for tmux_live in [false, true] {
assert_eq!(
plan_resume(state, tmux_live, true),
ResumeAction::Terminal,
"{state} (tmux_live={tmux_live}) must be refused as Terminal, never resumed"
);
}
}
}
#[test]
fn is_zombie_false_for_terminal_states() {
assert!(!is_zombie("deleted", false), "deleted is never a zombie");
assert!(
!is_zombie("decommissioned", false),
"decommissioned is never a zombie"
);
}
#[test]
fn guided_resume_plan_stopped_with_stale_tmux_still_restarts() {
assert_eq!(
plan_resume(
"stopped", true, true
),
ResumeAction::Restart,
"stopped + stale live tmux must still take the Restart path, not reconcile"
);
}
#[test]
fn resume_classification_state_prefers_persisted_state() {
let session = make_session_with_persisted_state("tm-writing-01", "stopped", "active");
assert_eq!(
resume_classification_state(&session),
"active",
"classification must use persisted_state, not the display-reconciled state"
);
}
#[test]
fn resume_classification_state_falls_back_to_display_state_when_absent() {
let session = make_session("tm-legacy-01", "stopped", None);
assert_eq!(
resume_classification_state(&session),
"stopped",
"must fall back to display state when persisted_state is absent"
);
}
#[test]
fn guided_resume_plan_resume_prefers_persisted_state_over_display_state() {
let session = make_session_with_persisted_state("tm-writing-01", "stopped", "active");
let tmux_live = false; let action = plan_resume(
resume_classification_state(&session),
tmux_live,
true,
);
assert_eq!(
action,
ResumeAction::ReconcileThenRestart,
"a zombie session displayed as 'stopped' but persisted 'active' must \
reconcile then restart, never a plain Restart that 409s"
);
assert_eq!(
plan_resume(&session.state, tmux_live, true),
ResumeAction::Restart,
"classifying off the display-reconciled state alone reproduces the bug"
);
}
#[test]
fn is_github_remote_accepts_github_com_ssh() {
assert!(
is_github_remote("git@github.com:o/r.git"),
"github.com SSH must be recognised as GitHub"
);
}
#[test]
fn is_github_remote_accepts_github_com_https() {
assert!(
is_github_remote("https://github.com/o/r.git"),
"github.com HTTPS must be recognised as GitHub"
);
}
#[test]
fn is_github_remote_accepts_github_hyphen_alias() {
assert!(
is_github_remote("git@github-duetto:duettoresearch/aria.git"),
"github-<alias> SSH remote must be recognised as GitHub"
);
}
#[test]
fn is_github_remote_accepts_github_alias_ssh_url_style() {
assert!(
is_github_remote("ssh://git@github-work/o/r"),
"ssh:// github-<alias> must be recognised as GitHub"
);
}
#[test]
fn is_github_remote_accepts_github_underscore_alias() {
assert!(
is_github_remote("git@github_personal:user/repo.git"),
"github_<alias> SSH remote must be recognised as GitHub"
);
}
#[test]
fn is_github_remote_rejects_gitlab() {
assert!(
!is_github_remote("git@gitlab.com:o/r.git"),
"gitlab.com must NOT be recognised as GitHub"
);
}
#[test]
fn is_github_remote_rejects_bitbucket() {
assert!(
!is_github_remote("https://bitbucket.org/o/r"),
"bitbucket.org must NOT be recognised as GitHub"
);
}
#[test]
fn is_github_remote_rejects_githubusercontent() {
assert!(
!is_github_remote("https://raw.githubusercontent.com/o/r/file"),
"githubusercontent.com must NOT be recognised as GitHub"
);
}
#[test]
fn is_github_remote_accepts_github_com_https_with_port() {
assert!(
is_github_remote("https://github.com:443/o/r.git"),
"https://github.com:443/… must be recognised as GitHub (port stripped)"
);
}
#[test]
fn is_github_remote_rejects_gitea_with_github_in_path() {
assert!(
!is_github_remote("https://gitea.example.com/mirrors/github-fork.git"),
"gitea host with github in path must NOT match"
);
}
#[test]
fn github_host_extracts_scp_style() {
assert_eq!(
github_host("git@github-duetto:duettoresearch/aria.git"),
"github-duetto"
);
assert_eq!(github_host("git@github.com:owner/repo.git"), "github.com");
}
#[test]
fn github_host_extracts_https() {
assert_eq!(
github_host("https://github.com/owner/repo.git"),
"github.com"
);
assert_eq!(github_host("https://gitlab.com/o/r"), "gitlab.com");
}
#[test]
fn github_host_extracts_ssh_url_with_user() {
assert_eq!(github_host("ssh://git@github-work/o/r"), "github-work");
}
#[test]
fn guided_derive_project_accepts_github_ssh_alias() {
let tmp = tempdir_with_name("trusty_test_github_alias_derive_1705");
let ok = git_init_with_commit(&tmp);
if !ok {
return;
}
git_remote_add(&tmp, "git@github-duetto:duettoresearch/aria.git");
let result = derive_project(&tmp);
match result {
Some((source_id, _workspace, _git_root)) => {
assert_eq!(
source_id, "duettoresearch/aria",
"source_id must be parsed correctly from alias remote"
);
}
None => panic!("expected Some for GitHub SSH alias remote, got None"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn guided_fallback_does_not_refuse_github_ssh_alias_remote() {
let dir = tempdir_with_name("trusty_test_github_alias_fallback_1705");
let ok = std::process::Command::new("git")
.arg("init")
.current_dir(&dir)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
eprintln!(
"guided_fallback_does_not_refuse_github_ssh_alias_remote: git unavailable, skipping"
);
return;
}
let _ = std::process::Command::new("git")
.args([
"remote",
"add",
"origin",
"git@github-test-fixture-alias-nonexistent:acmetest/repo-fixture.git",
])
.current_dir(&dir)
.status();
let repos_root = tempfile::tempdir().unwrap();
let repos_root_key = trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV;
let prev = std::env::var(repos_root_key).ok();
unsafe { std::env::set_var(repos_root_key, repos_root.path()) };
let client = reqwest::Client::new();
let result = fallback_protected(&client, "http://127.0.0.1:1", &dir).await;
unsafe {
match prev {
Some(v) => std::env::set_var(repos_root_key, v),
None => std::env::remove_var(repos_root_key),
}
}
assert!(
result.is_err(),
"fallback must Err (daemon unreachable / clone fails)"
);
let err_msg = format!("{}", result.unwrap_err());
assert!(
!err_msg.contains("auto-managed clones require a GitHub remote"),
"SSH alias remote must NOT trigger GitHub-remote refusal; got: {err_msg}"
);
assert!(
!dir.join("CLAUDE.md").exists(),
"CLAUDE.md must NOT appear in the live checkout"
);
assert!(
!dir.join(".mcp.json").exists(),
".mcp.json must NOT appear in the live checkout"
);
}
#[test]
fn needs_first_run_clone_returns_none_for_non_dir() {
assert!(needs_first_run_clone("https://github.com/owner/repo.git").is_none());
assert!(needs_first_run_clone("/nonexistent/path/that/does/not/exist").is_none());
assert!(needs_first_run_clone("").is_none());
}
#[test]
#[serial_test::serial]
fn needs_first_run_clone_returns_none_when_clone_exists() {
use std::process::Command;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !git(&["init"]) {
return; }
git(&[
"remote",
"add",
"origin",
"git@github.com:owner/already-cloned.git",
]);
let repos_env_key = trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV;
let tmp_repos = tempfile::TempDir::new().unwrap();
let base = tmp_repos.path().join("owner").join("already-cloned");
std::fs::create_dir_all(base.join(".git")).unwrap();
let prev = std::env::var(repos_env_key).ok();
let result = {
let _env_guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var(repos_env_key, tmp_repos.path()) };
let r = needs_first_run_clone(&dir.to_string_lossy());
unsafe {
match prev {
Some(v) => std::env::set_var(repos_env_key, v),
None => std::env::remove_var(repos_env_key),
}
}
r
};
assert!(
result.is_none(),
"base clone exists → must return None (no first-run message)"
);
}
#[test]
#[serial_test::serial]
fn needs_first_run_clone_returns_some_when_no_clone() {
use std::process::Command;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !git(&["init"]) {
return;
}
git(&[
"remote",
"add",
"origin",
"git@github.com:myorg/my-new-project.git",
]);
let repos_env_key = trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV;
let tmp_repos = tempfile::TempDir::new().unwrap();
let prev = std::env::var(repos_env_key).ok();
let result = {
let _env_guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var(repos_env_key, tmp_repos.path()) };
let r = needs_first_run_clone(&dir.to_string_lossy());
unsafe {
match prev {
Some(v) => std::env::set_var(repos_env_key, v),
None => std::env::remove_var(repos_env_key),
}
}
r
};
let (proj, _path) = result.expect("must return Some for a first-run scenario");
assert_eq!(proj, "myorg/my-new-project");
}
#[test]
fn spawn_progress_message_first_run() {
let path = PathBuf::from("/home/user/trusty-mpm-projects/owner/repo");
let msg = spawn_progress_message(Some(&("owner/repo".to_string(), path.clone())));
assert!(msg.contains("owner/repo"));
assert!(msg.contains(&path.display().to_string()));
assert!(msg.contains("first run"));
}
#[test]
fn spawn_progress_message_reuse() {
let msg = spawn_progress_message(None);
assert_eq!(msg, "tm: launching new session…");
}
#[test]
fn guided_non_github_refusal_message_does_not_mention_daemon_or_start() {
let msg = non_github_refusal_message("git@gitlab.com:org/repo.git");
assert!(
!msg.contains("daemon unreachable"),
"refusal message must NOT blame the daemon: {msg}"
);
assert!(
!msg.contains("tm start"),
"refusal message must NOT instruct to start the daemon: {msg}"
);
assert!(
!msg.contains("Start the daemon"),
"refusal message must NOT mention starting the daemon: {msg}"
);
}
#[test]
fn guided_non_github_refusal_message_explains_github_only_policy() {
let msg = non_github_refusal_message("https://gitea.example.com/org/repo.git");
assert!(
msg.contains("GitHub"),
"refusal must name GitHub as the auto-management requirement: {msg}"
);
assert!(
msg.to_lowercase().contains("auto-manag"),
"refusal must mention auto-management scope: {msg}"
);
}
#[test]
fn guided_non_github_refusal_message_includes_detected_remote() {
let remote = "git@gitlab.com:myorg/myrepo.git";
let msg = non_github_refusal_message(remote);
assert!(
msg.contains(remote),
"refusal must echo the detected remote ({remote}): {msg}"
);
}
#[test]
fn guided_non_github_refusal_message_reassures_live_checkout_untouched() {
let msg = non_github_refusal_message("https://bitbucket.org/org/repo.git");
assert!(
msg.contains("live checkout"),
"refusal must reassure that the live checkout was not touched: {msg}"
);
assert!(
msg.contains("not touched"),
"refusal must use the phrase 'not touched': {msg}"
);
}
#[test]
fn plan_non_git_fallback_retries_when_env_id_present() {
assert_eq!(
plan_non_git_fallback(Some("11111111-2222-3333-4444-555555555555")),
NonGitFallbackPlan::RetryInPlace("11111111-2222-3333-4444-555555555555".to_string())
);
}
#[test]
fn plan_non_git_fallback_ordinary_when_no_env_id() {
assert_eq!(
plan_non_git_fallback(None),
NonGitFallbackPlan::OrdinaryNotGit
);
}
#[test]
fn managed_pane_settle_pending_message_names_id() {
let id = "11111111-2222-3333-4444-555555555555";
let msg = managed_pane_settle_pending_message(id);
assert!(msg.contains(id), "message must name the session id: {msg}");
assert!(
!msg.to_ascii_lowercase().contains("not in a git project"),
"must not reuse the misleading non-git wording: {msg}"
);
assert!(
msg.contains("run `tm` again"),
"message must give the operator an actionable retry hint: {msg}"
);
}
#[test]
fn daily_banner_two_panel_version_in_title_bar_not_content() {
use crate::formatters::banner::two_panel::{render_two_panel_banner, strip_ansi};
use crate::formatters::info_box::{DaemonInfo, WelcomeData};
colored::control::set_override(false);
let data = WelcomeData {
project: "owner/repo".to_string(),
workspace: "/home/alice/trusty-mpm-projects/owner/repo".to_string(),
user: "alice".to_string(),
reconnecting: false,
session_name: String::new(),
daemon: DaemonInfo::default(),
recent_commits: vec![],
memory_status: "(not detected)".to_string(),
search_status: "(not detected)".to_string(),
review_status: "(not detected)".to_string(),
};
let version = env!("CARGO_PKG_VERSION");
let banner =
render_two_panel_banner(&data, 120, false).expect("120-col terminal must produce banner");
let bare = strip_ansi(&banner);
let count = bare.matches(version).count();
assert_eq!(
count, 1,
"version must appear exactly once (title bar only); found {count}"
);
let first = bare.lines().next().unwrap_or("");
assert!(
first.starts_with('╭'),
"title bar must start with ╭: {first:?}"
);
assert!(
first.contains(version),
"title bar must contain the version: {first:?}"
);
for (i, line) in bare.lines().enumerate().skip(1) {
let inner = line.trim_start_matches('│').trim_end_matches('│').trim();
assert!(
!inner.starts_with(&format!("trusty-mpm v{version}")),
"content row {i} must not carry the version line: {line:?}"
);
}
colored::control::unset_override();
}
#[test]
fn nested_fallback_action_relaunches_dead_states() {
for state in ["decommissioned", "stopped", "errored"] {
assert_eq!(
nested_fallback_action(state),
NestedFallbackAction::RelaunchInPlace,
"dead state {state:?} must relaunch in place, not switch-client to self"
);
}
}
#[test]
fn nested_fallback_action_reconnects_live_states() {
for state in ["active", "provisioning", "running", "some-future-state"] {
assert_eq!(
nested_fallback_action(state),
NestedFallbackAction::Reconnect,
"possibly-live state {state:?} must reconnect (default), not relaunch"
);
}
}
#[test]
fn nested_guard_notice_never_says_refusing() {
let msg = nested_guard_notice("my-session");
assert!(
!msg.to_lowercase().contains("refus"),
"nested_guard_notice must not read as a refusal: {msg:?}"
);
}
#[test]
fn nested_guard_notice_mentions_reconnect_target() {
let msg = nested_guard_notice("my-session");
assert!(
msg.contains("my-session"),
"nested_guard_notice must name the session being reconnected to: {msg:?}"
);
assert!(
msg.contains("reconnecting"),
"nested_guard_notice must still communicate the reconnect action: {msg:?}"
);
}
#[test]
fn inplace_self_relaunch_hint_suggests_managed_resume() {
let mut record = make_session("tm-cto-01", "active", None);
record.claude_session_id = Some("bfc69db6-cb98-4aa7-a07d-89fd69ba710b".to_string());
let hint = inplace_self_relaunch_hint(&record);
assert!(
hint.contains(&format!("tm sessions resume {}", record.id)),
"hint must suggest the managed `tm sessions resume <id>`: {hint:?}"
);
assert!(
hint.contains("already inside this session's pane"),
"hint must explain why a reconnect would be a no-op: {hint:?}"
);
}
#[test]
fn inplace_self_relaunch_hint_never_suggests_bare_claude() {
for sid in [None, Some("abc123".to_string()), Some(" ".to_string())] {
let mut record = make_session("tm-cto-01", "active", None);
record.claude_session_id = sid.clone();
let hint = inplace_self_relaunch_hint(&record);
assert!(
!hint.contains("claude"),
"hint must never mention a bare `claude` (loses managed config); \
sid={sid:?}: {hint:?}"
);
assert!(
hint.contains("tm sessions resume"),
"hint must always point at the managed resume; sid={sid:?}: {hint:?}"
);
}
}
async fn serve_once(response: &'static str) -> String {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 2048];
let _ = sock.read(&mut buf).await;
let _ = sock.write_all(response.as_bytes()).await;
let _ = sock.flush().await;
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn guard_managed_sessions_returns_none_when_the_list_call_errors() {
let url = serve_once("HTTP/1.1 500 Internal Server Error\r\ncontent-length: 0\r\n\r\n").await;
let client = reqwest::Client::new();
let result = guard_managed_sessions(&client, &url).await;
assert!(
result.is_none(),
"a 5xx from the daemon must fail open to None, not error out"
);
}
#[tokio::test]
async fn guard_managed_sessions_returns_none_when_the_daemon_is_unreachable() {
let client = reqwest::Client::new();
let result = guard_managed_sessions(&client, "http://127.0.0.1:1").await;
assert!(
result.is_none(),
"an unreachable daemon must fail open to None"
);
}
#[tokio::test]
async fn guard_managed_sessions_returns_sessions_on_success() {
let body = concat!(
r#"{"sessions":[{"id":"11111111-2222-3333-4444-555555555555","#,
r#""name":"tm-demo-01","state":"active","#,
r#""created_at":"2026-07-29T00:00:00Z"}]}"#
);
let response: &'static str = Box::leak(
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
)
.into_boxed_str(),
);
let url = serve_once(response).await;
let client = reqwest::Client::new();
let sessions = guard_managed_sessions(&client, &url)
.await
.expect("a well-formed listing must reach the guard");
assert_eq!(sessions.len(), 1, "the listing must survive intact");
assert_eq!(sessions[0].name, "tm-demo-01");
}