use clap::Parser;
use crate::cli::{Cli, Command, SessionAction};
use crate::commands::managed::{filter_live_sessions, is_live_session_state};
#[test]
fn cli_parses_session_decommission_force() {
let cli =
Cli::try_parse_from(["trusty-mpm", "session", "decommission", "abc", "--force"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Decommission { id, force },
} => assert!(id == "abc" && force),
other => panic!("expected session decommission --force, got {other:?}"),
}
}
#[test]
fn cli_parses_sessions_plural_canonical() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "list"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::List { dir },
} => assert_eq!(dir, None),
other => panic!("expected sessions list, got {other:?}"),
}
}
#[test]
fn cli_session_and_sessions_agree_for_every_verb() {
let cases: &[&[&str]] = &[
&["start"],
&["stop", "id-1"],
&["kill", "id-1"],
&["list"],
&["tui"],
&["clean"],
&["info", "id-1"],
&["instructions"],
&["events", "id-1"],
&["breakers"],
&["pause", "id-1"],
&["resume", "id-1"],
&["run", "id-1", "echo hi"],
&["output", "id-1"],
&["new", "https://example.com/o/r.git", "--task", "t"],
&["ls"],
&["activity", "id-1"],
&["send", "id-1", "text"],
&["answer", "id-1", "yes"],
&["attach", "id-1"],
&["managed-stop", "id-1"],
&["runtime-stop", "id-1"],
&["managed-resume", "id-1"],
&["decommission", "id-1"],
&["delete", "id-1"],
&["prune-idle"],
&["decommission-ephemeral"],
&["catchup"],
&["prune", "--state", "all"],
&["prune-worktrees"],
];
for verb_args in cases {
let mut singular_args = vec!["trusty-mpm", "session"];
singular_args.extend_from_slice(verb_args);
let mut plural_args = vec!["trusty-mpm", "sessions"];
plural_args.extend_from_slice(verb_args);
let singular_action = match Cli::try_parse_from(singular_args)
.unwrap_or_else(|e| panic!("`tm session {verb_args:?}` failed to parse: {e}"))
.command
.unwrap()
{
Command::Session { action } => action,
other => panic!("expected Command::Session for {verb_args:?}, got {other:?}"),
};
let plural_action = match Cli::try_parse_from(plural_args)
.unwrap_or_else(|e| panic!("`tm sessions {verb_args:?}` failed to parse: {e}"))
.command
.unwrap()
{
Command::Sessions { action } => action,
other => panic!("expected Command::Sessions for {verb_args:?}, got {other:?}"),
};
assert_eq!(
format!("{singular_action:?}"),
format!("{plural_action:?}"),
"session/sessions parsed to different actions for {verb_args:?}"
);
}
}
#[test]
fn session_alias_list_routes_to_the_list_verb_never_a_start() {
let command = Cli::try_parse_from(["trusty-mpm", "session", "list"])
.expect("`tm session list` must parse")
.command
.expect("a subcommand must be present");
match command {
Command::Session {
action: SessionAction::List { dir },
} => assert_eq!(dir, None, "no `--dir` means the cwd, not a positional name"),
Command::Session { action } => panic!(
"`tm session list` must route to the List verb, got {action:?} — a start/launch \
verb here is #7805"
),
Command::External(tokens) => panic!(
"`session` must match its own hidden subcommand, never the external catch-all \
(got {tokens:?})"
),
other => panic!("expected Command::Session, got {other:?}"),
}
}
#[test]
fn top_level_alias_notice_message() {
use crate::commands::managed::deprecation_message;
assert_eq!(
deprecation_message("session", "sessions"),
"warning: 'session' is deprecated; use 'sessions'"
);
}
#[test]
fn unresumable_remedy_line_cites_real_subcommands() {
use crate::commands::guided_resume::unresumable_remedy_line;
let id = "6ca3950b-90d5-4367-94c7-68576b61dafa";
let workspace_missing = unresumable_remedy_line(id, Some("workspace_missing"));
assert!(
!workspace_missing.contains("session rm"),
"must never cite the nonexistent `tm session rm` verb, got: {workspace_missing:?}"
);
let cli = Cli::try_parse_from(["trusty-mpm", "session", "delete", id, "--force"])
.expect("the exact verb cited by the workspace_missing remedy must parse");
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::Delete {
id: parsed_id,
force,
},
} => {
assert_eq!(parsed_id, id);
assert!(force, "the cited invocation must pass --force");
}
other => panic!("expected session delete, got {other:?}"),
}
let pane_gone = unresumable_remedy_line(id, Some("pane_gone"));
assert!(
!pane_gone.contains("session rm"),
"must never cite the nonexistent `tm session rm` verb, got: {pane_gone:?}"
);
assert!(
pane_gone.contains("sibling window"),
"pane_gone remedy must explain the sibling-window hazard, got: {pane_gone:?}"
);
assert!(
pane_gone.contains("tmux list-panes") || pane_gone.contains("session info"),
"pane_gone remedy must point at an inspection step before any teardown, got: {pane_gone:?}"
);
let cli = Cli::try_parse_from(["trusty-mpm", "session", "decommission", id])
.expect("the decommission verb cited by the pane_gone remedy must parse");
assert!(matches!(
cli.command.unwrap(),
Command::Session {
action: SessionAction::Decommission { .. }
}
));
let cli_info = Cli::try_parse_from(["trusty-mpm", "session", "info", id])
.expect("the info verb cited by the pane_gone remedy must parse");
assert!(matches!(
cli_info.command.unwrap(),
Command::Session {
action: SessionAction::Info { .. }
}
));
let fallback = unresumable_remedy_line(id, None);
assert!(
!fallback.contains("session rm"),
"must never cite the nonexistent `tm session rm` verb, got: {fallback:?}"
);
assert!(
!fallback.contains("delete") && !fallback.contains("decommission"),
"the no-reason fallback must not suggest a destructive verb, got: {fallback:?}"
);
}
#[test]
fn truncate_for_display_leaves_short_bodies_unchanged() {
use crate::commands::guided_resume::truncate_for_display;
let short = "workspace directory /gone no longer exists";
assert_eq!(truncate_for_display(short), short);
}
#[test]
fn truncate_for_display_caps_long_bodies() {
use crate::commands::guided_resume::truncate_for_display;
let long = "x".repeat(5000);
let result = truncate_for_display(&long);
assert!(
result.chars().count() < long.chars().count(),
"an oversized body must be shortened"
);
assert!(
result.ends_with("… (truncated)"),
"a truncated body must be marked as such, got tail: {:?}",
&result[result.len().saturating_sub(20)..]
);
}
#[test]
fn picker_filter_live_state_excludes_decommissioned() {
assert!(
!is_live_session_state("decommissioned", false),
"decommissioned must be excluded from default view"
);
assert!(
is_live_session_state("active", false),
"active must be included in default view"
);
assert!(
is_live_session_state("stopped", false),
"stopped must be included in default view"
);
assert!(
is_live_session_state("errored", false),
"errored must be included in default view"
);
assert!(
is_live_session_state("provisioning", false),
"provisioning must be included in default view"
);
}
#[test]
fn is_live_session_state_excludes_soft_deleted_record() {
assert!(
!is_live_session_state("deleted", false),
"a soft-deleted, still-in-store record must be excluded from the \
default picker/list view"
);
assert!(!is_live_session_state("decommissioned", false));
assert!(is_live_session_state("active", false));
assert!(is_live_session_state("stopped", false));
}
#[test]
fn is_live_session_state_hides_slot_tombstone_from_default_view() {
assert!(
!is_live_session_state("deleted", false),
"a #3034 slot tombstone must be hidden from the default view"
);
}
#[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, &std::collections::HashSet::new());
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, &std::collections::HashSet::new());
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, &std::collections::HashSet::new());
assert!(
filtered.is_empty(),
"all-decommissioned input must produce empty list"
);
}
#[test]
fn picker_filter_hides_slot_tombstone_and_soft_deleted_record() {
let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
serde_json::from_value(serde_json::json!([
{ "id": "a1", "name": "sess-active", "state": "active" },
{ "id": "", "name": "", "state": "deleted", "slot": 2, "deleted": true },
{ "id": "b2", "name": "sess-soft-deleted", "state": "deleted" },
]))
.expect("test data must deserialize");
let filtered = filter_live_sessions(sessions, &std::collections::HashSet::new());
assert_eq!(
filtered.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
vec!["a1"],
"only the active session survives the default view"
);
}
#[test]
fn is_live_session_state_hides_a_record_the_prune_classified_dead() {
assert!(
!is_live_session_state("stopped", true),
"a stopped record the sweep classified dead must be hidden from the default view"
);
assert!(
!is_live_session_state("errored", true),
"an errored record the sweep classified dead must be hidden from the default view"
);
}
#[test]
fn is_live_session_state_keeps_resumable_stopped_record() {
assert!(
is_live_session_state("stopped", false),
"a stopped record with an intact workspace is resumable and must stay visible"
);
assert!(
is_live_session_state("errored", false),
"an errored record with an intact workspace must stay visible"
);
assert!(
is_live_session_state("active", false),
"an active record must stay visible"
);
}
#[test]
fn is_live_session_state_hides_slot_tombstone_whatever_the_sweep_said() {
assert!(!is_live_session_state("deleted", true));
assert!(!is_live_session_state("deleted", false));
assert!(!is_live_session_state("decommissioned", false));
}
#[test]
fn picker_filter_hides_only_what_the_prune_classified_dead() {
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-alive", "state": "stopped" },
{ "id": "c3", "name": "sess-dead", "state": "stopped" },
{ "id": "d4", "name": "sess-dead-err", "state": "errored" },
{ "id": "", "name": "", "state": "deleted", "slot": 5, "deleted": true },
]))
.expect("test data must deserialize");
let dead: std::collections::HashSet<String> =
["c3".to_string(), "d4".to_string()].into_iter().collect();
let filtered = filter_live_sessions(sessions, &dead);
assert_eq!(
filtered.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
vec!["a1", "b2"],
"exactly the active row and the stopped row the sweep left alone \
survive; the slot tombstone is hidden too since #5952"
);
}
#[test]
fn picker_filter_keeps_a_live_row_flagged_unresumable() {
let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
serde_json::from_value(serde_json::json!([
{ "id": "a1", "name": "live-pane", "state": "active", "unresumable": true },
{ "id": "b2", "name": "detached", "state": "errored", "unresumable": true },
]))
.expect("test data must deserialize");
let filtered = filter_live_sessions(sessions, &std::collections::HashSet::new());
assert_eq!(
filtered.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
vec!["a1", "b2"],
"a row carrying the raw `unresumable` flag that the sweep did NOT \
classify dead must stay visible — hiding it makes a live session \
unreachable through `tm`"
);
}
#[test]
fn scope_for_display_all_keeps_dead_record_visible() {
use crate::commands::session_picker::{parse_managed_sessions, scope_for_display};
let raw = serde_json::json!({
"sessions": [
{ "id": "a1", "name": "sess-active", "state": "active", "slot": 1 },
{ "id": "c3", "name": "sess-dead", "state": "stopped", "slot": 2 },
]
})
.to_string();
let sessions = parse_managed_sessions(&raw).expect("parse must succeed");
let dead: std::collections::HashSet<String> = ["c3".to_string()].into_iter().collect();
assert_eq!(
scope_for_display(sessions.clone(), true, &dead)
.iter()
.map(|s| s.id.as_str())
.collect::<Vec<_>>(),
vec!["a1", "c3"],
"--all must keep the dead record"
);
assert_eq!(
scope_for_display(sessions, false, &dead)
.iter()
.map(|s| s.id.as_str())
.collect::<Vec<_>>(),
vec!["a1"],
"the default view must drop the same record --all keeps"
);
}
#[test]
fn scope_for_display_hides_tombstone_by_default_and_all_shows_it() {
use crate::commands::session_picker::{parse_managed_sessions, scope_for_display};
let raw = serde_json::json!({
"sessions": [
{ "id": "a1", "name": "sess-one", "state": "active", "slot": 2 },
{ "id": "", "name": "", "state": "deleted", "slot": 3, "deleted": true },
{ "id": "c3", "name": "sess-two", "state": "stopped", "slot": 4 },
]
})
.to_string();
let sessions = parse_managed_sessions(&raw).expect("parse must succeed");
let none: std::collections::HashSet<String> = std::collections::HashSet::new();
assert_eq!(
scope_for_display(sessions.clone(), false, &none)
.iter()
.map(|s| s.slot)
.collect::<Vec<_>>(),
vec![2, 4],
"the default view must omit the deleted slot, leaving its number as a gap"
);
assert!(
!scope_for_display(sessions.clone(), false, &none)
.iter()
.any(|s| s.deleted),
"no tombstone row may reach the default table"
);
assert_eq!(
scope_for_display(sessions, true, &none)
.iter()
.map(|s| (s.slot, s.deleted))
.collect::<Vec<_>>(),
vec![(2, false), (3, true), (4, false)],
"--all must list the tombstone at its original slot, in slot order"
);
}
#[test]
fn cli_parses_no_prune_on_both_listing_surfaces() {
let top = Cli::try_parse_from(["trusty-mpm", "ls", "--json", "--no-prune"]).unwrap();
match top.command.unwrap() {
Command::Ls { no_prune, json, .. } => {
assert!(no_prune, "top-level `tm ls --no-prune` must parse to true");
assert!(json);
}
other => panic!("expected top-level ls, got {other:?}"),
}
let sessions =
Cli::try_parse_from(["trusty-mpm", "sessions", "ls", "--all", "--no-prune"]).unwrap();
match sessions.command.unwrap() {
Command::Sessions {
action: SessionAction::Ls { no_prune, all, .. },
} => {
assert!(no_prune, "`tm sessions ls --no-prune` must parse to true");
assert!(all);
}
other => panic!("expected sessions ls, got {other:?}"),
}
match Cli::try_parse_from(["trusty-mpm", "ls"])
.unwrap()
.command
.unwrap()
{
Command::Ls { no_prune, .. } => assert!(!no_prune),
other => panic!("expected top-level ls, got {other:?}"),
}
match Cli::try_parse_from(["trusty-mpm", "sessions", "ls"])
.unwrap()
.command
.unwrap()
{
Command::Sessions {
action: SessionAction::Ls { no_prune, .. },
} => assert!(!no_prune),
other => panic!("expected sessions ls, got {other:?}"),
}
}
#[test]
fn ls_help_states_that_a_plain_listing_prunes() {
let render = |argv: &[&str]| {
Cli::try_parse_from(argv)
.expect_err("--help exits through clap's error channel")
.to_string()
};
for argv in [
["trusty-mpm", "ls", "--help"].as_slice(),
["trusty-mpm", "sessions", "ls", "--help"].as_slice(),
] {
let help = render(argv);
assert!(
help.contains("prune"),
"`{argv:?}` help must tell the operator this listing prunes: {help}"
);
}
}
#[test]
fn cli_parses_session_prune_unresolvable() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"prune",
"--state",
"unresolvable",
"--dry-run",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::Prune {
state,
dry_run,
include_active,
},
} => {
assert_eq!(state, "unresolvable");
assert!(dry_run);
assert!(
!include_active,
"the unresolvable selector must not need the blanket flag"
);
}
other => panic!("expected session prune, got {other:?}"),
}
}
#[test]
fn cli_parses_session_decommission_ephemeral_dry_run() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"decommission-ephemeral",
"--dry-run",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::DecommissionEphemeral { dry_run },
} => assert!(dry_run),
other => panic!("expected session decommission-ephemeral, got {other:?}"),
}
}