use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use uuid::Uuid;
pub const TEAMCTL_SESSION_NAMESPACE: Uuid = Uuid::from_bytes([
0x6d, 0xd6, 0xc8, 0xa3, 0x44, 0xb6, 0x4a, 0x18, 0x9b, 0x05, 0x91, 0xc1, 0xe2, 0x57, 0xfb, 0x3d,
]);
pub fn session_name(project: &str, agent: &str) -> String {
format!("teamctl:{project}:{agent}")
}
pub fn derive_session_id(project: &str, agent: &str) -> Uuid {
Uuid::new_v5(
&TEAMCTL_SESSION_NAMESPACE,
session_name(project, agent).as_bytes(),
)
}
pub fn claude_home() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".claude"))
}
pub fn freshen_session(
claude_home: &Path,
project: &str,
agent: &str,
) -> io::Result<Option<PathBuf>> {
let filename = format!("{}.jsonl", derive_session_id(project, agent));
let projects_dir = claude_home.join("projects");
let entries = match fs::read_dir(&projects_dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
for entry in entries.flatten() {
let candidate = entry.path().join(&filename);
if candidate.is_file() {
let mut bak: OsString = candidate.clone().into_os_string();
bak.push(".bak");
let bak = PathBuf::from(bak);
fs::rename(&candidate, &bak)?;
return Ok(Some(bak));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_inputs_yield_same_uuid() {
let a = derive_session_id("hello", "mgr");
let b = derive_session_id("hello", "mgr");
assert_eq!(a, b);
}
#[test]
fn different_agents_yield_different_uuids() {
let mgr = derive_session_id("hello", "mgr");
let dev = derive_session_id("hello", "dev");
assert_ne!(mgr, dev);
}
#[test]
fn different_projects_yield_different_uuids() {
let a = derive_session_id("alpha", "mgr");
let b = derive_session_id("beta", "mgr");
assert_ne!(a, b);
}
#[test]
fn namespace_constant_is_stable() {
assert_eq!(
TEAMCTL_SESSION_NAMESPACE.to_string(),
"6dd6c8a3-44b6-4a18-9b05-91c1e257fb3d"
);
}
#[test]
fn derived_uuid_is_v5_and_uses_namespace() {
let derived = derive_session_id("hello", "mgr");
let expected = Uuid::new_v5(&TEAMCTL_SESSION_NAMESPACE, b"teamctl:hello:mgr");
assert_eq!(derived, expected);
assert_eq!(derived.get_version_num(), 5);
}
#[test]
fn session_name_format_is_canonical() {
assert_eq!(session_name("hello", "mgr"), "teamctl:hello:mgr");
}
fn stage_session(claude_home: &Path, slug: &str, project: &str, agent: &str) -> PathBuf {
let dir = claude_home.join("projects").join(slug);
std::fs::create_dir_all(&dir).unwrap();
let jsonl = dir.join(format!("{}.jsonl", derive_session_id(project, agent)));
std::fs::write(&jsonl, "session-bytes").unwrap();
jsonl
}
#[test]
fn freshen_moves_existing_session_aside() {
let home = tempfile::tempdir().unwrap();
let jsonl = stage_session(home.path(), "-Users-x-proj", "hello", "mgr");
let bak = freshen_session(home.path(), "hello", "mgr").unwrap();
let bak = bak.expect("a staged session is reported moved");
assert!(!jsonl.exists(), "original JSONL is gone after freshen");
assert!(bak.exists(), "the .bak recovery copy exists");
assert_eq!(bak.extension().unwrap(), "bak");
assert!(bak.to_string_lossy().ends_with(".jsonl.bak"));
assert_eq!(std::fs::read_to_string(&bak).unwrap(), "session-bytes");
}
#[test]
fn freshen_is_noop_when_no_matching_session() {
let home = tempfile::tempdir().unwrap();
stage_session(home.path(), "-Users-x-proj", "hello", "other");
let bak = freshen_session(home.path(), "hello", "mgr").unwrap();
assert!(bak.is_none(), "no move when our UUID has no JSONL on disk");
}
#[test]
fn freshen_is_noop_when_projects_dir_absent() {
let home = tempfile::tempdir().unwrap();
let bak = freshen_session(home.path(), "hello", "mgr").unwrap();
assert!(bak.is_none());
}
#[test]
fn freshen_only_touches_the_session_jsonl() {
let home = tempfile::tempdir().unwrap();
let jsonl = stage_session(home.path(), "-Users-x-proj", "hello", "mgr");
let sibling = jsonl.with_file_name("task.md");
std::fs::write(&sibling, "durable").unwrap();
freshen_session(home.path(), "hello", "mgr")
.unwrap()
.unwrap();
assert!(!jsonl.exists());
assert_eq!(std::fs::read_to_string(&sibling).unwrap(), "durable");
}
}