use anyhow::{bail, Result};
pub const MAX_OWNER_DEPTH: usize = 20;
pub fn validate(name: &str) -> Result<()> {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() || c == '_' => {}
_ => bail!("invalid repo name {name:?}"),
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) {
bail!("invalid repo name {name:?}");
}
Ok(())
}
pub fn validate_repo_path(path: &str) -> Result<()> {
let segs: Vec<&str> = path.split('/').collect();
if segs.len() < 2 || segs.len() > MAX_OWNER_DEPTH + 1 {
bail!("invalid repo name {path:?}: expected owner/name");
}
for seg in &segs {
validate(seg).map_err(|_| anyhow::anyhow!("invalid repo name {path:?}"))?;
}
if segs[..segs.len() - 1].iter().any(|s| s.to_ascii_lowercase().ends_with(".git")) {
bail!("invalid repo name {path:?}");
}
Ok(())
}
pub fn from_wire_form(raw: &str) -> Option<String> {
let raw = raw.strip_prefix('/').unwrap_or(raw);
let path = raw.strip_suffix(".git")?;
validate_repo_path(path).ok()?;
Some(path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_paths() {
assert!(validate_repo_path("alice/dotfiles").is_ok());
assert!(validate_repo_path("acme/platform/api").is_ok());
assert!(validate_repo_path("flat").is_err());
assert!(validate_repo_path("acme//api").is_err());
assert!(validate_repo_path("acme/../api").is_err());
assert!(validate_repo_path("/acme/api").is_err());
assert!(validate_repo_path("x.git/api").is_err());
}
#[test]
fn wire_forms() {
assert_eq!(from_wire_form("/acme/platform/api.git").as_deref(), Some("acme/platform/api"));
assert_eq!(from_wire_form("acme/api.git").as_deref(), Some("acme/api"));
assert_eq!(from_wire_form("acme/api"), None);
assert_eq!(from_wire_form("api.git"), None);
}
}