use std::path::Path;
use crate::slug::slugify_string;
pub const PALACE_OVERRIDE_ENV: &str = "TRUSTY_MEMORY_PALACE";
pub const PALACE_ID_MAX_LEN: usize = 63;
pub fn palace_id_is_valid(slug: &str) -> bool {
if slug.is_empty() || slug.len() > PALACE_ID_MAX_LEN {
return false;
}
slug.bytes()
.enumerate()
.all(|(i, b)| b.is_ascii_lowercase() || b.is_ascii_digit() || (i > 0 && b == b'-'))
}
const CLAMP_HASH_HEX: usize = 8;
pub fn clamp_palace_id(slug: &str) -> String {
if slug.len() <= PALACE_ID_MAX_LEN {
return slug.to_string();
}
let budget = PALACE_ID_MAX_LEN - CLAMP_HASH_HEX - 1;
let mut prefix = String::with_capacity(budget);
for c in slug.chars() {
if prefix.len() + c.len_utf8() > budget {
break;
}
prefix.push(c);
}
while prefix.ends_with('-') {
prefix.pop();
}
let suffix = format!("{:08x}", (fnv1a_64(slug.as_bytes()) >> 32) as u32);
if prefix.is_empty() {
suffix
} else {
format!("{prefix}-{suffix}")
}
}
fn fnv1a_64(bytes: &[u8]) -> u64 {
const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
bytes
.iter()
.fold(OFFSET_BASIS, |h, b| (h ^ u64::from(*b)).wrapping_mul(PRIME))
}
pub fn palace_override_from_env() -> Option<String> {
match std::env::var(PALACE_OVERRIDE_ENV) {
Ok(v) if !v.trim().is_empty() => Some(v),
_ => None,
}
}
pub fn owner_repo_from_git_remote(url: &str) -> Option<String> {
let (owner_slug, repo_slug) = parse_owner_repo_slugs(url)?;
Some(clamp_palace_id(&match owner_slug {
Some(owner) => format!("{owner}-{repo_slug}"),
None => repo_slug,
}))
}
pub fn repo_slug_from_git_remote(url: &str) -> Option<String> {
parse_owner_repo_slugs(url).map(|(_, repo)| clamp_palace_id(&repo))
}
fn parse_owner_repo_slugs(url: &str) -> Option<(Option<String>, String)> {
let trimmed = url.trim();
if trimmed.is_empty() {
return None;
}
let without_scheme = strip_scheme(trimmed);
let path = host_relative_path(without_scheme);
let path = path.trim_end_matches('/');
let path = path.strip_suffix(".git").unwrap_or(path);
let path = path.trim_end_matches('/');
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
return None;
}
let (owner, repo) = match segments.as_slice() {
[.., owner, repo] => (Some(*owner), *repo),
[repo] => (None, *repo),
_ => return None,
};
let repo_slug = slugify_string(repo);
if repo_slug.is_empty() {
return None;
}
let owner_slug = owner.map(slugify_string).filter(|s| !s.is_empty());
Some((owner_slug, repo_slug))
}
fn strip_scheme(url: &str) -> &str {
match url.find("://") {
Some(idx) => &url[idx + 3..],
None => url,
}
}
fn host_relative_path(locator: &str) -> &str {
let colon = locator.find(':');
let slash = locator.find('/');
match (colon, slash) {
(Some(c), maybe_slash) if maybe_slash.is_none_or(|s| c < s) => {
let after_colon = &locator[c + 1..];
let port_end = after_colon.find('/').unwrap_or(after_colon.len());
let potential_port = &after_colon[..port_end];
if !potential_port.is_empty() && potential_port.bytes().all(|b| b.is_ascii_digit()) {
match after_colon.find('/') {
Some(s) => &after_colon[s + 1..],
None => "",
}
} else {
after_colon
}
}
(_, Some(s)) => &locator[s + 1..],
_ => locator,
}
}
pub fn parent_dir_slug(root: &Path) -> Option<String> {
let leaf = root.file_name().and_then(|s| s.to_str())?;
let leaf_slug = slugify_string(leaf);
if leaf_slug.is_empty() {
return None;
}
let parent_slug = root
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.map(slugify_string)
.filter(|s| !s.is_empty());
Some(clamp_palace_id(&match parent_slug {
Some(parent) => format!("{parent}-{leaf_slug}"),
None => leaf_slug,
}))
}
pub fn derive_palace_id(
project_root: &Path,
git_remote: Option<&str>,
override_value: Option<&str>,
) -> Option<String> {
if let Some(slug) = override_value.map(slugify_string).filter(|s| !s.is_empty()) {
return Some(clamp_palace_id(&slug));
}
if let Some(slug) = git_remote.and_then(owner_repo_from_git_remote) {
return Some(slug);
}
parent_dir_slug(project_root)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
#[test]
fn git_ssh_github() {
assert_eq!(
owner_repo_from_git_remote("git@github.com:bobmatnyc/trusty-tools.git").as_deref(),
Some("bobmatnyc-trusty-tools")
);
}
#[test]
fn git_https_github_with_and_without_dot_git() {
assert_eq!(
owner_repo_from_git_remote("https://github.com/bobmatnyc/trusty-tools.git").as_deref(),
Some("bobmatnyc-trusty-tools")
);
assert_eq!(
owner_repo_from_git_remote("https://github.com/bobmatnyc/trusty-tools").as_deref(),
Some("bobmatnyc-trusty-tools")
);
}
#[test]
fn git_non_github_host() {
assert_eq!(
owner_repo_from_git_remote("git@gitlab.example.com:acme/Cool_App.git").as_deref(),
Some("acme-cool-app")
);
assert_eq!(
owner_repo_from_git_remote("https://gitlab.example.com/acme/Cool_App").as_deref(),
Some("acme-cool-app")
);
}
#[test]
fn git_self_hosted_with_port() {
assert_eq!(
owner_repo_from_git_remote("https://git.company.com:8080/repo.git").as_deref(),
Some("repo")
);
assert_eq!(
owner_repo_from_git_remote("https://git.company.com:8080/owner/repo.git").as_deref(),
Some("owner-repo")
);
assert_eq!(
owner_repo_from_git_remote("https://git.company.com:8080/owner/repo/").as_deref(),
Some("owner-repo")
);
assert_eq!(
owner_repo_from_git_remote("git@git.company.com:owner/repo.git").as_deref(),
Some("owner-repo")
);
}
#[test]
fn git_trailing_slash() {
assert_eq!(
owner_repo_from_git_remote("https://github.com/bobmatnyc/trusty-tools/").as_deref(),
Some("bobmatnyc-trusty-tools")
);
}
#[test]
fn git_nested_group_takes_last_two() {
assert_eq!(
owner_repo_from_git_remote("https://gitlab.com/acme/team/widget.git").as_deref(),
Some("team-widget")
);
}
#[test]
fn git_repo_only() {
assert_eq!(
owner_repo_from_git_remote("git@host:repo.git").as_deref(),
Some("repo")
);
}
#[test]
fn git_empty_returns_none() {
assert_eq!(owner_repo_from_git_remote(""), None);
assert_eq!(owner_repo_from_git_remote(" "), None);
assert_eq!(owner_repo_from_git_remote("https://github.com/"), None);
}
#[test]
fn repo_slug_ssh_github() {
assert_eq!(
repo_slug_from_git_remote("git@github.com:bobmatnyc/trusty-tools.git").as_deref(),
Some("trusty-tools")
);
}
#[test]
fn repo_slug_https_with_owner() {
assert_eq!(
repo_slug_from_git_remote("https://github.com/bobmatnyc/trusty-tools").as_deref(),
Some("trusty-tools")
);
assert_eq!(
repo_slug_from_git_remote("https://gitlab.com/acme/team/cool-widget.git").as_deref(),
Some("cool-widget")
);
}
#[test]
fn repo_slug_repo_only() {
assert_eq!(
repo_slug_from_git_remote("git@host:repo.git").as_deref(),
Some("repo")
);
}
#[test]
fn repo_slug_empty_returns_none() {
assert_eq!(repo_slug_from_git_remote(""), None);
assert_eq!(repo_slug_from_git_remote("https://github.com/"), None);
}
#[test]
fn parent_dir_two_components() {
assert_eq!(
parent_dir_slug(Path::new("/Users/bob/Projects/trusty-tools")).as_deref(),
Some("projects-trusty-tools")
);
}
#[test]
fn parent_dir_normalises_case_and_underscores() {
assert_eq!(
parent_dir_slug(Path::new("/x/My_Org/Cool_App")).as_deref(),
Some("my-org-cool-app")
);
}
#[test]
fn parent_dir_single_component() {
assert_eq!(parent_dir_slug(Path::new("/solo")).as_deref(), Some("solo"));
}
#[test]
fn parent_dir_root_returns_none() {
assert_eq!(parent_dir_slug(Path::new("/")), None);
}
#[test]
fn override_env_wins_over_git() {
let root = PathBuf::from("/Users/bob/Projects/trusty-tools");
let got = derive_palace_id(
&root,
Some("git@github.com:bobmatnyc/trusty-tools.git"),
Some("my-override"),
);
assert_eq!(got.as_deref(), Some("my-override"));
}
#[test]
fn env_override_is_slugified() {
let root = PathBuf::from("/x/y");
let got = derive_palace_id(&root, None, Some("My Project_Name"));
assert_eq!(got.as_deref(), Some("my-project-name"));
}
#[test]
fn empty_override_falls_through_to_git() {
let root = PathBuf::from("/x/y");
let got = derive_palace_id(&root, Some("git@github.com:acme/widget.git"), Some(" "));
assert_eq!(got.as_deref(), Some("acme-widget"));
}
#[test]
fn git_used_when_no_override() {
let root = PathBuf::from("/some/checkout-dir");
let got = derive_palace_id(
&root,
Some("https://github.com/bobmatnyc/trusty-tools.git"),
None,
);
assert_eq!(got.as_deref(), Some("bobmatnyc-trusty-tools"));
}
#[test]
fn falls_back_to_parent_dir() {
let root = PathBuf::from("/Users/bob/Projects/trusty-tools");
assert_eq!(
derive_palace_id(&root, None, None).as_deref(),
Some("projects-trusty-tools")
);
assert_eq!(
derive_palace_id(&root, Some(""), None).as_deref(),
Some("projects-trusty-tools")
);
}
#[test]
fn all_empty_returns_none() {
assert_eq!(
derive_palace_id(Path::new("/"), Some(""), Some(" ")),
None
);
}
#[test]
fn palace_id_is_valid_accepts_and_rejects() {
assert!(palace_id_is_valid("bobmatnyc-trusty-tools"));
assert!(palace_id_is_valid("a"));
assert!(palace_id_is_valid("9"));
assert!(palace_id_is_valid(&"a".repeat(PALACE_ID_MAX_LEN)));
assert!(!palace_id_is_valid(""));
assert!(!palace_id_is_valid(&"a".repeat(PALACE_ID_MAX_LEN + 1)));
assert!(!palace_id_is_valid("-leading-hyphen"));
assert!(!palace_id_is_valid("UPPER"));
assert!(!palace_id_is_valid("has_underscore"));
assert!(!palace_id_is_valid("has space"));
assert!(!palace_id_is_valid("../traversal"));
}
#[test]
fn fnv1a_64_matches_published_vectors() {
assert_eq!(fnv1a_64(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a_64(b"a"), 0xaf63_dc4c_8601_ec8c);
assert_eq!(fnv1a_64(b"foobar"), 0x8594_4171_f739_67e8);
}
#[test]
fn short_ids_pass_through() {
assert_eq!(
clamp_palace_id("bobmatnyc-trusty-tools"),
"bobmatnyc-trusty-tools"
);
let exactly_at_limit = "a".repeat(PALACE_ID_MAX_LEN);
assert_eq!(clamp_palace_id(&exactly_at_limit), exactly_at_limit);
}
#[test]
fn long_ids_are_clamped_to_the_limit() {
let long = "a".repeat(PALACE_ID_MAX_LEN + 1);
let clamped = clamp_palace_id(&long);
assert!(
palace_id_is_valid(&clamped),
"clamped id must pass the daemon gate, got {clamped:?}"
);
let hyphen_at_the_cut = format!("{}{}", "b".repeat(53), "-".repeat(40));
let clamped = clamp_palace_id(&hyphen_at_the_cut);
assert!(palace_id_is_valid(&clamped), "got {clamped:?}");
assert!(!clamped.contains("--"), "got {clamped:?}");
}
#[test]
fn clamped_ids_with_a_shared_prefix_stay_distinct() {
let shared = "acme-organisation-with-a-very-long-name-indeed-limited-";
let a = clamp_palace_id(&format!("{shared}first-service"));
let b = clamp_palace_id(&format!("{shared}second-service"));
assert_ne!(a, b, "distinct projects must not share a palace id");
assert!(palace_id_is_valid(&a), "got {a:?}");
assert!(palace_id_is_valid(&b), "got {b:?}");
assert_eq!(a, clamp_palace_id(&format!("{shared}first-service")));
}
#[test]
fn derived_ids_always_pass_validation() {
let names = [
"trusty-tools",
"Trusty_Tools",
"a",
"9lives",
"My Cool Project",
" --weird__name-- ",
"project.git",
&"very-long-repository-name-that-nobody-would-type-on-purpose".repeat(3),
&"x".repeat(200),
&"a-".repeat(80),
"проект-кириллица",
"漢字-project",
"café-service",
];
let remote_shapes: [fn(&str) -> String; 4] = [
|n| format!("git@github.com:{n}/{n}.git"),
|n| format!("https://github.com/some-owner/{n}"),
|n| format!("https://git.company.com:8080/{n}/{n}.git"),
|n| format!("git@host:{n}.git"),
];
for name in names {
let root = PathBuf::from(format!("/Users/bob/{name}/{name}"));
if let Some(id) = derive_palace_id(&root, None, None) {
assert!(palace_id_is_valid(&id), "parent/dir {name:?} -> {id:?}");
}
if let Some(id) = derive_palace_id(&root, None, Some(name)) {
assert!(palace_id_is_valid(&id), "override {name:?} -> {id:?}");
}
for shape in remote_shapes {
let remote = shape(name);
if let Some(id) = derive_palace_id(&root, Some(&remote), None) {
assert!(palace_id_is_valid(&id), "remote {remote:?} -> {id:?}");
}
if let Some(id) = owner_repo_from_git_remote(&remote) {
assert!(palace_id_is_valid(&id), "owner_repo {remote:?} -> {id:?}");
}
if let Some(id) = repo_slug_from_git_remote(&remote) {
assert!(palace_id_is_valid(&id), "repo_slug {remote:?} -> {id:?}");
}
}
}
}
}