use serde::{Deserialize, Serialize};
use crate::{error::Result, Error, ProjectSlug};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteSpec {
pub origin: String,
pub project: ProjectSlug,
}
#[must_use]
pub fn strip_reposix_prefix(url: &str) -> &str {
let mut stripped = url;
while let Some(rest) = stripped.strip_prefix("reposix::") {
stripped = rest;
}
stripped
}
pub fn split_reposix_url(url: &str) -> Result<(&str, &str)> {
let stripped = strip_reposix_prefix(url);
let Some(idx) = stripped.find("/projects/") else {
return Err(Error::InvalidRemote(format!(
"expected `/projects/<slug>` in `{stripped}`"
)));
};
let origin = stripped[..idx].trim_end_matches('/');
if origin.is_empty() {
return Err(Error::InvalidRemote("empty origin".into()));
}
let tail = &stripped[idx + "/projects/".len()..];
let project = tail.trim_end_matches('/');
if project.is_empty() {
return Err(Error::InvalidRemote(format!(
"empty project segment in `{stripped}`"
)));
}
Ok((origin, project))
}
pub fn parse_remote_url(url: &str) -> Result<RemoteSpec> {
let (origin, project_tail) = split_reposix_url(url)?;
let slug_str = project_tail.split('/').next().unwrap_or("");
let project = ProjectSlug::parse(slug_str)
.ok_or_else(|| Error::InvalidRemote(format!("invalid project slug: `{slug_str}`")))?;
Ok(RemoteSpec {
origin: origin.to_owned(),
project,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_with_prefix() {
let r = parse_remote_url("reposix::http://localhost:7777/projects/demo").unwrap();
assert_eq!(r.origin, "http://localhost:7777");
assert_eq!(r.project.as_str(), "demo");
}
#[test]
fn parses_without_prefix() {
let r = parse_remote_url("https://api.example.com/projects/PROJ-A").unwrap();
assert_eq!(r.origin, "https://api.example.com");
assert_eq!(r.project.as_str(), "PROJ-A");
}
#[test]
fn rejects_path_traversal_slug() {
assert!(parse_remote_url("http://x/projects/..").is_err());
}
}