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, classify_cwd_project, cwd_owns_git_entry, derive_project,
fallback_protected, github_host, inplace_self_relaunch_hint, is_github_remote,
ls_tree_reports_tracked_dir, nested_fallback_action, nested_guard_notice, nested_managed_match,
non_github_refusal_message, pane_identity_confirmed, 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, plan_resume};
use crate::commands::managed::{filter_live_sessions, is_live_session_state};
use crate::commands::session_picker::{PickerDecision, parse_picker_choice};
#[test]
fn guided_picker_bare_enter_no_sessions_launches_new() {
assert_eq!(
parse_picker_choice("", 0, false, &[]),
PickerDecision::LaunchNew
);
assert_eq!(
parse_picker_choice(" \t", 0, false, &[]),
PickerDecision::LaunchNew
);
}
#[test]
fn guided_picker_bare_enter_live_session_resumes_first() {
assert_eq!(
parse_picker_choice("", 1, false, &[]),
PickerDecision::Resume(0)
);
assert_eq!(
parse_picker_choice(" ", 3, false, &[]),
PickerDecision::Resume(0)
);
}
#[test]
fn guided_picker_bare_enter_stopped_session_requires_confirm() {
assert_eq!(
parse_picker_choice("", 1, true, &[]),
PickerDecision::ConfirmRestart(0)
);
assert_eq!(
parse_picker_choice(" ", 3, true, &[]),
PickerDecision::ConfirmRestart(0)
);
}
#[test]
fn guided_picker_bare_enter_unresumable_session_blocked() {
assert_eq!(
parse_picker_choice("", 1, true, &[true]),
PickerDecision::Unresumable(0)
);
assert_eq!(
parse_picker_choice(" ", 3, true, &[true, false, false]),
PickerDecision::Unresumable(0)
);
}
#[test]
fn guided_picker_numeric_unresumable_session_blocked() {
assert_eq!(
parse_picker_choice("2", 3, false, &[false, true, false]),
PickerDecision::Unresumable(1)
);
assert_eq!(
parse_picker_choice("1", 3, false, &[false, true, false]),
PickerDecision::Resume(0)
);
}
#[test]
fn guided_picker_delete_parses_d_prefix() {
assert_eq!(
parse_picker_choice("d1", 1, false, &[]),
PickerDecision::Delete(0)
);
assert_eq!(
parse_picker_choice("d2", 3, true, &[]),
PickerDecision::Delete(1)
);
assert_eq!(
parse_picker_choice("D3\n", 3, false, &[]),
PickerDecision::Delete(2)
);
assert_eq!(
parse_picker_choice("d 2", 3, false, &[]),
PickerDecision::Delete(1)
);
}
#[test]
fn guided_picker_delete_out_of_range_unrecognised() {
assert_eq!(
parse_picker_choice("d4", 3, false, &[]),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("d0", 3, false, &[]),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("dx", 3, false, &[]),
PickerDecision::Unrecognised
);
}
#[test]
fn guided_picker_q_returns_quit() {
assert_eq!(
parse_picker_choice("q", 0, false, &[]),
PickerDecision::Quit
);
assert_eq!(parse_picker_choice("q", 3, true, &[]), PickerDecision::Quit);
}
#[test]
fn guided_picker_q_uppercase_returns_quit() {
assert_eq!(
parse_picker_choice("Q", 2, false, &[]),
PickerDecision::Quit
);
assert_eq!(
parse_picker_choice("Q\n", 0, false, &[]),
PickerDecision::Quit
);
}
#[test]
fn guided_picker_numeric_valid_resumes() {
assert_eq!(
parse_picker_choice("1", 1, true, &[]),
PickerDecision::Resume(0)
);
assert_eq!(
parse_picker_choice("1", 3, true, &[]),
PickerDecision::Resume(0)
);
assert_eq!(
parse_picker_choice("2", 3, false, &[]),
PickerDecision::Resume(1)
);
assert_eq!(
parse_picker_choice("3", 3, false, &[]),
PickerDecision::Resume(2)
);
assert_eq!(
parse_picker_choice("2\n", 3, false, &[]),
PickerDecision::Resume(1)
);
}
#[test]
fn guided_picker_numeric_launch_new() {
assert_eq!(
parse_picker_choice("1", 0, false, &[]),
PickerDecision::LaunchNew
);
assert_eq!(
parse_picker_choice("4", 3, false, &[]),
PickerDecision::LaunchNew
);
}
#[test]
fn guided_picker_out_of_range_unrecognised() {
assert_eq!(
parse_picker_choice("5", 3, false, &[]),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("100", 1, false, &[]),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("0", 3, false, &[]),
PickerDecision::Unrecognised
);
}
#[test]
fn guided_picker_non_numeric_unrecognised() {
assert_eq!(
parse_picker_choice("abc", 2, false, &[]),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("exit", 0, false, &[]),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("1a", 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_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(),
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,
attached: false,
}
}
#[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),
ResumeAction::Attach,
"active + live tmux must attach directly"
);
}
#[test]
fn guided_resume_plan_stopped_restarts() {
assert_eq!(
plan_resume("stopped", false),
ResumeAction::Restart,
"stopped must select the plain Restart path"
);
}
#[test]
fn guided_resume_plan_errored_restarts() {
assert_eq!(
plan_resume("errored", false),
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),
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),
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),
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),
ResumeAction::Restart,
"stopped + stale live tmux must still take the Restart path, not reconcile"
);
}
#[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 picker_filter_live_state_excludes_decommissioned() {
assert!(
!is_live_session_state("decommissioned"),
"decommissioned must be excluded from default view"
);
assert!(
is_live_session_state("active"),
"active must be included in default view"
);
assert!(
is_live_session_state("stopped"),
"stopped must be included in default view"
);
assert!(
is_live_session_state("errored"),
"errored must be included in default view"
);
assert!(
is_live_session_state("provisioning"),
"provisioning must be included in default view"
);
}
#[test]
fn is_live_session_state_excludes_deleted() {
assert!(
!is_live_session_state("deleted"),
"deleted must be excluded from the default picker/list view"
);
assert!(!is_live_session_state("decommissioned"));
assert!(is_live_session_state("active"));
assert!(is_live_session_state("stopped"));
}
#[test]
fn picker_filter_excludes_decommissioned_keeps_active() {
let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
serde_json::from_value(serde_json::json!([
{ "id": "a1", "name": "sess-active", "state": "active" },
{ "id": "b2", "name": "sess-dead-1", "state": "decommissioned" },
{ "id": "c3", "name": "sess-stopped", "state": "stopped" },
{ "id": "d4", "name": "sess-dead-2", "state": "decommissioned" },
{ "id": "e5", "name": "sess-provisioning", "state": "provisioning" },
]))
.expect("test data must deserialize");
let filtered = filter_live_sessions(sessions);
assert_eq!(
filtered.len(),
3,
"filter must keep exactly 3 live sessions (active, stopped, provisioning)"
);
assert!(
filtered.iter().any(|s| s.state == "active"),
"active session must survive filter"
);
assert!(
filtered.iter().any(|s| s.state == "stopped"),
"stopped session must survive filter"
);
assert!(
filtered.iter().any(|s| s.state == "provisioning"),
"provisioning session must survive filter"
);
assert!(
!filtered.iter().any(|s| s.state == "decommissioned"),
"decommissioned tombstones must be excluded"
);
}
#[test]
fn picker_filter_all_live_sessions_unchanged() {
let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
serde_json::from_value(serde_json::json!([
{ "id": "x1", "name": "sess-a", "state": "active" },
{ "id": "x2", "name": "sess-b", "state": "stopped" },
{ "id": "x3", "name": "sess-c", "state": "errored" },
]))
.expect("test data must deserialize");
let filtered = filter_live_sessions(sessions);
assert_eq!(
filtered.len(),
3,
"all-live input must pass through unchanged (3 sessions)"
);
}
#[test]
fn picker_filter_all_decommissioned_returns_empty() {
let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
serde_json::from_value(serde_json::json!([
{ "id": "z1", "name": "old-1", "state": "decommissioned" },
{ "id": "z2", "name": "old-2", "state": "decommissioned" },
]))
.expect("test data must deserialize");
let filtered = filter_live_sessions(sessions);
assert!(
filtered.is_empty(),
"all-decommissioned input must produce empty list"
);
}
#[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_managed_match_by_session_name() {
let sessions = vec![make_session("tm-proj-01", "active", None)];
let matched = nested_managed_match(Some("tm-proj-01"), None, &sessions);
assert_eq!(matched.map(|s| s.name.as_str()), Some("tm-proj-01"));
}
#[test]
fn nested_managed_match_by_env_id() {
let sessions = vec![make_session("tm-proj-01", "active", None)];
let matched = nested_managed_match(None, Some("tm-proj-01-id"), &sessions);
assert_eq!(matched.map(|s| s.name.as_str()), Some("tm-proj-01"));
}
#[test]
fn nested_managed_match_none_when_no_match() {
let sessions = vec![make_session("tm-proj-01", "active", None)];
let matched = nested_managed_match(Some("some-other-session"), Some("unrelated-id"), &sessions);
assert!(matched.is_none());
}
#[test]
fn nested_managed_match_none_when_both_inputs_absent() {
let sessions = vec![make_session("tm-proj-01", "active", None)];
let matched = nested_managed_match(None, None, &sessions);
assert!(matched.is_none());
}
#[test]
fn nested_managed_match_finds_record_missing_from_source_id_filtered_list() {
let mut orphaned = make_session("tm-orphan-02", "active", None);
orphaned.source_id = None;
let sessions = vec![orphaned];
let matched = nested_managed_match(Some("tm-orphan-02"), None, &sessions);
assert!(
matched.is_some(),
"must match by session name regardless of source_id"
);
}
fn make_session_at(
name: &str,
state: &str,
created_at: &str,
) -> trusty_mpm::client::ManagedSessionSummary {
let mut s = make_session(name, state, None);
s.created_at = Some(created_at.to_string());
s
}
#[test]
fn nested_managed_match_prefers_live_over_recycled_decommissioned_name() {
let mut decommissioned =
make_session_at("tm-proj-01", "decommissioned", "2026-03-01T00:00:00Z");
decommissioned.id = "old-id".to_string();
let mut live = make_session_at("tm-proj-01", "active", "2026-01-01T00:00:00Z");
live.id = "new-id".to_string();
let sessions = vec![decommissioned, live];
let matched = nested_managed_match(Some("tm-proj-01"), None, &sessions);
assert_eq!(
matched.map(|s| s.id.as_str()),
Some("new-id"),
"a live record must win over a decommissioned one sharing the same \
recycled name, even when the tombstone's created_at is later"
);
}
#[test]
fn nested_managed_match_falls_back_to_decommissioned_when_no_live_candidate() {
let sessions = vec![make_session("tm-apex-01", "decommissioned", None)];
let matched = nested_managed_match(Some("tm-apex-01"), None, &sessions);
assert_eq!(
matched.map(|s| s.name.as_str()),
Some("tm-apex-01"),
"must fall back to the decommissioned record when no live candidate \
shares its name"
);
}
#[test]
fn nested_managed_match_prefers_most_recent_live_among_multiple() {
let mut older = make_session_at("tm-proj-02", "active", "2026-01-01T00:00:00Z");
older.id = "older-id".to_string();
let mut newer = make_session_at("tm-proj-02", "active", "2026-02-01T00:00:00Z");
newer.id = "newer-id".to_string();
let sessions = vec![older, newer];
let matched = nested_managed_match(Some("tm-proj-02"), None, &sessions);
assert_eq!(matched.map(|s| s.id.as_str()), Some("newer-id"));
}
#[test]
fn pane_identity_confirmed_true_when_pane_id_matches_record() {
assert!(pane_identity_confirmed(Some("%5"), Some("%5")));
}
#[test]
fn pane_identity_confirmed_false_when_current_pane_id_absent() {
assert!(!pane_identity_confirmed(None, Some("%5")));
}
#[test]
fn pane_identity_confirmed_false_when_record_pane_id_absent() {
assert!(!pane_identity_confirmed(Some("%5"), None));
}
#[test]
fn pane_identity_confirmed_false_when_pane_ids_differ() {
assert!(!pane_identity_confirmed(Some("%7"), Some("%5")));
}
#[test]
fn pane_identity_confirmed_false_when_inherited_env_but_different_pane_id() {
let healed_record_pane_id = Some("%5");
let sibling_window_current_pane_id = Some("%9");
assert!(!pane_identity_confirmed(
sibling_window_current_pane_id,
healed_record_pane_id
));
}
#[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:?}"
);
}
}