use std::path::PathBuf;
use tempfile::TempDir;
use super::manager::{ManagedTmuxDriver, SessionManager};
use super::record::ManagedSessionState;
use super::tests::FakeTmuxDriver;
#[tokio::test]
async fn manager_naming_convention() {
let dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let record = mgr
.create(
"task".into(),
Some(PathBuf::from("/tmp/wt1")),
None,
None,
None,
None,
)
.await
.expect("create");
assert!(record.tmux_name.starts_with("tm-"), "has tm- prefix");
let suffix = record
.tmux_name
.rsplit('-')
.next()
.expect("name has at least one dash-delimited segment");
assert_eq!(
suffix.len(),
2,
"serial suffix is exactly 2 digits: {}",
record.tmux_name
);
assert!(
suffix.bytes().all(|b| b.is_ascii_digit()),
"serial suffix is numeric: {}",
record.tmux_name
);
let serial: u8 = suffix.parse().expect("suffix is numeric (checked above)");
assert!(
(1..=99).contains(&serial),
"serial is in 01-99: {}",
record.tmux_name
);
}
#[tokio::test]
async fn manager_serial_reuses_decommissioned_gap() {
let dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let repo_url = Some("https://github.com/acme/gap-test".to_string());
let r1 = mgr
.create(
"t1".into(),
Some(PathBuf::from("/tmp/g1")),
None,
None,
repo_url.clone(),
None,
)
.await
.expect("create r1");
let r2 = mgr
.create(
"t2".into(),
Some(PathBuf::from("/tmp/g2")),
None,
None,
repo_url.clone(),
None,
)
.await
.expect("create r2");
let r3 = mgr
.create(
"t3".into(),
Some(PathBuf::from("/tmp/g3")),
None,
None,
repo_url.clone(),
None,
)
.await
.expect("create r3");
assert_eq!(r1.tmux_name, "tm-gap-test-01");
assert_eq!(r2.tmux_name, "tm-gap-test-02");
assert_eq!(r3.tmux_name, "tm-gap-test-03");
{
let mut store = mgr.store.write().await;
let mut rec = store.get(&r2.id).await.expect("get r2");
rec.state = ManagedSessionState::Decommissioned;
store.upsert(rec).await.expect("upsert decommissioned r2");
}
fake.kill_session(&r2.tmux_name).expect("kill r2 tmux");
let r4 = mgr
.create(
"t4".into(),
Some(PathBuf::from("/tmp/g4")),
None,
None,
repo_url.clone(),
None,
)
.await
.expect("create r4");
assert_eq!(
r4.tmux_name, "tm-gap-test-02",
"decommissioned serial 02 must be reused, got {}",
r4.tmux_name
);
}
#[tokio::test]
async fn manager_reconcile_adopts_new_prefix_session() {
let dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
fake.seeded_names
.lock()
.unwrap()
.push("tm-trusty-tools-01".into());
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let report = mgr.reconcile_on_boot(false).await.expect("reconcile");
assert!(
report
.external_adopted
.contains(&"tm-trusty-tools-01".to_string()),
"a live tm-prefixed session must be externally adopted; report: {report:?}"
);
let listed = mgr.list().await;
let adopted = listed
.iter()
.find(|r| r.tmux_name == "tm-trusty-tools-01")
.expect("adopted record present");
assert_eq!(adopted.state, ManagedSessionState::Active);
}
struct PaneCwdTmux {
alive: Vec<String>,
pane_cwd: Option<PathBuf>,
}
impl ManagedTmuxDriver for PaneCwdTmux {
fn create_session(
&self,
_name: &str,
_workdir: &str,
) -> Result<(), super::manager::ManagedError> {
unimplemented!("not exercised by PaneCwdTmux tests")
}
fn kill_session(&self, _name: &str) -> Result<(), super::manager::ManagedError> {
unimplemented!("not exercised by PaneCwdTmux tests")
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), super::manager::ManagedError> {
unimplemented!("not exercised by PaneCwdTmux tests")
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, super::manager::ManagedError> {
unimplemented!("not exercised by PaneCwdTmux tests")
}
fn list_sessions(&self) -> Result<Vec<String>, super::manager::ManagedError> {
Ok(self.alive.clone())
}
fn get_pane_cwd(&self, _name: &str) -> Option<PathBuf> {
self.pane_cwd.clone()
}
}
#[tokio::test]
async fn reconcile_resolves_adopted_session_cwd_from_pane() {
let dir = TempDir::new().unwrap();
let workspace = TempDir::new().unwrap();
let fake = std::sync::Arc::new(PaneCwdTmux {
alive: vec!["tm-resolvable-01".into()],
pane_cwd: Some(workspace.path().to_path_buf()),
});
let mgr = SessionManager::new(dir.path(), fake).await.unwrap();
mgr.reconcile_on_boot(false).await.expect("reconcile");
let listed = mgr.list().await;
let adopted = listed
.iter()
.find(|r| r.tmux_name == "tm-resolvable-01")
.expect("adopted record present");
assert_eq!(adopted.cwd, workspace.path());
assert_eq!(adopted.workspace_path.as_deref(), Some(workspace.path()));
assert_eq!(adopted.task, "adopted session");
}
#[tokio::test]
async fn reconcile_flags_unresolvable_adopted_session_as_unmanaged() {
let dir = TempDir::new().unwrap();
let fake = std::sync::Arc::new(PaneCwdTmux {
alive: vec!["tm-unresolvable-01".into()],
pane_cwd: None,
});
let mgr = SessionManager::new(dir.path(), fake).await.unwrap();
mgr.reconcile_on_boot(false).await.expect("reconcile");
let listed = mgr.list().await;
let adopted = listed
.iter()
.find(|r| r.tmux_name == "tm-unresolvable-01")
.expect("adopted record present");
assert_eq!(adopted.cwd, PathBuf::from("/unknown"));
assert!(adopted.workspace_path.is_none());
assert!(
adopted.task.contains("unmanaged"),
"task must clearly flag the record as unmanaged, got: {}",
adopted.task
);
}
#[tokio::test]
async fn reconcile_skips_external_adopt_when_workspace_already_tracked() {
let dir = TempDir::new().unwrap();
let workspace = TempDir::new().unwrap();
let fake = std::sync::Arc::new(PaneCwdTmux {
alive: vec!["tm-crossed-01".into()],
pane_cwd: Some(workspace.path().to_path_buf()),
});
let mgr = SessionManager::new(dir.path(), fake).await.unwrap();
let existing = super::record::SessionRecord {
id: super::record::ManagedSessionId::new(),
tmux_name: "tm-known-01".into(),
cwd: workspace.path().to_path_buf(),
task: "existing task".into(),
state: ManagedSessionState::Stopped,
created_at: chrono::Utc::now(),
last_activity_at: None,
workspace_path: Some(workspace.path().to_path_buf()),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
};
let existing_id = existing.id;
mgr.store.write().await.upsert(existing).await.unwrap();
let report = mgr.reconcile_on_boot(false).await.expect("reconcile");
assert!(
!report
.external_adopted
.contains(&"tm-crossed-01".to_string()),
"a live session resolving to an already-tracked workspace must never be \
externally adopted; report: {report:?}"
);
let listed = mgr.list().await;
assert_eq!(
listed.len(),
1,
"no second record must be minted for the same workspace; records: {listed:?}"
);
assert_eq!(
listed[0].id, existing_id,
"the original record must be the only one"
);
assert!(
!listed.iter().any(|r| r.tmux_name == "tm-crossed-01"),
"no record must carry the crossed tmux_name"
);
}
#[tokio::test]
async fn create_with_reserved_name_suffixes_stale_reservation() {
let dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let first = mgr
.create(
"squatter".into(),
Some(PathBuf::from("/tmp/wt1")),
Some("stale".into()),
None,
None,
None,
)
.await
.expect("first create");
assert_eq!(first.tmux_name, "tm-stale-01");
let second = mgr
.create_with_reserved_name(
super::record::ManagedSessionId::new(),
"tm-stale-01".into(),
"stale reservation".into(),
Some(PathBuf::from("/tmp/wt2")),
None,
None,
None,
Default::default(),
false,
false,
)
.await
.expect("a stale reservation must auto-suffix, never collide");
assert_eq!(
second.tmux_name, "tm-stale-02",
"the stale reserved name must be suffixed to the next free ordinal"
);
assert!(
fake.session_exists("tm-stale-02"),
"the tmux session must be created under the SUFFIXED name"
);
assert_eq!(
mgr.get(&first.id).await.expect("get first").tmux_name,
"tm-stale-01"
);
}
#[tokio::test]
async fn dedupe_name_against_caps_suffixed_length_at_64() {
let long = "x".repeat(64); let taken: std::collections::HashSet<String> = [long.clone()].into();
let result = SessionManager::dedupe_name_against(&long, &taken);
assert!(
result.chars().count() <= 64,
"suffixed result must stay within the 64-char cap, got {} chars: {result}",
result.chars().count()
);
assert_ne!(result, long, "the taken candidate must still be suffixed");
}