Skip to main content

reference_query/core/
identity.rs

1//! Repository identity — the normalized name a logical project is keyed by.
2//!
3//! Two checkouts of the same project (clone, fork) should resolve to the same
4//! identity so symbols and learned behavior aggregate. See `docs/ARCHITECTURE.md`.
5
6use std::fmt;
7
8/// Normalized identity of a logical project.
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub enum RepoIdentity {
11    /// Derived from an upstream git remote, e.g. `github.com/org/repo`.
12    Remote(String),
13    /// Fallback: an absolute local path, rendered as `local:/abs/path`.
14    Local(String),
15    /// An explicit, user-provided name.
16    Named(String),
17}
18
19impl RepoIdentity {
20    /// Build an identity from a git remote URL, normalizing the common
21    /// transports to `host/path` form. Returns `None` if no host/path can be
22    /// recovered (callers fall back to [`RepoIdentity::Local`]).
23    ///
24    /// ```text
25    /// git@github.com:org/repo.git        -> github.com/org/repo
26    /// https://github.com/org/repo.git    -> github.com/org/repo
27    /// ssh://git@github.com/org/repo      -> github.com/org/repo
28    /// ```
29    pub fn from_remote_url(url: &str) -> Option<RepoIdentity> {
30        normalize_remote(url).map(RepoIdentity::Remote)
31    }
32
33    /// Build a `local:` identity from an absolute path.
34    pub fn local(path: &str) -> RepoIdentity {
35        RepoIdentity::Local(path.to_string())
36    }
37}
38
39impl fmt::Display for RepoIdentity {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            RepoIdentity::Remote(s) => f.write_str(s),
43            RepoIdentity::Local(p) => write!(f, "local:{p}"),
44            RepoIdentity::Named(n) => f.write_str(n),
45        }
46    }
47}
48
49/// Reduce a git remote URL to canonical `host/path` (no scheme, user, port,
50/// or trailing `.git`).
51fn normalize_remote(url: &str) -> Option<String> {
52    let url = url.trim();
53    if url.is_empty() {
54        return None;
55    }
56
57    // scp-like syntax: [user@]host:path
58    let rest = if let Some(stripped) = strip_scheme(url) {
59        // scheme://[user@]host[:port]/path
60        stripped
61    } else if let Some((host_part, path)) = url.split_once(':') {
62        // git@github.com:org/repo.git
63        let host = host_part.rsplit('@').next().unwrap_or(host_part);
64        return assemble(host, path);
65    } else {
66        return None;
67    };
68
69    let (authority, path) = rest.split_once('/')?;
70    let host_with_user = authority;
71    let host = host_with_user.rsplit('@').next().unwrap_or(host_with_user);
72    let host = host.split(':').next().unwrap_or(host); // drop :port
73    assemble(host, path)
74}
75
76/// Strip a known scheme prefix, returning the remainder (`authority/path`).
77fn strip_scheme(url: &str) -> Option<&str> {
78    for scheme in ["https://", "http://", "ssh://", "git://"] {
79        if let Some(rest) = url.strip_prefix(scheme) {
80            return Some(rest);
81        }
82    }
83    None
84}
85
86fn assemble(host: &str, path: &str) -> Option<String> {
87    let host = host.trim().trim_matches('/');
88    let path = path.trim().trim_matches('/');
89    let path = path.strip_suffix(".git").unwrap_or(path);
90    if host.is_empty() || path.is_empty() {
91        return None;
92    }
93    Some(format!("{host}/{path}"))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn normalizes_scp_syntax() {
102        assert_eq!(
103            RepoIdentity::from_remote_url("git@github.com:dpep/rq.git"),
104            Some(RepoIdentity::Remote("github.com/dpep/rq".into()))
105        );
106    }
107
108    #[test]
109    fn normalizes_https() {
110        assert_eq!(
111            RepoIdentity::from_remote_url("https://github.com/dpep/rq.git"),
112            Some(RepoIdentity::Remote("github.com/dpep/rq".into()))
113        );
114    }
115
116    #[test]
117    fn normalizes_ssh_with_user_and_port() {
118        assert_eq!(
119            RepoIdentity::from_remote_url("ssh://git@github.com:22/dpep/rq"),
120            Some(RepoIdentity::Remote("github.com/dpep/rq".into()))
121        );
122    }
123
124    #[test]
125    fn forks_and_clones_share_identity() {
126        let a = RepoIdentity::from_remote_url("git@github.com:dpep/rq.git");
127        let b = RepoIdentity::from_remote_url("https://github.com/dpep/rq");
128        assert_eq!(a, b);
129    }
130
131    #[test]
132    fn empty_and_garbage_return_none() {
133        assert_eq!(RepoIdentity::from_remote_url(""), None);
134        assert_eq!(RepoIdentity::from_remote_url("not-a-url"), None);
135    }
136
137    #[test]
138    fn display_renders_each_variant() {
139        assert_eq!(
140            RepoIdentity::Remote("github.com/dpep/rq".into()).to_string(),
141            "github.com/dpep/rq"
142        );
143        assert_eq!(
144            RepoIdentity::local("/home/dpep/rq").to_string(),
145            "local:/home/dpep/rq"
146        );
147        assert_eq!(RepoIdentity::Named("work".into()).to_string(), "work");
148    }
149}