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 #[must_use]
53 pub const fn cli_override(self) -> &'static str {
54 match self {
55 Self::Github => "RK_GH_BIN",
56 Self::Gitlab => "RK_GLAB_BIN",
57 }
58 }
59
60 #[must_use]
74 pub const fn cli_floor(self) -> (u32, u32, u32) {
75 match self {
76 Self::Github => (2, 57, 0),
77 Self::Gitlab => (1, 114, 0),
78 }
79 }
80
81 #[must_use]
83 pub const fn cli_upgrade(self) -> &'static str {
84 match self {
85 Self::Github => "upgrade gh, or point RK_GH_BIN at a newer binary",
86 Self::Gitlab => "upgrade glab, or point RK_GLAB_BIN at a newer binary",
87 }
88 }
89
90 pub const ALL: [Self; 2] = [Self::Github, Self::Gitlab];
92}
93
94#[derive(Debug, Default)]
97pub struct Detection {
98 pub host: Option<String>,
100 pub repo: Option<String>,
102 pub forge: Option<Forge>,
105}
106
107#[must_use]
117pub fn detect(dir: &Path) -> Detection {
118 let mut command = Command::new(crate::probes::git_bin());
119 for var in crate::maintenance::GIT_HOOK_VARS {
120 command.env_remove(var);
121 }
122 let out = command
123 .current_dir(dir)
124 .args(["remote", "get-url", "origin"])
125 .output();
126 let url = match out {
127 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
128 _ => return Detection::default(),
129 };
130 let Some((host, path)) = split_remote(&url) else {
131 return Detection::default();
132 };
133 let forge = forge_for_host(&host);
134 Detection {
135 host: Some(host),
136 repo: Some(path),
137 forge,
138 }
139}
140
141#[must_use]
145pub fn forge_for_host(host: &str) -> Option<Forge> {
146 if host == "github.com" {
147 return Some(Forge::Github);
148 }
149 if host == "gitlab.com" || host.starts_with("gitlab.") {
150 return Some(Forge::Gitlab);
151 }
152 None
153}
154
155#[must_use]
158pub fn split_remote(url: &str) -> Option<(String, String)> {
159 let (host, raw_path) = if let Some((_, rest)) = url.split_once("://") {
160 let (authority, path) = rest.split_once('/')?;
161 let host = authority
162 .rsplit_once('@')
163 .map_or(authority, |(_, host)| host);
164 let host = host.split(':').next()?;
165 (host.to_owned(), path.to_owned())
166 } else {
167 let (authority, path) = url.split_once(':')?;
168 let host = authority
169 .rsplit_once('@')
170 .map_or(authority, |(_, host)| host);
171 (host.to_owned(), path.to_owned())
172 };
173 let path = raw_path
174 .trim_start_matches('/')
175 .trim_end_matches('/')
176 .trim_end_matches(".git")
177 .to_owned();
178 (!host.is_empty() && !path.is_empty()).then_some((host, path))
179}
180
181#[must_use]
188pub fn tech_of(dir: &Path) -> Option<&'static str> {
189 technologies_of(dir).first().copied()
190}
191
192#[must_use]
196pub fn technologies_of(dir: &Path) -> Vec<&'static str> {
197 [
198 ("Cargo.toml", "rust"),
199 ("pyproject.toml", "python"),
200 ("VERSION", "bash"),
201 ]
202 .into_iter()
203 .filter(|(file, _)| dir.join(file).is_file())
204 .map(|(_, technology)| technology)
205 .collect()
206}
207
208#[derive(Debug, Default)]
214pub struct Observation {
215 pub technologies: Vec<&'static str>,
217 pub host: Option<String>,
219 pub repo: Option<String>,
221 pub forge: Option<Forge>,
223}
224
225#[must_use]
227pub fn observe(dir: &Path) -> Observation {
228 let detected = detect(dir);
229 Observation {
230 technologies: technologies_of(dir),
231 host: detected.host,
232 repo: detected.repo,
233 forge: detected.forge,
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::{Forge, forge_for_host, split_remote};
240
241 #[test]
242 fn a_remote_splits_into_host_and_path_in_both_forms() {
243 assert_eq!(
244 split_remote("https://github.com/owner/name.git"),
245 Some(("github.com".into(), "owner/name".into()))
246 );
247 assert_eq!(
248 split_remote("git@gitlab.com:group/sub/name.git"),
249 Some(("gitlab.com".into(), "group/sub/name".into()))
250 );
251 assert_eq!(
252 split_remote("ssh://git@github.com:22/owner/name.git"),
253 Some(("github.com".into(), "owner/name".into()))
254 );
255 assert_eq!(split_remote("not a url"), None);
256 }
257
258 #[test]
259 fn a_host_maps_to_its_forge_and_an_unknown_host_to_none() {
260 assert_eq!(forge_for_host("github.com"), Some(Forge::Github));
261 assert_eq!(forge_for_host("gitlab.com"), Some(Forge::Gitlab));
262 assert_eq!(forge_for_host("gitlab.example.org"), Some(Forge::Gitlab));
263 assert_eq!(forge_for_host("codeberg.org"), None);
264 }
265}