Skip to main content

reposix_core/
remote.rs

1//! Remote URL parsing.
2//!
3//! Reposix git remote URLs take the form `reposix::<scheme>://<host>[:port]/projects/<slug>` —
4//! e.g. `reposix::http://localhost:7777/projects/demo`. The `reposix::` prefix is stripped by
5//! git before invoking the helper, but we accept either form for ergonomics in CLI flows.
6
7use serde::{Deserialize, Serialize};
8
9use crate::{error::Result, Error, ProjectSlug};
10
11/// A parsed remote URL pointing at a specific project on a reposix-compatible backend.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct RemoteSpec {
14    /// Origin (scheme + host + optional port), e.g. `http://localhost:7777`.
15    pub origin: String,
16    /// Project slug, e.g. `demo`.
17    pub project: ProjectSlug,
18}
19
20/// Strip the `reposix::` prefix from a URL — tolerates a double-strip
21/// edge case (`reposix::reposix::...`) defensively.
22#[must_use]
23pub fn strip_reposix_prefix(url: &str) -> &str {
24    let mut stripped = url;
25    while let Some(rest) = stripped.strip_prefix("reposix::") {
26        stripped = rest;
27    }
28    stripped
29}
30
31/// Low-level split of a reposix remote URL into `(origin, project)` string slices.
32///
33/// This is the shared splitter used by both [`parse_remote_url`] and the
34/// `git-remote-reposix` backend-dispatch parser. It strips an optional
35/// `reposix::` prefix, locates the `/projects/` separator, and returns
36/// the trimmed origin and project segments without any further
37/// validation. Callers layer their own slug rules on top.
38///
39/// # Errors
40///
41/// Returns [`Error::InvalidRemote`] if the URL has no `/projects/`
42/// segment, an empty origin, or an empty project segment.
43pub fn split_reposix_url(url: &str) -> Result<(&str, &str)> {
44    let stripped = strip_reposix_prefix(url);
45
46    let Some(idx) = stripped.find("/projects/") else {
47        return Err(Error::InvalidRemote(format!(
48            "expected `/projects/<slug>` in `{stripped}`"
49        )));
50    };
51    let origin = stripped[..idx].trim_end_matches('/');
52    if origin.is_empty() {
53        return Err(Error::InvalidRemote("empty origin".into()));
54    }
55    let tail = &stripped[idx + "/projects/".len()..];
56    let project = tail.trim_end_matches('/');
57    if project.is_empty() {
58        return Err(Error::InvalidRemote(format!(
59            "empty project segment in `{stripped}`"
60        )));
61    }
62    Ok((origin, project))
63}
64
65/// Parse a reposix remote URL.
66///
67/// # Errors
68/// Returns [`Error::InvalidRemote`] if the URL does not contain a `/projects/<slug>` path or if
69/// the slug fails [`ProjectSlug::parse`] validation.
70pub fn parse_remote_url(url: &str) -> Result<RemoteSpec> {
71    let (origin, project_tail) = split_reposix_url(url)?;
72    // Older callers expect the project to be a single bare slug; if the
73    // tail contains a `/` (e.g. GitHub's `owner/repo`), only the leading
74    // segment is taken and validated as a `ProjectSlug`. Backends that
75    // need the full path-bearing form must use the lower-level
76    // `split_reposix_url` directly.
77    let slug_str = project_tail.split('/').next().unwrap_or("");
78    let project = ProjectSlug::parse(slug_str)
79        .ok_or_else(|| Error::InvalidRemote(format!("invalid project slug: `{slug_str}`")))?;
80    Ok(RemoteSpec {
81        origin: origin.to_owned(),
82        project,
83    })
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn parses_with_prefix() {
92        let r = parse_remote_url("reposix::http://localhost:7777/projects/demo").unwrap();
93        assert_eq!(r.origin, "http://localhost:7777");
94        assert_eq!(r.project.as_str(), "demo");
95    }
96
97    #[test]
98    fn parses_without_prefix() {
99        let r = parse_remote_url("https://api.example.com/projects/PROJ-A").unwrap();
100        assert_eq!(r.origin, "https://api.example.com");
101        assert_eq!(r.project.as_str(), "PROJ-A");
102    }
103
104    #[test]
105    fn rejects_path_traversal_slug() {
106        assert!(parse_remote_url("http://x/projects/..").is_err());
107    }
108}