pub fn project_slug_from_owner_repo(value: &str) -> Option<String> {
let mut parts: Vec<&str> = value
.trim()
.trim_end_matches(".git")
.split('/')
.filter(|part| !part.trim().is_empty())
.collect();
if parts.len() >= 2 {
let repo = parts.pop().expect("repo segment");
let owner = parts.pop().expect("owner segment");
let owner = sanitize_path_label(owner, "");
let repo = sanitize_path_label(repo, "");
if owner.is_empty() || repo.is_empty() {
return None;
}
return Some(format!("{owner}__{repo}"));
}
let slug = sanitize_path_label(value, "");
if slug.is_empty() { None } else { Some(slug) }
}
pub const LOCAL_FALLBACK_SLUG_PREFIX: &str = "local__";
pub fn is_local_fallback_slug(slug: &str) -> bool {
let Some(rest) = slug.strip_prefix(LOCAL_FALLBACK_SLUG_PREFIX) else {
return false;
};
let Some((base, hash)) = rest.rsplit_once('-') else {
return false;
};
!base.is_empty()
&& hash.len() == 8
&& hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
pub fn project_slug_from_remote_url(remote: &str) -> Option<String> {
let parsed = crate::git::parse_git_remote_url(remote)?;
project_slug_from_owner_repo(&parsed.path)
}
pub fn sanitize_path_label(value: &str, fallback: &str) -> String {
let mut out = String::new();
let mut last_dash = false;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
let trimmed = out.trim_matches('-');
let mut sanitized: String = trimmed.chars().take(80).collect();
sanitized = sanitized.trim_matches('-').to_string();
if sanitized.is_empty() {
fallback.to_string()
} else {
sanitized
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn owner_repo_uses_double_underscore_and_sanitizes() {
assert_eq!(
project_slug_from_owner_repo("Sympoies/Nils CLI"),
Some("sympoies__nils-cli".to_string())
);
}
#[test]
fn nested_groups_keep_only_last_owner_segment() {
assert_eq!(
project_slug_from_owner_repo("acme/platform/backend/svc"),
Some("backend__svc".to_string())
);
}
#[test]
fn remote_url_forms_match() {
assert_eq!(
project_slug_from_remote_url("git@github.com:sympoies/nils-cli.git"),
Some("sympoies__nils-cli".to_string())
);
assert_eq!(
project_slug_from_remote_url("https://github.com/sympoies/nils-cli.git"),
Some("sympoies__nils-cli".to_string())
);
}
#[test]
fn single_segment_and_empty() {
assert_eq!(
project_slug_from_owner_repo("solo"),
Some("solo".to_string())
);
assert_eq!(project_slug_from_owner_repo(" "), None);
}
#[test]
fn recognizes_local_fallback_slug() {
assert!(is_local_fallback_slug("local__nils-cli-deadbeef"));
assert!(is_local_fallback_slug("local__repo-00000000"));
}
#[test]
fn rejects_non_local_fallback_slugs() {
assert!(!is_local_fallback_slug("sympoies__nils-cli"));
assert!(!is_local_fallback_slug("local__some-repo"));
assert!(!is_local_fallback_slug("local__widget"));
assert!(!is_local_fallback_slug("local__repo-deadbeefa")); assert!(!is_local_fallback_slug("local__repo-DEADBEEF")); assert!(!is_local_fallback_slug("local__-deadbeef")); assert!(!is_local_fallback_slug("notlocal__repo-deadbeef"));
}
#[test]
fn sanitize_collapses_and_trims() {
assert_eq!(sanitize_path_label(" My.Repo!! ", "fb"), "my-repo");
assert_eq!(sanitize_path_label("", "fb"), "fb");
assert_eq!(sanitize_path_label("***", "untitled"), "untitled");
}
}