lean_ctx/core/git/
repo_url.rs1#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RepoRef {
16 pub host: String,
18 pub owner: String,
20 pub repo: String,
22 pub clone_url: String,
24 pub git_ref: Option<String>,
26 pub subpath: Option<String>,
28}
29
30impl RepoRef {
31 pub fn project_path(&self) -> String {
33 format!("{}/{}", self.owner, self.repo)
34 }
35
36 pub fn cache_slug(&self) -> String {
39 format!("{}/{}/{}", self.host, self.owner, self.repo)
40 }
41}
42
43pub fn parse(url: &str) -> Option<RepoRef> {
45 let rest = url.trim().strip_prefix("https://")?;
46 let (host, path) = rest.split_once('/')?;
47 if !is_valid_host(host) {
48 return None;
49 }
50 let path = path.split(['?', '#']).next().unwrap_or("");
52 let path = path.trim_matches('/');
53 if path.is_empty() {
54 return None;
55 }
56
57 let (project_path, git_ref, subpath) = split_project_and_location(path);
58 let project_path = project_path.trim_end_matches('/').trim_end_matches(".git");
59
60 let segments: Vec<&str> = project_path.split('/').filter(|s| !s.is_empty()).collect();
61 if segments.len() < 2 {
62 return None; }
64 let repo = (*segments.last()?).to_string();
65 let owner = segments[..segments.len() - 1].join("/");
66 if owner.is_empty() || repo.is_empty() {
67 return None;
68 }
69
70 let clone_url = format!("https://{host}/{owner}/{repo}.git");
71 Some(RepoRef {
72 host: host.to_string(),
73 owner,
74 repo,
75 clone_url,
76 git_ref,
77 subpath,
78 })
79}
80
81fn split_project_and_location(path: &str) -> (String, Option<String>, Option<String>) {
84 if let Some((proj, tail)) = path.split_once("/-/") {
86 let (git_ref, subpath) = parse_location_tail(tail);
87 return (proj.to_string(), git_ref, subpath);
88 }
89
90 let segs: Vec<&str> = path.split('/').collect();
94 if segs.len() >= 4 && matches!(segs[2], "blob" | "tree" | "raw" | "src" | "commits") {
95 let project = segs[..2].join("/");
96 let tail = segs[3..].join("/");
97 let (git_ref, subpath) = parse_ref_then_path(&tail);
98 return (project, git_ref, subpath);
99 }
100
101 (path.to_string(), None, None)
102}
103
104fn parse_location_tail(tail: &str) -> (Option<String>, Option<String>) {
106 let segs: Vec<&str> = tail.split('/').filter(|s| !s.is_empty()).collect();
107 if segs.is_empty() {
108 return (None, None);
109 }
110 let after_kind = if matches!(segs[0], "blob" | "tree" | "raw") {
112 &segs[1..]
113 } else {
114 &segs[..]
115 };
116 parse_ref_then_path(&after_kind.join("/"))
117}
118
119fn parse_ref_then_path(s: &str) -> (Option<String>, Option<String>) {
123 let s = s.trim_matches('/');
124 if s.is_empty() {
125 return (None, None);
126 }
127 match s.split_once('/') {
128 Some((r, p)) if !p.is_empty() => (Some(r.to_string()), Some(p.to_string())),
129 _ => (Some(s.to_string()), None),
130 }
131}
132
133fn is_valid_host(host: &str) -> bool {
135 !host.is_empty()
136 && host.contains('.')
137 && !host.contains(' ')
138 && !host.contains('@')
139 && !host.starts_with('.')
140 && !host.ends_with('.')
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn parses_bare_repo_root() {
149 let r = parse("https://github.com/yvgude/lean-ctx").unwrap();
150 assert_eq!(r.host, "github.com");
151 assert_eq!(r.owner, "yvgude");
152 assert_eq!(r.repo, "lean-ctx");
153 assert_eq!(r.clone_url, "https://github.com/yvgude/lean-ctx.git");
154 assert_eq!(r.git_ref, None);
155 assert_eq!(r.subpath, None);
156 }
157
158 #[test]
159 fn strips_dot_git_and_trailing_slash() {
160 let r = parse("https://github.com/o/r.git/").unwrap();
161 assert_eq!(r.repo, "r");
162 assert_eq!(r.clone_url, "https://github.com/o/r.git");
163 }
164
165 #[test]
166 fn parses_github_blob_ref_and_subpath() {
167 let r = parse("https://github.com/yvgude/lean-ctx/blob/main/src/core/mod.rs").unwrap();
168 assert_eq!(r.project_path(), "yvgude/lean-ctx");
169 assert_eq!(r.git_ref.as_deref(), Some("main"));
170 assert_eq!(r.subpath.as_deref(), Some("src/core/mod.rs"));
171 assert_eq!(r.clone_url, "https://github.com/yvgude/lean-ctx.git");
172 }
173
174 #[test]
175 fn parses_github_tree_ref_only() {
176 let r = parse("https://github.com/o/r/tree/v1.2.3").unwrap();
177 assert_eq!(r.git_ref.as_deref(), Some("v1.2.3"));
178 assert_eq!(r.subpath, None);
179 }
180
181 #[test]
182 fn parses_gitlab_dash_blob_with_nested_group() {
183 let r = parse("https://gitlab.com/group/sub/proj/-/blob/main/a/b.rs").unwrap();
184 assert_eq!(r.host, "gitlab.com");
185 assert_eq!(r.owner, "group/sub");
186 assert_eq!(r.repo, "proj");
187 assert_eq!(r.git_ref.as_deref(), Some("main"));
188 assert_eq!(r.subpath.as_deref(), Some("a/b.rs"));
189 assert_eq!(r.clone_url, "https://gitlab.com/group/sub/proj.git");
190 }
191
192 #[test]
193 fn parses_gitlab_tree_dir() {
194 let r = parse("https://gitlab.com/o/r/-/tree/dev/src").unwrap();
195 assert_eq!(r.git_ref.as_deref(), Some("dev"));
196 assert_eq!(r.subpath.as_deref(), Some("src"));
197 }
198
199 #[test]
200 fn drops_query_and_fragment() {
201 let r = parse("https://github.com/o/r/blob/main/x.rs?plain=1#L10").unwrap();
202 assert_eq!(r.subpath.as_deref(), Some("x.rs"));
203 }
204
205 #[test]
206 fn rejects_non_https_and_garbage() {
207 assert!(parse("http://github.com/o/r").is_none());
208 assert!(parse("git@github.com:o/r.git").is_none());
209 assert!(parse("https://github.com/justowner").is_none());
210 assert!(parse("https://localhost/o/r").is_none()); assert!(parse("not a url").is_none());
212 }
213
214 #[test]
215 fn cache_slug_is_filesystem_nested() {
216 let r = parse("https://gitlab.com/group/sub/proj").unwrap();
217 assert_eq!(r.cache_slug(), "gitlab.com/group/sub/proj");
218 }
219}