1use std::path::Path;
11use std::process::Command;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Forge {
16 Github,
18 Gitlab,
20}
21
22impl Forge {
23 #[must_use]
25 pub const fn as_str(self) -> &'static str {
26 match self {
27 Self::Github => "github",
28 Self::Gitlab => "gitlab",
29 }
30 }
31
32 #[must_use]
34 pub fn parse(name: &str) -> Option<Self> {
35 match name {
36 "github" => Some(Self::Github),
37 "gitlab" => Some(Self::Gitlab),
38 _ => None,
39 }
40 }
41
42 #[must_use]
44 pub const fn cli(self) -> &'static str {
45 match self {
46 Self::Github => "gh",
47 Self::Gitlab => "glab",
48 }
49 }
50
51 pub const ALL: [Self; 2] = [Self::Github, Self::Gitlab];
53}
54
55#[derive(Debug, Default)]
58pub struct Detection {
59 pub host: Option<String>,
61 pub repo: Option<String>,
63 pub forge: Option<Forge>,
66}
67
68#[must_use]
70pub fn detect(dir: &Path) -> Detection {
71 let out = Command::new("git")
72 .args(["-C"])
73 .arg(dir)
74 .args(["remote", "get-url", "origin"])
75 .output();
76 let url = match out {
77 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
78 _ => return Detection::default(),
79 };
80 let Some((host, path)) = split_remote(&url) else {
81 return Detection::default();
82 };
83 let forge = forge_for_host(&host);
84 Detection {
85 host: Some(host),
86 repo: Some(path),
87 forge,
88 }
89}
90
91#[must_use]
95pub fn forge_for_host(host: &str) -> Option<Forge> {
96 if host == "github.com" {
97 return Some(Forge::Github);
98 }
99 if host == "gitlab.com" || host.starts_with("gitlab.") {
100 return Some(Forge::Gitlab);
101 }
102 None
103}
104
105#[must_use]
108pub fn split_remote(url: &str) -> Option<(String, String)> {
109 let (host, raw_path) = if let Some((_, rest)) = url.split_once("://") {
110 let (authority, path) = rest.split_once('/')?;
111 let host = authority
112 .rsplit_once('@')
113 .map_or(authority, |(_, host)| host);
114 let host = host.split(':').next()?;
115 (host.to_owned(), path.to_owned())
116 } else {
117 let (authority, path) = url.split_once(':')?;
118 let host = authority
119 .rsplit_once('@')
120 .map_or(authority, |(_, host)| host);
121 (host.to_owned(), path.to_owned())
122 };
123 let path = raw_path
124 .trim_start_matches('/')
125 .trim_end_matches('/')
126 .trim_end_matches(".git")
127 .to_owned();
128 (!host.is_empty() && !path.is_empty()).then_some((host, path))
129}
130
131#[must_use]
134pub fn tech_of(dir: &Path) -> Option<&'static str> {
135 if dir.join("Cargo.toml").is_file() {
136 Some("rust")
137 } else if dir.join("pyproject.toml").is_file() {
138 Some("python")
139 } else if dir.join("VERSION").is_file() {
140 Some("bash")
141 } else {
142 None
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::{Forge, forge_for_host, split_remote};
149
150 #[test]
151 fn a_remote_splits_into_host_and_path_in_both_forms() {
152 assert_eq!(
153 split_remote("https://github.com/owner/name.git"),
154 Some(("github.com".into(), "owner/name".into()))
155 );
156 assert_eq!(
157 split_remote("git@gitlab.com:group/sub/name.git"),
158 Some(("gitlab.com".into(), "group/sub/name".into()))
159 );
160 assert_eq!(
161 split_remote("ssh://git@github.com:22/owner/name.git"),
162 Some(("github.com".into(), "owner/name".into()))
163 );
164 assert_eq!(split_remote("not a url"), None);
165 }
166
167 #[test]
168 fn a_host_maps_to_its_forge_and_an_unknown_host_to_none() {
169 assert_eq!(forge_for_host("github.com"), Some(Forge::Github));
170 assert_eq!(forge_for_host("gitlab.com"), Some(Forge::Gitlab));
171 assert_eq!(forge_for_host("gitlab.example.org"), Some(Forge::Gitlab));
172 assert_eq!(forge_for_host("codeberg.org"), None);
173 }
174}