trusty_common/github_path.rs
1//! Shared GitHub `owner/repo` path derivation (issue #1220).
2//!
3//! Why: two trusty-* subsystems need the canonical `owner/repo` identity of a
4//! project, derived from its git origin remote: trusty-mpm's managed-session
5//! workspace root (`~/trusty-mpm-projects/<owner>/<repo>/…`, #1220) and
6//! trusty-memory's palace-ID derivation (#1217). Before this module each crate
7//! re-implemented git-URL parsing; centralising it in `trusty-common` gives both
8//! one tested seam and guarantees they agree on what `<owner>/<repo>` means for
9//! a given remote. Unlike trusty-memory's `owner_repo_from_git_remote` (which
10//! collapses to a single storage-safe `owner-repo` token), this module keeps
11//! `owner` and `repo` as SEPARATE components because #1220 maps them onto two
12//! nested filesystem path segments.
13//!
14//! What: [`GithubPath`] is the parsed `{ owner, repo }` pair; [`parse_github_path`]
15//! turns a git remote URL into one (pure, no I/O); [`derive_github_path`] runs
16//! `git config --get remote.origin.url` in a directory and parses the result
17//! (the only I/O entry point). Both components are slugified for filesystem
18//! safety — lower-cased, non-alphanumerics collapsed to `-`, trailing `.git`
19//! stripped — so the result is always two clean path segments.
20//!
21//! Test: `parse_*` unit tests cover SSH/HTTPS, with/without `.git`, trailing
22//! slashes, nested groups, owner-less and empty inputs; `derive_*` is covered by
23//! the `derive_github_path_reads_origin` test against a real temp git repo.
24
25use std::path::Path;
26use std::process::Command;
27
28/// A parsed, slugified GitHub-style project identity.
29///
30/// Why: #1220's workspace-root convention nests sessions under
31/// `~/trusty-mpm-projects/<owner>/<repo>/`, so callers need the owner and the
32/// repo as two independent, filesystem-safe segments — not a single fused token.
33/// Keeping them in a struct (rather than a tuple) makes call sites self-documenting
34/// and lets the type grow (e.g. a `host` field) without breaking signatures.
35/// What: the slugified `owner` and `repo` path segments. Both are guaranteed
36/// non-empty by every constructor in this module (a parse that cannot produce a
37/// non-empty `repo` returns `None`); `owner` is `"unknown-owner"` only via the
38/// explicit owner-less fallback documented on [`parse_github_path`].
39/// Test: every `parse_*` test asserts the two fields independently.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct GithubPath {
42 /// Slugified repository owner (GitHub user or org), e.g. `bobmatnyc`.
43 pub owner: String,
44 /// Slugified repository name, e.g. `trusty-tools`.
45 pub repo: String,
46}
47
48impl GithubPath {
49 /// Render the identity as the relative path `<owner>/<repo>`.
50 ///
51 /// Why: the workspace-root builder joins this onto the configured root; a
52 /// single accessor keeps the join order (`owner` then `repo`) in one place so
53 /// callers cannot accidentally swap the segments.
54 /// What: returns `format!("{owner}/{repo}")` — always using `/` so the result
55 /// is a portable relative path that `Path::join` splits into two components.
56 /// Test: `github_path_rel_joins_owner_repo`.
57 pub fn rel_path(&self) -> String {
58 format!("{}/{}", self.owner, self.repo)
59 }
60}
61
62/// Slugify a single path component for filesystem safety.
63///
64/// Why: owners and repo names can contain mixed case, underscores, and (rarely)
65/// other punctuation; turning each into a clean kebab-case segment keeps the
66/// derived workspace path predictable and avoids surprising directory names.
67/// What: lower-cases, strips a trailing `.git`, maps `[a-z0-9]` through, collapses
68/// any run of other characters to a single `-`, and trims leading/trailing `-`.
69/// Returns an empty string when nothing survives. Mirrors trusty-memory's
70/// `slugify_string` so the two crates derive identical tokens for the same input.
71/// Test: exercised indirectly by every `parse_*` test (case, underscores,
72/// `.git`, punctuation).
73fn slugify_component(input: &str) -> String {
74 let lowered = input.trim().to_ascii_lowercase();
75 let stripped = lowered.strip_suffix(".git").unwrap_or(&lowered);
76 let mut out = String::with_capacity(stripped.len());
77 let mut prev_hyphen = false;
78 for c in stripped.chars() {
79 match c {
80 'a'..='z' | '0'..='9' => {
81 out.push(c);
82 prev_hyphen = false;
83 }
84 _ => {
85 // Collapse any run of separators/punctuation into one hyphen,
86 // never leading.
87 if !prev_hyphen && !out.is_empty() {
88 out.push('-');
89 prev_hyphen = true;
90 }
91 }
92 }
93 }
94 while out.ends_with('-') {
95 out.pop();
96 }
97 out
98}
99
100/// Strip a leading URL scheme (`https://`, `ssh://`, `git://`, …) if present.
101///
102/// Why: the path-extraction logic only needs the host-and-path portion; a
103/// scheme's `://` colon must not be mistaken for the SSH host delimiter.
104/// What: returns everything after a leading `<scheme>://`; inputs without a
105/// scheme (SSH scp-syntax like `git@host:owner/repo`) are returned unchanged.
106/// Test: covered by `parse_https_*` (scheme present) and `parse_ssh_*` (absent).
107fn strip_scheme(url: &str) -> &str {
108 match url.find("://") {
109 Some(idx) => &url[idx + 3..],
110 None => url,
111 }
112}
113
114/// Reduce a host-prefixed locator to just its path portion.
115///
116/// Why: both `host/owner/repo` (URL) and `host:owner/repo` (SSH scp-syntax)
117/// carry the host as a leading component that must be dropped before taking the
118/// trailing `owner/repo` segments.
119/// What: if an SSH `:` separator precedes the first `/`, splits on it and returns
120/// the remainder; otherwise drops the first `/`-delimited segment (the host). A
121/// locator with no separators is returned unchanged.
122/// Test: covered by `parse_ssh_github`, `parse_https_github_*`.
123fn host_relative_path(locator: &str) -> &str {
124 let colon = locator.find(':');
125 let slash = locator.find('/');
126 match (colon, slash) {
127 (Some(c), maybe_slash) if maybe_slash.is_none_or(|s| c < s) => &locator[c + 1..],
128 (_, Some(s)) => &locator[s + 1..],
129 _ => locator,
130 }
131}
132
133/// Fallback owner used when a remote exposes a repo segment but no owner.
134///
135/// Why: #1220 nests sessions two levels deep (`<owner>/<repo>`); an owner-less
136/// remote (e.g. `git@host:repo.git`) would otherwise yield a one-level path and
137/// break the convention. A stable sentinel keeps the two-segment shape.
138/// What: `"unknown-owner"` — already slug-safe.
139/// Test: `parse_repo_only_uses_unknown_owner`.
140pub const UNKNOWN_OWNER: &str = "unknown-owner";
141
142/// Parse a git remote URL into a [`GithubPath`] (`{ owner, repo }`).
143///
144/// Why: the canonical identity of a hosted project is the `owner/repo` path in
145/// its remote URL, not the local directory name. Parsing it purely (no I/O) makes
146/// every URL-shape branch deterministically unit-testable.
147/// What: handles the three canonical shapes — SSH (`git@github.com:owner/repo.git`),
148/// HTTPS (`https://github.com/owner/repo(.git)`), and scp-less host paths — by
149/// stripping the scheme + host, trimming a trailing `.git`/slashes, splitting on
150/// `/`, and taking the final two segments as `(owner, repo)`. Each segment is
151/// slugified. When only one trailing segment is parseable (no owner) the repo is
152/// kept and `owner` falls back to [`UNKNOWN_OWNER`] so the result is always a
153/// two-segment path. Returns `None` only when no non-empty `repo` slug can be
154/// produced (empty input, host-only URL).
155/// Test: `parse_ssh_github`, `parse_https_github_with_and_without_dot_git`,
156/// `parse_non_github_host`, `parse_trailing_slash`, `parse_nested_group_takes_last_two`,
157/// `parse_repo_only_uses_unknown_owner`, `parse_empty_returns_none`.
158pub fn parse_github_path(url: &str) -> Option<GithubPath> {
159 let trimmed = url.trim();
160 if trimmed.is_empty() {
161 return None;
162 }
163
164 let path = host_relative_path(strip_scheme(trimmed));
165 let path = path.trim_end_matches('/');
166 let path = path.strip_suffix(".git").unwrap_or(path);
167 let path = path.trim_end_matches('/');
168
169 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
170 let (owner_raw, repo_raw) = match segments.as_slice() {
171 [.., owner, repo] => (Some(*owner), *repo),
172 [repo] => (None, *repo),
173 _ => return None,
174 };
175
176 let repo = slugify_component(repo_raw);
177 if repo.is_empty() {
178 return None;
179 }
180
181 let owner = owner_raw
182 .map(slugify_component)
183 .filter(|s| !s.is_empty())
184 .unwrap_or_else(|| UNKNOWN_OWNER.to_string());
185
186 Some(GithubPath { owner, repo })
187}
188
189/// Parse an already-canonical `owner/repo` string into a [`GithubPath`].
190///
191/// Why: [`parse_github_path`] expects a git *URL* — it strips a leading host
192/// segment, so feeding it a bare `owner/repo` would wrongly drop `owner` as if
193/// it were a host. Round-tripping a stored canonical identity (e.g. the
194/// `?repo_identity=owner/repo` daemon filter, or a value read back from
195/// `indexes.toml`) needs a parser that treats the input as a path, not a URL.
196/// What: rejects input containing `@` or `://` up front — those are unambiguous
197/// markers of a URL/SSH remote (`git@host:owner/repo`, `https://host/owner/repo`),
198/// which this function must NOT silently mis-slugify into a nonsense identity
199/// (e.g. `git@host:owner/repo` would otherwise parse as owner=`host:owner`,
200/// repo=`repo`). Callers that genuinely have a remote URL must use
201/// [`parse_github_path`] instead. Otherwise splits on `/`, keeps the last two
202/// non-empty segments as `(owner, repo)` (so nested group paths collapse to
203/// their final two, matching [`parse_github_path`]), and slugifies each. A
204/// single trailing segment maps to `(UNKNOWN_OWNER, repo)`. Returns `None` when
205/// no non-empty `repo` slug survives, or the URL/SSH-shape guard rejects it.
206/// Test: `parse_owner_repo_roundtrips_rel_path`, `parse_owner_repo_single_segment`,
207/// `parse_owner_repo_empty_returns_none`, `parse_owner_repo_rejects_url_shaped_input`.
208pub fn parse_owner_repo(s: &str) -> Option<GithubPath> {
209 let trimmed = s.trim();
210 if trimmed.contains('@') || trimmed.contains("://") {
211 return None;
212 }
213 let segments: Vec<&str> = trimmed.split('/').filter(|p| !p.is_empty()).collect();
214 let (owner_raw, repo_raw) = match segments.as_slice() {
215 [.., owner, repo] => (Some(*owner), *repo),
216 [repo] => (None, *repo),
217 _ => return None,
218 };
219 let repo = slugify_component(repo_raw);
220 if repo.is_empty() {
221 return None;
222 }
223 let owner = owner_raw
224 .map(slugify_component)
225 .filter(|s| !s.is_empty())
226 .unwrap_or_else(|| UNKNOWN_OWNER.to_string());
227 Some(GithubPath { owner, repo })
228}
229
230/// Derive a [`GithubPath`] from the git origin remote of a directory.
231///
232/// Why: the only I/O entry point — the workspace-root builder needs the
233/// `owner/repo` identity of the repo a session targets, which lives in its
234/// `remote.origin.url`. Shelling out to `git config` (rather than reading
235/// `.git/config`) transparently handles worktrees, where `.git` is a file
236/// pointing at the parent repo.
237/// What: runs `git -C <dir> config --get remote.origin.url`; on success parses
238/// the URL via [`parse_github_path`]. Returns `None` when git is absent, `dir` is
239/// not a repo, there is no origin remote, or the URL has no parseable identity.
240/// Best-effort, no network.
241/// Test: `derive_github_path_reads_origin` (temp repo with an origin remote);
242/// `derive_github_path_none_outside_repo`.
243pub fn derive_github_path(dir: &Path) -> Option<GithubPath> {
244 let output = Command::new("git")
245 .arg("-C")
246 .arg(dir)
247 .arg("config")
248 .arg("--get")
249 .arg("remote.origin.url")
250 .output()
251 .ok()?;
252 if !output.status.success() {
253 return None;
254 }
255 let url = String::from_utf8_lossy(&output.stdout);
256 parse_github_path(url.trim())
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 /// Why: SSH scp-syntax is the most common GitHub remote; owner and repo must
264 /// be split into two slugified segments.
265 /// Test: itself.
266 #[test]
267 fn parse_ssh_github() {
268 let gp = parse_github_path("git@github.com:bobmatnyc/trusty-tools.git").expect("parsed");
269 assert_eq!(gp.owner, "bobmatnyc");
270 assert_eq!(gp.repo, "trusty-tools");
271 }
272
273 /// Why: HTTPS remotes appear with and without the trailing `.git`; both must
274 /// yield the same two segments.
275 /// Test: itself.
276 #[test]
277 fn parse_https_github_with_and_without_dot_git() {
278 let with = parse_github_path("https://github.com/bobmatnyc/trusty-tools.git").unwrap();
279 let without = parse_github_path("https://github.com/bobmatnyc/trusty-tools").unwrap();
280 assert_eq!(with, without);
281 assert_eq!(with.owner, "bobmatnyc");
282 assert_eq!(with.repo, "trusty-tools");
283 }
284
285 /// Why: non-GitHub hosts still expose `owner/repo`; the host is irrelevant
286 /// and mixed-case/underscores must be normalised in both segments.
287 /// Test: itself.
288 #[test]
289 fn parse_non_github_host() {
290 let gp = parse_github_path("git@gitlab.example.com:Acme/Cool_App.git").unwrap();
291 assert_eq!(gp.owner, "acme");
292 assert_eq!(gp.repo, "cool-app");
293 }
294
295 /// Why: a trailing slash must not produce an empty repo segment.
296 /// Test: itself.
297 #[test]
298 fn parse_trailing_slash() {
299 let gp = parse_github_path("https://github.com/bobmatnyc/trusty-tools/").unwrap();
300 assert_eq!(gp.owner, "bobmatnyc");
301 assert_eq!(gp.repo, "trusty-tools");
302 }
303
304 /// Why: nested group paths (GitLab subgroups) must resolve to the final two
305 /// segments — the immediate group and the repo.
306 /// Test: itself.
307 #[test]
308 fn parse_nested_group_takes_last_two() {
309 let gp = parse_github_path("https://gitlab.com/acme/team/widget.git").unwrap();
310 assert_eq!(gp.owner, "team");
311 assert_eq!(gp.repo, "widget");
312 }
313
314 /// Why: an owner-less remote must still yield a two-segment path so the
315 /// `<owner>/<repo>` convention holds; owner falls back to the sentinel.
316 /// Test: itself.
317 #[test]
318 fn parse_repo_only_uses_unknown_owner() {
319 let gp = parse_github_path("git@host:repo.git").unwrap();
320 assert_eq!(gp.owner, UNKNOWN_OWNER);
321 assert_eq!(gp.repo, "repo");
322 }
323
324 /// Why: empty / host-only inputs have no extractable identity and must
325 /// return `None` so the caller falls back to a slug/default.
326 /// Test: itself.
327 #[test]
328 fn parse_empty_returns_none() {
329 assert_eq!(parse_github_path(""), None);
330 assert_eq!(parse_github_path(" "), None);
331 assert_eq!(parse_github_path("https://github.com/"), None);
332 }
333
334 /// Why: callers join the identity onto a root; `rel_path` must emit
335 /// `<owner>/<repo>` in that exact order.
336 /// Test: itself.
337 #[test]
338 fn github_path_rel_joins_owner_repo() {
339 let gp = GithubPath {
340 owner: "bobmatnyc".into(),
341 repo: "trusty-tools".into(),
342 };
343 assert_eq!(gp.rel_path(), "bobmatnyc/trusty-tools");
344 }
345
346 /// Why: the I/O entry point must read a real `remote.origin.url` and parse it;
347 /// uses a throwaway temp repo so it never touches the network.
348 /// Test: itself (skips cleanly if `git` is unavailable on the runner).
349 #[test]
350 fn derive_github_path_reads_origin() {
351 let tmp = tempfile::TempDir::new().expect("tempdir");
352 let dir = tmp.path();
353 let git = |args: &[&str]| {
354 Command::new("git")
355 .arg("-C")
356 .arg(dir)
357 .args(args)
358 .output()
359 .map(|o| o.status.success())
360 .unwrap_or(false)
361 };
362 if !git(&["init"]) {
363 // No usable git on this runner — nothing to assert.
364 return;
365 }
366 let _ = git(&[
367 "remote",
368 "add",
369 "origin",
370 "git@github.com:bobmatnyc/trusty-tools.git",
371 ]);
372 let gp = derive_github_path(dir).expect("derived from origin");
373 assert_eq!(gp.owner, "bobmatnyc");
374 assert_eq!(gp.repo, "trusty-tools");
375 }
376
377 /// Why: a directory that is not a git repo must yield `None`, not panic, so
378 /// the caller can fall back cleanly.
379 /// Test: itself.
380 #[test]
381 fn derive_github_path_none_outside_repo() {
382 let tmp = tempfile::TempDir::new().expect("tempdir");
383 assert_eq!(derive_github_path(tmp.path()), None);
384 }
385
386 /// Why: a canonical identity string must round-trip through `rel_path` →
387 /// `parse_owner_repo` unchanged, and must NOT drop `owner` (the URL parser
388 /// bug this function exists to avoid).
389 /// Test: itself.
390 #[test]
391 fn parse_owner_repo_roundtrips_rel_path() {
392 let gp = parse_owner_repo("bobmatnyc/trusty-tools").expect("parsed");
393 assert_eq!(gp.owner, "bobmatnyc");
394 assert_eq!(gp.repo, "trusty-tools");
395 assert_eq!(parse_owner_repo(&gp.rel_path()), Some(gp));
396 // Nested group paths collapse to the final two segments.
397 let nested = parse_owner_repo("acme/team/widget").unwrap();
398 assert_eq!(nested.owner, "team");
399 assert_eq!(nested.repo, "widget");
400 // Mixed case / underscores are slugified, matching derive-time output.
401 let slug = parse_owner_repo("Acme/Cool_App").unwrap();
402 assert_eq!(slug.owner, "acme");
403 assert_eq!(slug.repo, "cool-app");
404 }
405
406 /// Why: a single trailing segment (no owner) must still yield a two-segment
407 /// identity via the `UNKNOWN_OWNER` sentinel, mirroring `parse_github_path`.
408 /// Test: itself.
409 #[test]
410 fn parse_owner_repo_single_segment() {
411 let gp = parse_owner_repo("repo").unwrap();
412 assert_eq!(gp.owner, UNKNOWN_OWNER);
413 assert_eq!(gp.repo, "repo");
414 }
415
416 /// Why: empty / slug-less inputs have no identity and must return `None`.
417 /// Test: itself.
418 #[test]
419 fn parse_owner_repo_empty_returns_none() {
420 assert_eq!(parse_owner_repo(""), None);
421 assert_eq!(parse_owner_repo(" "), None);
422 assert_eq!(parse_owner_repo("///"), None);
423 }
424
425 /// Why: `parse_owner_repo` treats its input as a canonical `owner/repo`
426 /// PATH, not a URL — feeding it an SSH (`git@host:owner/repo`) or HTTPS
427 /// (`https://host/owner/repo`) remote must be rejected rather than silently
428 /// mis-slugified into a nonsense identity (e.g. owner=`host:owner`).
429 /// Test: itself.
430 #[test]
431 fn parse_owner_repo_rejects_url_shaped_input() {
432 assert_eq!(
433 parse_owner_repo("git@github.com:bobmatnyc/trusty-tools.git"),
434 None
435 );
436 assert_eq!(
437 parse_owner_repo("https://github.com/bobmatnyc/trusty-tools"),
438 None
439 );
440 assert_eq!(parse_owner_repo("ssh://git@host/owner/repo"), None);
441 }
442}