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}
16
17impl RepoIdentity {
18    /// Build an identity from a git remote URL, normalizing the common
19    /// transports to `host/path` form. Returns `None` if no host/path can be
20    /// recovered (callers fall back to [`RepoIdentity::Local`]).
21    ///
22    /// ```text
23    /// git@github.com:org/repo.git        -> github.com/org/repo
24    /// https://github.com/org/repo.git    -> github.com/org/repo
25    /// ssh://git@github.com/org/repo      -> github.com/org/repo
26    /// ```
27    pub fn from_remote_url(url: &str) -> Option<RepoIdentity> {
28        normalize_remote(url).map(RepoIdentity::Remote)
29    }
30
31    /// Build a `local:` identity from an absolute path.
32    pub fn local(path: &str) -> RepoIdentity {
33        RepoIdentity::Local(path.to_string())
34    }
35}
36
37impl fmt::Display for RepoIdentity {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            RepoIdentity::Remote(s) => f.write_str(s),
41            RepoIdentity::Local(p) => write!(f, "local:{p}"),
42        }
43    }
44}
45
46/// Reduce a git remote URL to canonical `host/path` (no scheme, user, port,
47/// or trailing `.git`).
48fn normalize_remote(url: &str) -> Option<String> {
49    let url = url.trim();
50    if url.is_empty() {
51        return None;
52    }
53
54    // scp-like syntax: [user@]host:path
55    let rest = if let Some(stripped) = strip_scheme(url) {
56        // scheme://[user@]host[:port]/path
57        stripped
58    } else if let Some((host_part, path)) = url.split_once(':') {
59        // git@github.com:org/repo.git
60        let host = host_part.rsplit('@').next().unwrap_or(host_part);
61        return assemble(host, path);
62    } else {
63        return None;
64    };
65
66    let (authority, path) = rest.split_once('/')?;
67    let host_with_user = authority;
68    let host = host_with_user.rsplit('@').next().unwrap_or(host_with_user);
69    let host = host.split(':').next().unwrap_or(host); // drop :port
70    assemble(host, path)
71}
72
73/// Strip a known scheme prefix, returning the remainder (`authority/path`).
74fn strip_scheme(url: &str) -> Option<&str> {
75    for scheme in ["https://", "http://", "ssh://", "git://"] {
76        if let Some(rest) = url.strip_prefix(scheme) {
77            return Some(rest);
78        }
79    }
80    None
81}
82
83fn assemble(host: &str, path: &str) -> Option<String> {
84    let host = host.trim().trim_matches('/');
85    let path = path.trim().trim_matches('/');
86    let path = path.strip_suffix(".git").unwrap_or(path);
87    if host.is_empty() || path.is_empty() {
88        return None;
89    }
90    Some(format!("{host}/{path}"))
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn normalizes_scp_syntax() {
99        assert_eq!(
100            RepoIdentity::from_remote_url("git@github.com:dpep/rq.git"),
101            Some(RepoIdentity::Remote("github.com/dpep/rq".into()))
102        );
103    }
104
105    #[test]
106    fn normalizes_https() {
107        assert_eq!(
108            RepoIdentity::from_remote_url("https://github.com/dpep/rq.git"),
109            Some(RepoIdentity::Remote("github.com/dpep/rq".into()))
110        );
111    }
112
113    #[test]
114    fn normalizes_ssh_with_user_and_port() {
115        assert_eq!(
116            RepoIdentity::from_remote_url("ssh://git@github.com:22/dpep/rq"),
117            Some(RepoIdentity::Remote("github.com/dpep/rq".into()))
118        );
119    }
120
121    #[test]
122    fn forks_and_clones_share_identity() {
123        let a = RepoIdentity::from_remote_url("git@github.com:dpep/rq.git");
124        let b = RepoIdentity::from_remote_url("https://github.com/dpep/rq");
125        assert_eq!(a, b);
126    }
127
128    #[test]
129    fn empty_and_garbage_return_none() {
130        assert_eq!(RepoIdentity::from_remote_url(""), None);
131        assert_eq!(RepoIdentity::from_remote_url("not-a-url"), None);
132    }
133
134    #[test]
135    fn display_renders_each_variant() {
136        assert_eq!(
137            RepoIdentity::Remote("github.com/dpep/rq".into()).to_string(),
138            "github.com/dpep/rq"
139        );
140        assert_eq!(
141            RepoIdentity::local("/home/dpep/rq").to_string(),
142            "local:/home/dpep/rq"
143        );
144    }
145}