use std::collections::HashSet;
use std::path::Path;
use thiserror::Error;
use uuid::Uuid;
mod dedupe;
pub use dedupe::dedupe_by_ordinal;
const ADJECTIVES: &[&str] = &[
"quiet", "brave", "silent", "swift", "calm", "bold", "bright", "clever", "gentle", "happy",
"keen", "lively", "merry", "noble", "proud", "rapid", "sharp", "sleek", "steady", "sunny",
"warm", "wise", "agile", "amber", "azure", "crisp", "deep", "eager", "fancy", "fierce",
"frosty", "golden", "humble", "icy", "jolly", "lucky", "mellow", "mighty", "misty", "nimble",
"polar", "royal", "rustic", "shiny", "snowy", "solar", "stark", "vivid", "witty", "zesty",
];
const NOUNS: &[&str] = &[
"falcon", "river", "crane", "otter", "maple", "comet", "harbor", "ember", "willow", "canyon",
"meadow", "boulder", "cedar", "delta", "fjord", "glade", "harvest", "island", "jungle",
"lagoon", "marsh", "nebula", "oasis", "pine", "quartz", "ridge", "summit", "tundra", "valley",
"anchor", "badger", "cobra", "dolphin", "eagle", "ferret", "gibbon", "heron", "ibis", "jaguar",
"koala", "lynx", "marten", "newt", "osprey", "puffin", "raven", "sparrow", "tiger", "viper",
"walrus",
];
pub const PREFIX: &str = "tm-";
pub const LEGACY_PREFIX_TMPM: &str = "tmpm-";
pub const LEGACY_PREFIX_FULL: &str = "trusty-mpm-";
pub fn is_managed_session_name(name: &str) -> bool {
name.starts_with(PREFIX)
|| name.starts_with(LEGACY_PREFIX_TMPM)
|| name.starts_with(LEGACY_PREFIX_FULL)
}
pub fn strip_managed_prefix(name: &str) -> &str {
name.strip_prefix(LEGACY_PREFIX_TMPM)
.or_else(|| name.strip_prefix(PREFIX))
.or_else(|| name.strip_prefix(LEGACY_PREFIX_FULL))
.unwrap_or(name)
}
pub fn name_from_uuid(uuid: &Uuid) -> String {
let value = uuid.as_u128();
let adj = ADJECTIVES[(value % ADJECTIVES.len() as u128) as usize];
let noun = NOUNS[((value / ADJECTIVES.len() as u128) % NOUNS.len() as u128) as usize];
format!("{PREFIX}{adj}-{noun}")
}
const MAX_FOLDER_LEN: usize = 20;
const MAX_REPO_SLUG_LEN: usize = 24;
const DIR_FALLBACK: &str = "tm-session";
const LOCAL_LEAF_FALLBACK: &str = "local";
const MAX_SERIAL: u8 = 99;
fn sanitize_slug(input: &str, max_len: usize) -> String {
let mut slug = String::with_capacity(input.len());
let mut prev_dash = true; for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
slug.extend(ch.to_lowercase());
prev_dash = false;
} else if !prev_dash {
slug.push('-');
prev_dash = true;
}
}
while slug.ends_with('-') {
slug.pop();
}
if slug.len() > max_len {
slug.truncate(max_len);
while slug.ends_with('-') {
slug.pop();
}
}
slug
}
fn slug_project(name: &str) -> String {
sanitize_slug(name, MAX_REPO_SLUG_LEN)
}
pub fn leaf_slug_from_dir(path: &Path) -> String {
let basename = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
sanitize_slug(&basename, MAX_FOLDER_LEN)
}
pub fn name_from_dir(path: &Path) -> String {
let slug = leaf_slug_from_dir(path);
if slug.is_empty() {
DIR_FALLBACK.to_string()
} else {
format!("{PREFIX}{slug}")
}
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum SessionNameError {
#[error(
"no free session-name serial (01-99) for project '{0}' — decommission or stop an \
existing `tm-{0}-NN` session before creating another"
)]
SerialExhausted(String),
}
fn parse_serial_for_leaf(name: &str, leaf: &str) -> Option<u8> {
let rest = name.strip_prefix(PREFIX)?;
let rest = rest.strip_prefix(leaf)?;
let digits = rest.strip_prefix('-')?;
if digits.len() != 2 || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let serial = digits.parse::<u8>().ok()?;
if serial == 0 { None } else { Some(serial) }
}
pub fn allocate_serial(leaf: &str, existing_names: &[String]) -> Result<u8, SessionNameError> {
let taken: HashSet<u8> = existing_names
.iter()
.filter_map(|n| parse_serial_for_leaf(n, leaf))
.collect();
(1..=MAX_SERIAL)
.find(|n| !taken.contains(n))
.ok_or_else(|| SessionNameError::SerialExhausted(leaf.to_string()))
}
pub fn build_session_name(
leaf: &str,
existing_names: &[String],
) -> Result<String, SessionNameError> {
let slug = slug_project(leaf);
let slug = if slug.is_empty() {
LOCAL_LEAF_FALLBACK.to_string()
} else {
slug
};
let serial = allocate_serial(&slug, existing_names)?;
Ok(format!("{PREFIX}{slug}-{serial:02}"))
}
pub fn build_managed_session_name(
project: Option<&str>,
cwd: &Path,
existing_names: &[String],
) -> Result<String, SessionNameError> {
debug_assert!(
project.is_none_or(|p| !p.contains('/')),
"build_managed_session_name: pass the repo segment only, not owner/repo — got {project:?}"
);
let leaf = match project.map(slug_project) {
Some(slug) if !slug.is_empty() => slug,
_ => leaf_slug_from_dir(cwd),
};
build_session_name(&leaf, existing_names)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic() {
let id = Uuid::parse_str("367c6c51-1025-419c-b6d6-be9a753e8914").unwrap();
assert_eq!(name_from_uuid(&id), name_from_uuid(&id));
}
#[test]
fn managed_prefix_matches() {
assert!(is_managed_session_name("tm-brave-otter"));
assert!(is_managed_session_name("trusty-mpm-deadbeef"));
let id = Uuid::parse_str("367c6c51-1025-419c-b6d6-be9a753e8914").unwrap();
assert!(is_managed_session_name(&name_from_uuid(&id)));
assert!(is_managed_session_name(
&build_managed_session_name(Some("trusty-tools"), Path::new("/tmp/x"), &[]).unwrap()
));
assert!(is_managed_session_name(
&build_managed_session_name(None, Path::new("/"), &[]).unwrap()
));
}
#[test]
fn managed_prefix_matches_legacy_tmpm() {
assert!(is_managed_session_name("tmpm-brave-otter"));
assert!(is_managed_session_name("tmpm-trusty-tools-abc12345"));
}
#[test]
fn managed_prefix_rejects_foreign() {
assert!(!is_managed_session_name("work"));
assert!(!is_managed_session_name("0"));
assert!(!is_managed_session_name("my-tm-thing"));
assert!(!is_managed_session_name("my-tmpm-thing"));
assert!(!is_managed_session_name("not-trusty-mpm-x"));
}
#[test]
fn strip_managed_prefix_strips_current() {
assert_eq!(
strip_managed_prefix("tm-trusty-tools-01"),
"trusty-tools-01"
);
}
#[test]
fn strip_managed_prefix_strips_legacy() {
assert_eq!(
strip_managed_prefix("tmpm-aipowerranking"),
"aipowerranking"
);
assert_eq!(strip_managed_prefix("trusty-mpm-abc123"), "abc123");
}
#[test]
fn strip_managed_prefix_passthrough() {
assert_eq!(strip_managed_prefix("frontend"), "frontend");
}
#[test]
fn strip_managed_prefix_no_partial_overlap_with_legacy_tmpm() {
assert_eq!(strip_managed_prefix("tmpm-quiet-falcon"), "quiet-falcon");
assert_ne!(strip_managed_prefix("tmpm-quiet-falcon"), "pm-quiet-falcon");
assert_ne!(strip_managed_prefix("tmpm-quiet-falcon"), "-quiet-falcon");
}
#[test]
fn managed_prefix_matches_legacy_tmpm_quiet_falcon() {
assert!(is_managed_session_name("tmpm-quiet-falcon"));
}
#[test]
fn format_matches() {
for _ in 0..200 {
let name = name_from_uuid(&Uuid::new_v4());
let rest = name.strip_prefix("tm-").expect("tm- prefix");
let mut parts = rest.split('-');
let adj = parts.next().expect("adjective");
let noun = parts.next().expect("noun");
assert!(parts.next().is_none(), "exactly two words: {name}");
assert!(ADJECTIVES.contains(&adj), "adjective from list: {adj}");
assert!(NOUNS.contains(&noun), "noun from list: {noun}");
assert!(name.len() <= 23, "name under 23 chars: {name}");
}
}
#[test]
fn distinct_uuids_distinct_names() {
let mut names = std::collections::HashSet::new();
for _ in 0..500 {
names.insert(name_from_uuid(&Uuid::new_v4()));
}
assert!(
names.len() > 400,
"expected mostly-distinct names: {}",
names.len()
);
}
#[test]
fn known_uuid_is_stable() {
assert_eq!(name_from_uuid(&Uuid::nil()), "tm-quiet-falcon");
}
#[test]
fn name_from_dir_basic() {
assert_eq!(
name_from_dir(Path::new("/Users/masa/Projects/trusty-mpm")),
"tm-trusty-mpm"
);
}
#[test]
fn name_from_dir_sanitizes() {
assert_eq!(
name_from_dir(Path::new("/home/foo/my project")),
"tm-my-project"
);
assert_eq!(name_from_dir(Path::new("/srv/my_api_v2")), "tm-my-api-v2");
assert_eq!(name_from_dir(Path::new("/x/MixedCase")), "tm-mixedcase");
}
#[test]
fn name_from_dir_collapses_and_trims() {
assert_eq!(
name_from_dir(Path::new("/x/--weird__ name--")),
"tm-weird-name"
);
assert_eq!(name_from_dir(Path::new("/x/...dots...")), "tm-dots");
}
#[test]
fn name_from_dir_truncates() {
let name = name_from_dir(Path::new("/x/this-is-a-very-long-folder-name"));
let slug = name.strip_prefix("tm-").expect("tm- prefix");
assert!(slug.len() <= 20, "slug under 20 chars: {slug}");
assert!(!slug.ends_with('-'), "no trailing dash: {slug}");
assert_eq!(name, "tm-this-is-a-very-long");
}
#[test]
fn name_from_dir_empty_fallback() {
assert_eq!(name_from_dir(Path::new("/")), "tm-session");
assert_eq!(name_from_dir(Path::new("/x/----")), "tm-session");
assert_eq!(name_from_dir(Path::new("")), "tm-session");
}
#[test]
fn leaf_slug_from_dir_basic() {
assert_eq!(
leaf_slug_from_dir(Path::new("/Users/masa/Projects/trusty-tools")),
"trusty-tools"
);
}
#[test]
fn leaf_slug_from_dir_sanitizes() {
assert_eq!(
leaf_slug_from_dir(Path::new("/x/My_Project.v2")),
"my-project-v2"
);
}
#[test]
fn leaf_slug_from_dir_empty_for_root() {
assert_eq!(leaf_slug_from_dir(Path::new("/")), "");
}
#[test]
fn slug_project_sanitizes_dots_colons_uppercase() {
assert_eq!(slug_project("My.Repo:v2"), "my-repo-v2");
assert_eq!(slug_project("TRUSTY_TOOLS"), "trusty-tools");
assert_eq!(slug_project("...repo..."), "repo");
}
#[test]
fn slug_project_collapses_and_trims() {
assert_eq!(slug_project("my__repo--v2"), "my-repo-v2");
assert_eq!(
slug_project("--leading-and-trailing--"),
"leading-and-trailing"
);
}
#[test]
fn slug_project_truncates() {
let long = "this-is-a-very-long-repository-name-that-exceeds-the-cap";
let slug = slug_project(long);
assert!(
slug.len() <= MAX_REPO_SLUG_LEN,
"slug must be ≤ {MAX_REPO_SLUG_LEN} chars: {slug}"
);
assert!(
!slug.ends_with('-'),
"no trailing dash after truncation: {slug}"
);
}
#[test]
fn allocate_serial_starts_at_one() {
assert_eq!(allocate_serial("trusty-tools", &[]).unwrap(), 1);
}
#[test]
fn allocate_serial_reuses_gap() {
let existing = vec![
"tm-trusty-tools-01".to_string(),
"tm-trusty-tools-03".to_string(),
];
assert_eq!(allocate_serial("trusty-tools", &existing).unwrap(), 2);
}
#[test]
fn allocate_serial_ignores_other_leaf() {
let existing = vec!["tm-other-project-01".to_string()];
assert_eq!(allocate_serial("trusty-tools", &existing).unwrap(), 1);
}
#[test]
fn parse_serial_matches_own_leaf() {
assert_eq!(
parse_serial_for_leaf("tm-trusty-tools-07", "trusty-tools"),
Some(7)
);
}
#[test]
fn parse_serial_rejects_other_leaf() {
assert_eq!(
parse_serial_for_leaf("tm-other-project-01", "trusty-tools"),
None
);
}
#[test]
fn parse_serial_rejects_legacy_hex_suffix() {
assert_eq!(
parse_serial_for_leaf("tmpm-trusty-tools-abc12345", "trusty-tools"),
None
);
}
#[test]
fn parse_serial_rejects_serial_00() {
assert_eq!(
parse_serial_for_leaf("tm-trusty-tools-00", "trusty-tools"),
None
);
}
#[test]
fn allocate_serial_exhausted_errors() {
let existing: Vec<String> = (1..=99u8)
.map(|n| format!("tm-trusty-tools-{n:02}"))
.collect();
let err = allocate_serial("trusty-tools", &existing).unwrap_err();
assert_eq!(
err,
SessionNameError::SerialExhausted("trusty-tools".to_string())
);
}
#[test]
fn build_session_name_first_serial() {
assert_eq!(
build_session_name("trusty-tools", &[]).unwrap(),
"tm-trusty-tools-01"
);
}
#[test]
fn build_session_name_gap_reuse() {
let existing = vec![
"tm-trusty-tools-01".to_string(),
"tm-trusty-tools-03".to_string(),
];
assert_eq!(
build_session_name("trusty-tools", &existing).unwrap(),
"tm-trusty-tools-02"
);
}
#[test]
fn build_session_name_empty_leaf_falls_back_to_local() {
assert_eq!(build_session_name("...", &[]).unwrap(), "tm-local-01");
}
#[test]
fn build_session_name_exhausted() {
let existing: Vec<String> = (1..=99u8).map(|n| format!("tm-proj-{n:02}")).collect();
assert!(matches!(
build_session_name("proj", &existing),
Err(SessionNameError::SerialExhausted(_))
));
}
#[test]
fn build_managed_session_name_github_project() {
let name =
build_managed_session_name(Some("trusty-tools"), Path::new("/tmp/x"), &[]).unwrap();
assert_eq!(name, "tm-trusty-tools-01");
}
#[test]
fn build_managed_session_name_dir_fallback() {
let name =
build_managed_session_name(None, Path::new("/Users/masa/Projects/trusty-tools"), &[])
.unwrap();
assert_eq!(name, "tm-trusty-tools-01");
}
#[test]
fn build_managed_session_name_local_fallback() {
let name = build_managed_session_name(None, Path::new("/"), &[]).unwrap();
assert_eq!(name, "tm-local-01");
}
#[test]
fn build_managed_session_name_sanitized_repo() {
let name =
build_managed_session_name(Some("My.Repo:v2"), Path::new("/tmp/x"), &[]).unwrap();
assert_eq!(name, "tm-my-repo-v2-01");
}
#[test]
fn build_managed_session_name_concurrent_sessions_increment() {
let mut existing: Vec<String> = vec![];
let n1 = build_managed_session_name(Some("trusty-tools"), Path::new("/tmp/x"), &existing)
.unwrap();
assert_eq!(n1, "tm-trusty-tools-01");
existing.push(n1);
let n2 = build_managed_session_name(Some("trusty-tools"), Path::new("/tmp/x"), &existing)
.unwrap();
assert_eq!(n2, "tm-trusty-tools-02");
existing.push(n2);
let n3 = build_managed_session_name(Some("trusty-tools"), Path::new("/tmp/x"), &existing)
.unwrap();
assert_eq!(n3, "tm-trusty-tools-03");
}
}