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