use clap::Parser;
use crate::cli::{AuthAction, Cli, Command, RepairAction, ServicesAction, SessctlAction};
use crate::commands::session::compose_session_instructions;
#[test]
fn cli_parses_attach() {
let cli = Cli::try_parse_from(["trusty-mpm", "attach", "frontend"]).unwrap();
match cli.command.unwrap() {
Command::Attach { target, json } => {
assert_eq!(target, "frontend");
assert!(!json);
}
other => panic!("expected Attach, got {other:?}"),
}
}
#[test]
fn cli_parses_attach_with_json() {
let cli = Cli::try_parse_from(["trusty-mpm", "attach", "abc-123", "--json"]).unwrap();
match cli.command.unwrap() {
Command::Attach { target, json } => {
assert_eq!(target, "abc-123");
assert!(json);
}
other => panic!("expected Attach, got {other:?}"),
}
}
#[test]
fn cli_attach_requires_target() {
assert!(Cli::try_parse_from(["trusty-mpm", "attach"]).is_err());
}
#[test]
fn cli_parses_connect() {
let cli = Cli::try_parse_from(["trusty-mpm", "connect"]).unwrap();
match cli.command.unwrap() {
Command::Connect { dir } => assert_eq!(dir, None),
other => panic!("expected Connect, got {other:?}"),
}
assert_connect_claude_cmd_carries_persona_flags();
}
#[test]
fn cli_parses_connect_with_dir() {
let cli = Cli::try_parse_from(["trusty-mpm", "connect", "/work/p"]).unwrap();
match cli.command.unwrap() {
Command::Connect { dir } => assert_eq!(dir.as_deref(), Some("/work/p")),
other => panic!("expected Connect, got {other:?}"),
}
assert_connect_claude_cmd_carries_persona_flags();
}
fn assert_connect_claude_cmd_carries_persona_flags() {
let path = std::path::Path::new("/tmp/trusty-mpm-connect-test-prompt.txt");
let cmd = crate::commands::launch::connect_claude_cmd(Some(path));
assert!(
cmd.contains("--append-system-prompt-file"),
"connect claude_cmd must inject the PM system prompt file: {cmd}"
);
assert!(
cmd.contains("--setting-sources project,local"),
"connect claude_cmd must carry the session-isolation flag: {cmd}"
);
}
#[test]
fn cli_sm_aliases_reach_coordinator() {
for name in ["coordinator", "sm", "session-manager", "coord"] {
let cli = Cli::try_parse_from(["trusty-mpm", name, "hello there"])
.unwrap_or_else(|e| panic!("`tm {name}` must parse: {e}"));
match cli.command.unwrap() {
Command::Coordinator { message, action } => {
assert_eq!(
message.as_deref(),
Some("hello there"),
"`tm {name}` carries the message"
);
assert!(action.is_none(), "a plain message has no subcommand");
}
other => panic!("`tm {name}` must be Command::Coordinator, got {other:?}"),
}
}
}
#[test]
fn cli_parses_sm_serve_stdio() {
use crate::cli::CoordinatorAction;
for name in ["coordinator", "sm", "session-manager", "coord"] {
let cli = Cli::try_parse_from(["trusty-mpm", name, "serve", "--stdio"])
.unwrap_or_else(|e| panic!("`tm {name} serve --stdio` must parse: {e}"));
match cli.command.unwrap() {
Command::Coordinator { message, action } => {
assert!(message.is_none(), "the serve subcommand carries no message");
match action {
Some(CoordinatorAction::Serve { stdio }) => {
assert!(stdio, "--stdio sets the stdio flag");
}
other => panic!("`tm {name} serve` must be Serve, got {other:?}"),
}
}
other => panic!("`tm {name} serve` must be Command::Coordinator, got {other:?}"),
}
}
}
#[test]
fn cli_parses_daemon_custom_addr() {
let cli = Cli::try_parse_from(["trusty-mpm", "daemon", "--addr", "0.0.0.0:9000"]).unwrap();
match cli.command.unwrap() {
Command::Daemon { addr, .. } => assert_eq!(addr.to_string(), "0.0.0.0:9000"),
other => panic!("expected Daemon, got {other:?}"),
}
}
#[test]
fn cli_parses_services_list() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "list"]).unwrap();
assert!(matches!(
cli.command.unwrap(),
Command::Services {
action: ServicesAction::List { json: false }
}
));
}
#[test]
fn cli_parses_services_list_json() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "list", "--json"]).unwrap();
assert!(matches!(
cli.command.unwrap(),
Command::Services {
action: ServicesAction::List { json: true }
}
));
}
#[test]
fn cli_parses_services_status() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "status", "trusty-search"]).unwrap();
match cli.command.unwrap() {
Command::Services {
action: ServicesAction::Status { name, json },
} => {
assert_eq!(name, "trusty-search");
assert!(!json);
}
other => panic!("expected services status, got {other:?}"),
}
}
#[test]
fn cli_parses_services_status_json() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"services",
"status",
"trusty-search",
"--json",
])
.unwrap();
match cli.command.unwrap() {
Command::Services {
action: ServicesAction::Status { name, json },
} => {
assert_eq!(name, "trusty-search");
assert!(json);
}
other => panic!("expected services status --json, got {other:?}"),
}
}
#[test]
fn cli_parses_services_port() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "port", "trusty-search"]).unwrap();
match cli.command.unwrap() {
Command::Services {
action: ServicesAction::Port { name },
} => assert_eq!(name, "trusty-search"),
other => panic!("expected services port, got {other:?}"),
}
}
#[test]
fn cli_parses_services_url() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "url", "trusty-search"]).unwrap();
match cli.command.unwrap() {
Command::Services {
action: ServicesAction::Url { name },
} => assert_eq!(name, "trusty-search"),
other => panic!("expected services url, got {other:?}"),
}
}
#[test]
fn cli_parses_services_health() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "health", "trusty-search"]).unwrap();
match cli.command.unwrap() {
Command::Services {
action: ServicesAction::Health { name },
} => assert_eq!(name, "trusty-search"),
other => panic!("expected services health, got {other:?}"),
}
}
#[test]
fn cli_parses_services_log() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "log", "trusty-search"]).unwrap();
match cli.command.unwrap() {
Command::Services {
action: ServicesAction::Log { name },
} => assert_eq!(name, "trusty-search"),
other => panic!("expected services log, got {other:?}"),
}
}
#[test]
fn cli_parses_services_init() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "init"]).unwrap();
assert!(matches!(
cli.command.unwrap(),
Command::Services {
action: ServicesAction::Init { force: false }
}
));
}
#[test]
fn cli_parses_services_init_force() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "init", "--force"]).unwrap();
assert!(matches!(
cli.command.unwrap(),
Command::Services {
action: ServicesAction::Init { force: true }
}
));
}
#[test]
fn cli_parses_services_restart() {
let cli = Cli::try_parse_from(["trusty-mpm", "services", "restart", "trusty-search"]).unwrap();
match cli.command.unwrap() {
Command::Services {
action: ServicesAction::Restart { name },
} => assert_eq!(name, "trusty-search"),
other => panic!("expected services restart, got {other:?}"),
}
}
#[test]
fn compose_session_instructions_display_matches_stash() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
let (display, _output, stash_path) =
compose_session_instructions(project).expect("compose succeeds");
let on_disk =
std::fs::read_to_string(&stash_path).expect("stash file must be readable after compose");
assert_eq!(
display, on_disk,
"tm session instructions display must equal the stash file (issue #382)"
);
}
#[test]
fn compose_session_instructions_display_matches_live_prompt() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
let (display, _output, _stash) =
compose_session_instructions(project).expect("compose succeeds");
let live_prompt = trusty_mpm::core::session_launch::build_system_prompt_for(project);
assert_eq!(
display, live_prompt,
"tm session instructions output must match the live launch prompt (issue #382)"
);
}
#[test]
fn compose_session_instructions_display_matches_live_prompt_with_override() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
std::fs::write(
project.join("CLAUDE.md"),
"<!-- TRUSTY-MPM: WORKFLOW START v=1 -->\n\
# Custom Workflow\n\nCOMPOSE_OVERRIDE_MARKER\n\
<!-- TRUSTY-MPM: WORKFLOW END -->\n",
)
.unwrap();
let (display, _output, _stash) =
compose_session_instructions(project).expect("compose succeeds");
let live_prompt = trusty_mpm::core::session_launch::build_system_prompt_for(project);
assert_eq!(
display, live_prompt,
"display and live prompt must match with overrides present (issue #382)"
);
assert!(
display.contains("COMPOSE_OVERRIDE_MARKER"),
"override must be reflected in display"
);
assert!(
!display.contains("# PM Workflow Configuration"),
"bundled workflow must be replaced in display"
);
}
#[test]
fn cli_parses_daemon_mcp() {
let cli = Cli::try_parse_from(["trusty-mpm", "daemon", "--mcp"]).unwrap();
match cli.command.unwrap() {
Command::Daemon { mcp, .. } => assert!(mcp),
other => panic!("expected Daemon, got {other:?}"),
}
}
#[test]
fn cli_parses_repair_deploy() {
let cli = Cli::try_parse_from(["trusty-mpm", "repair", "deploy"]).unwrap();
match cli.command.unwrap() {
Command::Repair {
action: RepairAction::Deploy { force },
} => {
assert!(!force, "force must default to false");
}
other => panic!("expected Repair {{ Deploy }}, got {other:?}"),
}
}
#[test]
fn cli_parses_repair_deploy_force() {
let cli = Cli::try_parse_from(["trusty-mpm", "repair", "deploy", "--force"]).unwrap();
match cli.command.unwrap() {
Command::Repair {
action: RepairAction::Deploy { force },
} => {
assert!(force, "force must be true with --force flag");
}
other => panic!("expected Repair {{ Deploy {{ force: true }} }}, got {other:?}"),
}
}
#[test]
fn cli_parses_auth_set_token() {
let cli = Cli::try_parse_from(["trusty-mpm", "auth", "set-token", "--token", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Auth {
action: AuthAction::SetToken { token, stdin },
} => {
assert_eq!(token.as_deref(), Some("abc"));
assert!(!stdin, "stdin flag must default to false");
}
other => panic!("expected Auth {{ SetToken }}, got {other:?}"),
}
}
#[test]
fn cli_parses_auth_set_token_stdin() {
let cli = Cli::try_parse_from(["trusty-mpm", "auth", "set-token", "--stdin"]).unwrap();
match cli.command.unwrap() {
Command::Auth {
action: AuthAction::SetToken { token, stdin },
} => {
assert_eq!(token, None);
assert!(stdin);
}
other => panic!("expected Auth {{ SetToken }}, got {other:?}"),
}
}
#[test]
fn cli_parses_auth_clear_token() {
let cli = Cli::try_parse_from(["trusty-mpm", "auth", "clear-token"]).unwrap();
assert!(matches!(
cli.command.unwrap(),
Command::Auth {
action: AuthAction::ClearToken
}
));
}
#[test]
fn cli_parses_auth_status() {
let cli = Cli::try_parse_from(["trusty-mpm", "auth", "status"]).unwrap();
assert!(matches!(
cli.command.unwrap(),
Command::Auth {
action: AuthAction::Status
}
));
}
#[test]
fn cli_parses_rm_standalone() {
let cli = Cli::try_parse_from(["trusty-mpm", "rm", "my-proj"]).unwrap();
match cli.command.unwrap() {
Command::Rm { alias, root } => {
assert_eq!(alias, "my-proj");
assert_eq!(root, None);
}
other => panic!("expected Rm, got {other:?}"),
}
}
#[test]
fn cli_parses_rm_with_root() {
let cli =
Cli::try_parse_from(["trusty-mpm", "rm", "my-proj", "--root", "/tmp/custom"]).unwrap();
match cli.command.unwrap() {
Command::Rm { alias, root } => {
assert_eq!(alias, "my-proj");
assert_eq!(root.as_deref(), Some("/tmp/custom"));
}
other => panic!("expected Rm, got {other:?}"),
}
}
#[test]
fn cli_rm_requires_alias() {
assert!(Cli::try_parse_from(["trusty-mpm", "rm"]).is_err());
}
#[test]
fn cli_parses_update_standalone() {
let cli = Cli::try_parse_from(["trusty-mpm", "update", "my-proj"]).unwrap();
match cli.command.unwrap() {
Command::Update { alias, root } => {
assert_eq!(alias.as_deref(), Some("my-proj"));
assert_eq!(root, None);
}
other => panic!("expected Update, got {other:?}"),
}
}
#[test]
fn cli_parses_update_all_standalone() {
let cli = Cli::try_parse_from(["trusty-mpm", "update"]).unwrap();
match cli.command.unwrap() {
Command::Update { alias, root } => {
assert_eq!(alias, None);
assert_eq!(root, None);
}
other => panic!("expected Update, got {other:?}"),
}
}
#[test]
fn cli_parses_update_with_root() {
let cli = Cli::try_parse_from(["trusty-mpm", "update", "--root", "/tmp/r"]).unwrap();
match cli.command.unwrap() {
Command::Update { alias, root } => {
assert_eq!(alias, None);
assert_eq!(root.as_deref(), Some("/tmp/r"));
}
other => panic!("expected Update, got {other:?}"),
}
}
#[test]
fn rm_cmd_removes_registry_entry_and_project_dir() {
use crate::commands::managed_root::ManagedPaths;
use crate::commands::standalone::rm_cmd;
use trusty_mpm::core::standalone::registry::ManagedRegistry;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let paths = ManagedPaths::from_root(root.clone());
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("demo", "https://github.com/org/repo", false)
.unwrap();
reg.save().unwrap();
let project_dir = root.join("projects").join("demo");
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(project_dir.join("sentinel.txt"), b"data").unwrap();
rm_cmd(&paths, "demo").expect("rm_cmd must succeed");
let reg2 = ManagedRegistry::load(&root).unwrap();
assert!(
reg2.list().is_empty(),
"registry must be empty after rm_cmd"
);
assert!(
!project_dir.exists(),
"project dir must be deleted by rm_cmd"
);
}
#[test]
fn rm_cmd_errors_on_unknown_alias() {
use crate::commands::managed_root::ManagedPaths;
use crate::commands::standalone::rm_cmd;
let tmp = tempfile::TempDir::new().unwrap();
let paths = ManagedPaths::from_root(tmp.path().to_path_buf());
let result = rm_cmd(&paths, "nonexistent");
assert!(result.is_err(), "rm_cmd must error on unknown alias");
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("nonexistent"),
"error message must mention the alias"
);
}
#[test]
fn rm_cmd_leaves_claude_config_intact() {
use crate::commands::managed_root::ManagedPaths;
use crate::commands::standalone::rm_cmd;
use trusty_mpm::core::standalone::registry::ManagedRegistry;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let paths = ManagedPaths::from_root(root.clone());
let cfg_dir = root.join("claude-config");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(cfg_dir.join("shared.json"), b"{}").unwrap();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("proj", "https://github.com/org/repo", false)
.unwrap();
reg.save().unwrap();
let project_dir = root.join("projects").join("proj");
std::fs::create_dir_all(&project_dir).unwrap();
rm_cmd(&paths, "proj").expect("rm_cmd must succeed");
assert!(
cfg_dir.exists(),
"claude-config dir must NOT be touched by rm_cmd"
);
assert!(
cfg_dir.join("shared.json").exists(),
"files inside claude-config must survive rm_cmd"
);
}
#[test]
fn rm_cmd_succeeds_when_project_dir_absent() {
use crate::commands::managed_root::ManagedPaths;
use crate::commands::standalone::rm_cmd;
use trusty_mpm::core::standalone::registry::ManagedRegistry;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let paths = ManagedPaths::from_root(root.clone());
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("ghost", "https://github.com/org/repo", false)
.unwrap();
reg.save().unwrap();
assert!(!root.join("projects").join("ghost").exists());
rm_cmd(&paths, "ghost").expect("rm_cmd must succeed even without project dir");
let reg2 = ManagedRegistry::load(&root).unwrap();
assert!(
reg2.list().is_empty(),
"registry must be empty after rm_cmd on unloaded alias"
);
}
#[test]
fn update_cmd_errors_if_alias_not_in_registry() {
use crate::commands::managed_root::ManagedPaths;
use crate::commands::standalone::update_cmd;
let tmp = tempfile::TempDir::new().unwrap();
let paths = ManagedPaths::from_root(tmp.path().to_path_buf());
let result = update_cmd(&paths, Some("missing-alias"));
assert!(result.is_err(), "update_cmd must error when alias unknown");
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("missing-alias"),
"error must name the unknown alias"
);
}
#[test]
fn update_cmd_errors_if_not_loaded() {
use crate::commands::managed_root::ManagedPaths;
use crate::commands::standalone::update_cmd;
use trusty_mpm::core::standalone::registry::ManagedRegistry;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let paths = ManagedPaths::from_root(root.clone());
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("not-loaded", "https://github.com/org/repo", false)
.unwrap();
reg.save().unwrap();
assert!(!root.join("projects").join("not-loaded").exists());
let result = update_cmd(&paths, Some("not-loaded"));
assert!(
result.is_err(),
"update_cmd must error when alias is registered but not yet loaded"
);
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("not-loaded"),
"error must name the alias: {msg}"
);
assert!(
msg.contains("tm load"),
"error must hint to run `tm load`: {msg}"
);
}
#[test]
fn update_cmd_all_skips_unloaded_returns_ok_when_none_loaded() {
use crate::commands::managed_root::ManagedPaths;
use crate::commands::standalone::update_cmd;
use trusty_mpm::core::standalone::registry::ManagedRegistry;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let paths = ManagedPaths::from_root(root.clone());
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("unloaded", "https://github.com/org/repo", false)
.unwrap();
reg.save().unwrap();
let result = update_cmd(&paths, None);
assert!(
result.is_ok(),
"update_cmd with no loaded aliases must return Ok"
);
}
#[test]
fn cli_parses_sessctl_run() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessctl", "run", "my-proj"]).unwrap();
match cli.command.unwrap() {
Command::Sessctl {
action: SessctlAction::Run {
project_id, tmux, ..
},
} => {
assert_eq!(project_id, "my-proj");
assert!(!tmux);
}
other => panic!("expected sessctl run, got {other:?}"),
}
}
#[test]
fn cli_parses_sessctl_run_tmux() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessctl", "run", "--tmux", "proj"]).unwrap();
match cli.command.unwrap() {
Command::Sessctl {
action: SessctlAction::Run { tmux, .. },
} => assert!(tmux),
other => panic!("expected sessctl run, got {other:?}"),
}
}
#[test]
fn cli_parses_sessctl_connect() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessctl", "connect", "my-proj-0"]).unwrap();
match cli.command.unwrap() {
Command::Sessctl {
action: SessctlAction::Connect { session_id },
} => assert_eq!(session_id, "my-proj-0"),
other => panic!("expected sessctl connect, got {other:?}"),
}
}
#[test]
fn cli_parses_sessctl_stop() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessctl", "stop", "my-proj-0"]).unwrap();
match cli.command.unwrap() {
Command::Sessctl {
action: SessctlAction::Stop { session_id, force },
} => {
assert_eq!(session_id, "my-proj-0");
assert!(!force);
}
other => panic!("expected sessctl stop, got {other:?}"),
}
}
#[test]
fn cli_parses_sessctl_stop_force() {
let cli =
Cli::try_parse_from(["trusty-mpm", "sessctl", "stop", "--force", "my-proj-0"]).unwrap();
match cli.command.unwrap() {
Command::Sessctl {
action: SessctlAction::Stop { force, .. },
} => assert!(force),
other => panic!("expected sessctl stop --force, got {other:?}"),
}
}
#[test]
fn cli_parses_sessctl_auth() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessctl", "auth", "my-proj-0"]).unwrap();
match cli.command.unwrap() {
Command::Sessctl {
action: SessctlAction::Auth { session_id },
} => assert_eq!(session_id, "my-proj-0"),
other => panic!("expected sessctl auth, got {other:?}"),
}
}
#[test]
fn cli_parses_sessctl_list() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessctl", "list"]).unwrap();
match cli.command.unwrap() {
Command::Sessctl {
action: SessctlAction::List { project, format },
} => {
assert!(project.is_none());
assert_eq!(format, "table");
}
other => panic!("expected sessctl list, got {other:?}"),
}
}
#[test]
fn cli_parses_sessctl_list_with_project_and_json() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"sessctl",
"list",
"--project",
"foo",
"--format",
"json",
])
.unwrap();
match cli.command.unwrap() {
Command::Sessctl {
action: SessctlAction::List { project, format },
} => {
assert_eq!(project.as_deref(), Some("foo"));
assert_eq!(format, "json");
}
other => panic!("expected sessctl list, got {other:?}"),
}
}
#[tokio::test]
#[serial_test::serial]
async fn guided_fallback_never_pollutes_github_git_checkout() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path();
let git_init_ok = std::process::Command::new("git")
.arg("init")
.current_dir(project)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !git_init_ok {
eprintln!(
"guided_fallback_never_pollutes_github_git_checkout: git not available, skipping"
);
return;
}
let _ = std::process::Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/trusty-ci-nonexistent/no-repo-1724.git",
])
.current_dir(project)
.status();
let repos_root = tempfile::tempdir().unwrap();
let fake_base = repos_root
.path()
.join("trusty-ci-nonexistent")
.join("no-repo-1724");
std::fs::create_dir_all(fake_base.join(".git")).unwrap();
let prev = std::env::var(trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV).ok();
unsafe {
std::env::set_var(
trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV,
repos_root.path(),
);
}
let client = reqwest::Client::new();
let result =
crate::commands::guided::fallback_protected(&client, "http://127.0.0.1:1", project).await;
unsafe {
match prev {
Some(v) => std::env::set_var(
trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV,
v,
),
None => {
std::env::remove_var(trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV)
}
}
}
assert!(
!project.join("CLAUDE.md").exists(),
"#1724: CLAUDE.md must NOT be written to the live git checkout"
);
assert!(
!project.join(".mcp.json").exists(),
"#1724: .mcp.json must NOT be written to the live git checkout"
);
assert!(
!project.join(".trusty-mpm").exists(),
"#1724: .trusty-mpm dir must NOT be created in the live git checkout"
);
assert!(
!project.join(".claude").exists(),
"#1724: .claude dir must NOT be created in the live git checkout"
);
assert!(
result.is_err(),
"#1724: fallback must Err rather than deploy to the live checkout; \
got Ok which means framework files may have been written"
);
}
#[tokio::test]
async fn guided_fallback_blocks_non_github_git_checkout() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path();
let git_init_ok = std::process::Command::new("git")
.arg("init")
.current_dir(project)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !git_init_ok {
eprintln!("guided_fallback_blocks_non_github_git_checkout: git not available, skipping");
return;
}
let _ = std::process::Command::new("git")
.args([
"remote",
"add",
"origin",
"https://gitea.example.com/org/repo.git",
])
.current_dir(project)
.status();
let client = reqwest::Client::new();
let result =
crate::commands::guided::fallback_protected(&client, "http://127.0.0.1:1", project).await;
assert!(
!project.join("CLAUDE.md").exists(),
"#1724: CLAUDE.md must NOT be written to a non-GitHub git checkout"
);
assert!(
!project.join(".mcp.json").exists(),
"#1724: .mcp.json must NOT be written to a non-GitHub git checkout"
);
assert!(
!project.join(".trusty-mpm").exists(),
"#1724: .trusty-mpm dir must NOT be created in a non-GitHub git checkout"
);
assert!(
!project.join(".claude").exists(),
"#1724: .claude dir must NOT be created in a non-GitHub git checkout"
);
assert!(
result.is_err(),
"#1724: fallback must Err for non-GitHub git checkout; \
got Ok which means framework files may have been deployed"
);
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("auto-managed clones require a GitHub remote")
|| err_msg.contains("live git checkout"),
"#1724: error message must explain the protection; got: {err_msg}"
);
}
#[tokio::test]
async fn guided_fallback_untracked_ancestor_does_not_redirect() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path();
let git_ok = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(project)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !git_ok {
eprintln!(
"guided_fallback_untracked_ancestor_does_not_redirect: git unavailable, skipping"
);
return;
}
for (k, v) in [("user.email", "t@example.com"), ("user.name", "T")] {
let _ = std::process::Command::new("git")
.args(["config", k, v])
.current_dir(project)
.status();
}
std::fs::write(project.join("README.md"), b"x").unwrap();
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(project)
.status();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "init", "-q"])
.current_dir(project)
.status();
let _ = std::process::Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(project)
.status();
let untracked = project.join("cto");
std::fs::create_dir_all(&untracked).unwrap();
let client = reqwest::Client::new();
let result =
crate::commands::guided::fallback_protected(&client, "http://127.0.0.1:1", &untracked)
.await;
assert!(
result.is_ok(),
"#2534: untracked ancestor subdir must route to the clean non-git \
fallback (Ok), not the ancestor's managed-clone redirect; got: {result:?}"
);
for p in ["CLAUDE.md", ".mcp.json", ".trusty-mpm", ".claude"] {
assert!(
!untracked.join(p).exists(),
"#2534: {p} must NOT be written to the untracked directory"
);
assert!(
!project.join(p).exists(),
"#2534: {p} must NOT be written to the enclosing repo"
);
}
}
#[tokio::test]
async fn guided_fallback_non_git_dir_reaches_launch_path() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path();
assert!(
!project.join(".git").exists(),
"test precondition: must NOT be a git repo"
);
let client = reqwest::Client::new();
let result =
crate::commands::guided::fallback_protected(&client, "http://127.0.0.1:1", project).await;
assert!(
result.is_ok(),
"#1839: non-git dir must exit cleanly (Ok) with a help hint; got: {result:?}"
);
assert!(
!project.join("CLAUDE.md").exists(),
"CLAUDE.md must not exist for plain non-git directories"
);
}
#[tokio::test]
#[serial_test::serial]
async fn guided_fallback_non_git_dir_no_managed_env_is_fast() {
let managed_key = "TM_MANAGED_SESSION_ID";
let prev = std::env::var(managed_key).ok();
unsafe { std::env::remove_var(managed_key) };
let prev_tmux = std::env::var("TMUX").ok();
unsafe { std::env::remove_var("TMUX") };
let dir = tempfile::tempdir().unwrap();
let project = dir.path();
assert!(!project.join(".git").exists(), "test precondition");
let client = reqwest::Client::new();
let start = std::time::Instant::now();
let result =
crate::commands::guided::fallback_protected(&client, "http://127.0.0.1:1", project).await;
let elapsed = start.elapsed();
unsafe {
match prev {
Some(ref v) => std::env::set_var(managed_key, v),
None => std::env::remove_var(managed_key),
}
match prev_tmux {
Some(ref v) => std::env::set_var("TMUX", v),
None => std::env::remove_var("TMUX"),
}
}
assert!(
result.is_ok(),
"non-git dir must exit cleanly; got {result:?}"
);
assert!(
elapsed < std::time::Duration::from_millis(150),
"no managed-session signal must never pay the #4061 retry budget; took {elapsed:?}"
);
}
async fn spawn_4061_state_mock(
id: &'static str,
states: Vec<&'static str>,
) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
let hits = Arc::new(AtomicUsize::new(0));
let hits_task = hits.clone();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let n = hits_task.fetch_add(1, Ordering::SeqCst);
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(
std::time::Duration::from_millis(200),
sock.read(&mut buf),
)
.await
{
Ok(Ok(n)) if n > 0 => {
if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
_ => break,
}
}
let idx = n.min(states.len().saturating_sub(1));
let state = states.get(idx).copied().unwrap_or("active");
let body = format!(r#"{{"id":"{id}","name":"tm-test-4061","state":"{state}"}}"#);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
}
});
(format!("http://{addr}"), hits)
}
#[tokio::test]
#[serial_test::serial]
async fn guided_fallback_non_git_dir_with_managed_env_settles_quickly_returns_promptly() {
const TEST_ID: &str = "11111111-2222-3333-4444-555555555555";
let (url, hits) = spawn_4061_state_mock(TEST_ID, vec!["active", "stopped"]).await;
let managed_key = "TM_MANAGED_SESSION_ID";
let prev = std::env::var(managed_key).ok();
unsafe { std::env::set_var(managed_key, TEST_ID) };
let dir = tempfile::tempdir().unwrap();
let project = dir.path();
let client = reqwest::Client::new();
let start = std::time::Instant::now();
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
crate::commands::guided::fallback_protected(&client, &url, project),
)
.await;
let elapsed = start.elapsed();
unsafe {
match prev {
Some(ref v) => std::env::set_var(managed_key, v),
None => std::env::remove_var(managed_key),
}
}
let result = result.expect("#4061: fallback_protected must never hang");
assert!(
result.is_ok(),
"a managed pane whose record settles must still exit cleanly; got {result:?}"
);
assert!(
!project.join("CLAUDE.md").exists(),
"CLAUDE.md must not be written for this non-git cwd"
);
assert!(
hits.load(std::sync::atomic::Ordering::SeqCst) >= 1,
"the retried in-place check must actually query the daemon"
);
assert!(
elapsed < std::time::Duration::from_millis(300),
"a record that settles on the second poll must not pay the full \
retry budget; took {elapsed:?}"
);
}
#[tokio::test]
#[serial_test::serial]
async fn guided_fallback_non_git_dir_with_managed_env_unreachable_daemon_does_not_hang() {
const TEST_ID: &str = "11111111-2222-3333-4444-555555555555";
let managed_key = "TM_MANAGED_SESSION_ID";
let prev = std::env::var(managed_key).ok();
unsafe { std::env::set_var(managed_key, TEST_ID) };
let dir = tempfile::tempdir().unwrap();
let project = dir.path();
let client = reqwest::Client::new();
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
crate::commands::guided::fallback_protected(&client, "http://127.0.0.1:1", project),
)
.await;
unsafe {
match prev {
Some(ref v) => std::env::set_var(managed_key, v),
None => std::env::remove_var(managed_key),
}
}
let result = result.expect("#4061: fallback_protected must never hang, even unreachable");
assert!(
result.is_ok(),
"an unreachable daemon must still exit cleanly; got {result:?}"
);
assert!(
!project.join("CLAUDE.md").exists(),
"CLAUDE.md must not be written for this non-git cwd"
);
}
#[tokio::test]
#[serial_test::serial]
async fn guided_fallback_blocks_github_git_from_subdirectory() {
let repo_dir = tempfile::tempdir().unwrap();
let git_init_ok = std::process::Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !git_init_ok {
eprintln!("guided_fallback_blocks_github_git_from_subdirectory: git unavailable, skipping");
return;
}
for (k, v) in [("user.email", "t@example.com"), ("user.name", "T")] {
let _ = std::process::Command::new("git")
.args(["config", k, v])
.current_dir(repo_dir.path())
.status();
}
let _ = std::process::Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/trusty-ci-nonexistent/subdir-test-1724.git",
])
.current_dir(repo_dir.path())
.status();
let subdir = repo_dir.path().join("src").join("module");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(subdir.join(".keep"), b"").unwrap();
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(repo_dir.path())
.status();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "init", "-q"])
.current_dir(repo_dir.path())
.status();
let repos_root = tempfile::tempdir().unwrap();
let fake_base = repos_root
.path()
.join("trusty-ci-nonexistent")
.join("subdir-test-1724");
std::fs::create_dir_all(fake_base.join(".git")).unwrap();
let prev = std::env::var(trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV).ok();
unsafe {
std::env::set_var(
trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV,
repos_root.path(),
);
}
let client = reqwest::Client::new();
let result =
crate::commands::guided::fallback_protected(&client, "http://127.0.0.1:1", &subdir).await;
unsafe {
match prev {
Some(v) => std::env::set_var(
trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV,
v,
),
None => {
std::env::remove_var(trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV)
}
}
}
assert!(
!repo_dir.path().join("CLAUDE.md").exists(),
"#1724: CLAUDE.md must NOT appear in repo root when called from subdir"
);
assert!(
!subdir.join("CLAUDE.md").exists(),
"#1724: CLAUDE.md must NOT appear in the subdir"
);
assert!(
!repo_dir.path().join(".mcp.json").exists(),
"#1724: .mcp.json must NOT appear in repo root"
);
assert!(
!subdir.join(".mcp.json").exists(),
"#1724: .mcp.json must NOT appear in the subdir"
);
assert!(
result.is_err(),
"#1724: fallback from subdir must Err, not deploy to the live tree"
);
}
#[tokio::test]
#[serial_test::serial]
async fn guided_fallback_redirect_success_worktree_not_live_checkout() {
let repos_root = tempfile::tempdir().unwrap();
let base = repos_root
.path()
.join("test-owner-1724")
.join("test-repo-1724");
std::fs::create_dir_all(&base).unwrap();
let git_init_ok = std::process::Command::new("git")
.arg("init")
.current_dir(&base)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !git_init_ok {
eprintln!(
"guided_fallback_redirect_success_worktree_not_live_checkout: git unavailable, skipping"
);
return;
}
let _ = std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"config",
"user.email",
"ci@test.invalid",
])
.status();
let _ = std::process::Command::new("git")
.args(["-C", base.to_str().unwrap(), "config", "user.name", "CI"])
.status();
let commit_ok = std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"commit",
"--allow-empty",
"-m",
"init",
])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !commit_ok {
eprintln!(
"guided_fallback_redirect_success_worktree_not_live_checkout: git commit failed, skipping"
);
return;
}
let live_dir = tempfile::tempdir().unwrap();
let _ = std::process::Command::new("git")
.arg("init")
.current_dir(live_dir.path())
.status();
let _ = std::process::Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/test-owner-1724/test-repo-1724.git",
])
.current_dir(live_dir.path())
.status();
let repos_root_key = trusty_mpm::daemon::managed_routes::inproject::REPOS_ROOT_ENV;
let prev_repos_root = 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 =
crate::commands::guided::fallback_protected(&client, "http://127.0.0.1:1", live_dir.path())
.await;
unsafe {
match prev_repos_root {
Some(ref v) => std::env::set_var(repos_root_key, v),
None => std::env::remove_var(repos_root_key),
}
}
assert!(
!live_dir.path().join("CLAUDE.md").exists(),
"#1724: CLAUDE.md must NOT appear in the live checkout after redirect"
);
assert!(
!live_dir.path().join(".mcp.json").exists(),
"#1724: .mcp.json must NOT appear in the live checkout after redirect"
);
assert!(
!live_dir.path().join(".trusty-mpm").exists(),
"#1724: .trusty-mpm must NOT be created in the live checkout"
);
assert!(
!live_dir.path().join(".claude").exists(),
"#1724: .claude must NOT be created in the live checkout"
);
let worktrees_dir = base.join(".worktrees");
assert!(
worktrees_dir.exists(),
"#1724 #1803: .worktrees/ dir must exist under base clone after successful redirect"
);
let sessions: Vec<_> = std::fs::read_dir(&worktrees_dir)
.expect(".worktrees dir must be listable")
.filter_map(|e| e.ok())
.collect();
assert!(
!sessions.is_empty(),
"#1724 #1803: at least one per-session worktree must be present under .worktrees/, \
proving launch(Some(worktree)) was invoked rather than launch(None)"
);
}
#[test]
fn launch_paths_prepare_through_the_isolated_seam() {
const LAUNCH_SRC: &str = include_str!("commands/launch.rs");
const META_LAUNCH_SRC: &str = include_str!("commands/meta/launch.rs");
for (name, src, expected_calls) in [
("commands/launch.rs", LAUNCH_SRC, 2usize),
("commands/meta/launch.rs", META_LAUNCH_SRC, 1usize),
] {
assert!(
!src.contains("FrameworkPaths::default()"),
"{name} spawns `claude` with `{}`, which excludes the `user` tier \
`FrameworkPaths::default()` deploys into — deploy via \
`session_launch::prepare_isolated_session` instead (issue #4203)",
trusty_mpm::core::model_inject::SETTING_SOURCES_FLAG
);
assert_eq!(
src.matches("prepare_isolated_session(").count(),
expected_calls,
"{name} must deploy through `prepare_isolated_session` exactly \
{expected_calls}x (issue #4203); if a call site was added or removed, \
confirm its spawn's `--setting-sources` posture and update this count"
);
}
const START_SRC: &str = include_str!("commands/session/start.rs");
assert!(
!START_SRC.contains("SETTING_SOURCES_FLAG"),
"commands/session/start.rs now carries a `--setting-sources` flag, so its \
`FrameworkPaths::default()` deploy has become the #4203 defect — route it \
through `prepare_isolated_session` and add it to the list above"
);
}
#[test]
fn cli_parses_reinstall() {
let cli = Cli::try_parse_from(["trusty-mpm", "reinstall"]).unwrap();
assert!(matches!(
cli.command.unwrap(),
Command::Reinstall(crate::cli::ReinstallArgs {
force: false,
binary: false,
yes: false
})
));
}
#[test]
fn cli_parses_reinstall_binary() {
let cli = Cli::try_parse_from(["trusty-mpm", "reinstall", "--binary", "--yes"]).unwrap();
assert!(matches!(
cli.command.unwrap(),
Command::Reinstall(crate::cli::ReinstallArgs {
binary: true,
yes: true,
..
})
));
assert!(Cli::try_parse_from(["trusty-mpm", "reinstall", "--yes"]).is_err());
}