use tempfile::TempDir;
use super::manager::{ManagedError, ManagedTmuxDriver};
use super::record::{ManagedSessionId, ManagedSessionState};
use super::rename::validate_session_name;
use super::tests::{make_manager, seed_record};
#[test]
fn validate_session_name_accepts_valid_and_trims() {
assert_eq!(
validate_session_name(" tm-quiet-falcon ").unwrap(),
"tm-quiet-falcon"
);
assert_eq!(
validate_session_name("tm_worker_01").unwrap(),
"tm_worker_01"
);
}
#[test]
fn validate_session_name_rejects_empty_and_bad_chars() {
assert!(
validate_session_name(" ").is_err(),
"empty/whitespace must reject"
);
assert!(
validate_session_name("has space").is_err(),
"whitespace must reject"
);
assert!(
validate_session_name("has.dot").is_err(),
"tmux-reserved '.' must reject"
);
assert!(
validate_session_name("has:colon").is_err(),
"tmux-reserved ':' must reject"
);
assert!(
validate_session_name(&"x".repeat(65)).is_err(),
"over-64-char name must reject"
);
}
#[tokio::test]
async fn rename_updates_name_and_persists() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Stopped, false).await;
let updated = mgr.rename(&id, "tm-renamed-01").await.expect("rename");
assert_eq!(updated.tmux_name, "tm-renamed-01");
let reread = mgr.get(&id).await.expect("get");
assert_eq!(reread.tmux_name, "tm-renamed-01");
}
#[tokio::test]
async fn rename_same_name_is_noop() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Stopped, false).await;
let current = mgr.get(&id).await.expect("get").tmux_name;
let same = mgr.rename(&id, ¤t).await.expect("rename same");
assert_eq!(same.tmux_name, current);
}
#[tokio::test]
async fn rename_suffixes_collision_with_record() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let a = ManagedSessionId::new();
let b = ManagedSessionId::new();
seed_record(&mgr, &dir, a, ManagedSessionState::Stopped, false).await;
seed_record(&mgr, &dir, b, ManagedSessionState::Stopped, false).await;
let b_name = mgr.get(&b).await.expect("get b").tmux_name;
let updated = mgr
.rename(&a, &b_name)
.await
.expect("collision must auto-suffix, never reject");
assert_eq!(updated.tmux_name, format!("{b_name}-2"));
assert_eq!(mgr.get(&b).await.expect("get b").tmux_name, b_name);
}
#[tokio::test]
async fn rename_suffixes_collision_with_live_tmux() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Stopped, false).await;
fake.create_session("tm-foreign-live", "/tmp")
.expect("register foreign tmux");
let updated = mgr
.rename(&id, "tm-foreign-live")
.await
.expect("collision with a live foreign session must auto-suffix, never reject");
assert_eq!(updated.tmux_name, "tm-foreign-live-2");
}
#[tokio::test]
async fn rename_suffix_skips_to_next_free_ordinal() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let a = ManagedSessionId::new();
let b = ManagedSessionId::new();
let c = ManagedSessionId::new();
seed_record(&mgr, &dir, a, ManagedSessionState::Stopped, false).await;
seed_record(&mgr, &dir, b, ManagedSessionState::Stopped, false).await;
seed_record(&mgr, &dir, c, ManagedSessionState::Stopped, false).await;
let b_name = mgr.get(&b).await.expect("get b").tmux_name;
let first = mgr
.rename(&a, &b_name)
.await
.expect("first collision suffixes to -2");
assert_eq!(first.tmux_name, format!("{b_name}-2"));
let second = mgr
.rename(&c, &b_name)
.await
.expect("second collision must skip the now-taken -2 and land on -3");
assert_eq!(second.tmux_name, format!("{b_name}-3"));
}
#[tokio::test]
async fn rename_concurrent_stopped_renames_to_same_target_never_collide() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let a = ManagedSessionId::new();
let b = ManagedSessionId::new();
seed_record(&mgr, &dir, a, ManagedSessionState::Stopped, false).await;
seed_record(&mgr, &dir, b, ManagedSessionState::Stopped, false).await;
let (ra, rb) = tokio::join!(
mgr.rename(&a, "tm-contested"),
mgr.rename(&b, "tm-contested")
);
let ra = ra.expect("rename a");
let rb = rb.expect("rename b");
assert_ne!(
ra.tmux_name, rb.tmux_name,
"concurrent renames to one target must never both claim the same name"
);
let mut names = [ra.tmux_name.as_str(), rb.tmux_name.as_str()];
names.sort_unstable();
assert_eq!(
names,
["tm-contested", "tm-contested-2"],
"one wins the bare name, the other takes the -2 suffix"
);
}
#[cfg(unix)]
#[tokio::test]
async fn rename_rolls_back_tmux_when_store_write_fails() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Active, false).await;
let old_name = mgr.get(&id).await.expect("get").tmux_name;
let dir_perms = std::fs::metadata(dir.path()).expect("meta").permissions();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555))
.expect("make store dir read-only");
let err = mgr
.rename(&id, "tm-doomed-rename")
.await
.expect_err("rename must fail when the store write fails");
std::fs::set_permissions(dir.path(), dir_perms).expect("restore perms");
assert!(
matches!(err, ManagedError::InvalidState(_, _)),
"got {err:?}"
);
let renames = fake.rename_calls.lock().unwrap();
assert!(
renames
.iter()
.any(|(o, n)| o == &old_name && n == "tm-doomed-rename"),
"the forward rename must have happened: {renames:?}"
);
assert!(
renames
.iter()
.any(|(o, n)| o == "tm-doomed-rename" && n == &old_name),
"the compensating rollback rename must have happened: {renames:?}"
);
drop(renames);
assert!(
fake.session_exists(&old_name),
"the live session must answer to its ORIGINAL name after rollback"
);
assert!(
!fake.session_exists("tm-doomed-rename"),
"the failed new name must not linger on the live session"
);
}
#[tokio::test]
async fn rename_rejects_invalid_name() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Stopped, false).await;
let err = mgr
.rename(&id, "bad name!")
.await
.expect_err("invalid name must be rejected");
assert!(
matches!(err, ManagedError::InvalidState(_, _)),
"got {err:?}"
);
}
#[tokio::test]
async fn rename_rejects_terminal_record() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Decommissioned, false).await;
let err = mgr
.rename(&id, "tm-new-name")
.await
.expect_err("a terminal record must not be renamed");
assert!(
matches!(err, ManagedError::InvalidState(_, _)),
"got {err:?}"
);
}
#[tokio::test]
async fn rename_reuses_name_freed_by_a_deleted_record() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let live = ManagedSessionId::new();
let gone = ManagedSessionId::new();
seed_record(&mgr, &dir, live, ManagedSessionState::Stopped, false).await;
seed_record(&mgr, &dir, gone, ManagedSessionState::Deleted, false).await;
let freed = mgr.get(&gone).await.expect("get gone").tmux_name;
let updated = mgr
.rename(&live, &freed)
.await
.expect("a deleted tombstone's name must be reusable");
assert_eq!(updated.tmux_name, freed);
}
#[tokio::test]
async fn rename_renames_live_tmux_session() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Active, false).await;
let old_name = mgr.get(&id).await.expect("get").tmux_name;
mgr.rename(&id, "tm-live-renamed").await.expect("rename");
let renames = fake.rename_calls.lock().unwrap();
assert!(
renames
.iter()
.any(|(o, n)| o == &old_name && n == "tm-live-renamed"),
"expected rename_session({old_name} -> tm-live-renamed), got {renames:?}"
);
drop(renames);
assert!(
fake.session_exists("tm-live-renamed"),
"new name must be live"
);
assert!(!fake.session_exists(&old_name), "old name must be gone");
}
#[tokio::test]
async fn rename_legacy_record_without_pane_id_still_renames_live_tmux_session() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Active, false).await;
assert_eq!(
mgr.get(&id).await.expect("get").pane_id,
None,
"sanity: seeded record is legacy (no pane_id)"
);
let old_name = mgr.get(&id).await.expect("get").tmux_name;
*fake.pane_exists_override.lock().unwrap() = Some(false);
mgr.rename(&id, "tm-legacy-renamed").await.expect("rename");
let renames = fake.rename_calls.lock().unwrap();
assert!(
renames
.iter()
.any(|(o, n)| o == &old_name && n == "tm-legacy-renamed"),
"a legacy record's rename must still physically rename its live tmux \
session via the name-only check: {renames:?}"
);
}
#[tokio::test]
async fn rename_never_renames_unrelated_live_session_sharing_a_stale_name() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
*fake.pane_id_override.lock().unwrap() = Some("%3".to_string());
let record = mgr
.create(
"task".into(),
Some(dir.path().to_path_buf()),
Some("dup-name-session".into()),
Some(dir.path().to_path_buf()),
None,
None,
)
.await
.expect("create");
assert_eq!(
record.pane_id.as_deref(),
Some("%3"),
"sanity: pane_id captured at create time"
);
mgr.set_workspace(
&record.id,
dir.path().to_path_buf(),
ManagedSessionState::Active,
)
.await
.expect("set Active");
let old_name = record.tmux_name.clone();
*fake.pane_exists_override.lock().unwrap() = Some(false);
let updated = mgr
.rename(&record.id, "tm-renamed-safely")
.await
.expect("rename must still succeed — DB-only when the tmux identity can't be confirmed");
assert_eq!(updated.tmux_name, "tm-renamed-safely");
assert!(
fake.rename_calls.lock().unwrap().is_empty(),
"must never physically rename a tmux session that isn't confirmed to be this record's own"
);
assert!(
fake.session_exists(&old_name),
"the unrelated live session (still answering to old_name) must remain untouched"
);
assert!(
!fake.session_exists("tm-renamed-safely"),
"no tmux session should have been created/renamed under the new name"
);
}
#[tokio::test]
async fn rename_renames_live_session_when_pane_confirmed_alive() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
*fake.pane_id_override.lock().unwrap() = Some("%9".to_string());
let record = mgr
.create(
"task".into(),
Some(dir.path().to_path_buf()),
Some("own-pane-session".into()),
Some(dir.path().to_path_buf()),
None,
None,
)
.await
.expect("create");
mgr.set_workspace(
&record.id,
dir.path().to_path_buf(),
ManagedSessionState::Active,
)
.await
.expect("set Active");
let old_name = record.tmux_name.clone();
mgr.rename(&record.id, "tm-live-renamed-2")
.await
.expect("rename");
let renames = fake.rename_calls.lock().unwrap();
assert!(
renames
.iter()
.any(|(o, n)| o == &old_name && n == "tm-live-renamed-2"),
"a confirmed-alive pane must still rename the live tmux session: {renames:?}"
);
}