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]
74pub fn detect(dir: &Path) -> Detection {
75 let mut command = Command::new(crate::probes::git_bin());
76 for var in crate::maintenance::GIT_HOOK_VARS {
77 command.env_remove(var);
78 }
79 let out = command
80 .args(["-C"])
81 .arg(dir)
82 .args(["remote", "get-url", "origin"])
83 .output();
84 let url = match out {
85 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
86 _ => return Detection::default(),
87 };
88 let Some((host, path)) = split_remote(&url) else {
89 return Detection::default();
90 };
91 let forge = forge_for_host(&host);
92 Detection {
93 host: Some(host),
94 repo: Some(path),
95 forge,
96 }
97}
98
99#[must_use]
103pub fn forge_for_host(host: &str) -> Option<Forge> {
104 if host == "github.com" {
105 return Some(Forge::Github);
106 }
107 if host == "gitlab.com" || host.starts_with("gitlab.") {
108 return Some(Forge::Gitlab);
109 }
110 None
111}
112
113#[must_use]
116pub fn split_remote(url: &str) -> Option<(String, String)> {
117 let (host, raw_path) = if let Some((_, rest)) = url.split_once("://") {
118 let (authority, path) = rest.split_once('/')?;
119 let host = authority
120 .rsplit_once('@')
121 .map_or(authority, |(_, host)| host);
122 let host = host.split(':').next()?;
123 (host.to_owned(), path.to_owned())
124 } else {
125 let (authority, path) = url.split_once(':')?;
126 let host = authority
127 .rsplit_once('@')
128 .map_or(authority, |(_, host)| host);
129 (host.to_owned(), path.to_owned())
130 };
131 let path = raw_path
132 .trim_start_matches('/')
133 .trim_end_matches('/')
134 .trim_end_matches(".git")
135 .to_owned();
136 (!host.is_empty() && !path.is_empty()).then_some((host, path))
137}
138
139#[must_use]
142pub fn tech_of(dir: &Path) -> Option<&'static str> {
143 if dir.join("Cargo.toml").is_file() {
144 Some("rust")
145 } else if dir.join("pyproject.toml").is_file() {
146 Some("python")
147 } else if dir.join("VERSION").is_file() {
148 Some("bash")
149 } else {
150 None
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::{Forge, forge_for_host, split_remote};
157
158 #[test]
159 fn a_remote_splits_into_host_and_path_in_both_forms() {
160 assert_eq!(
161 split_remote("https://github.com/owner/name.git"),
162 Some(("github.com".into(), "owner/name".into()))
163 );
164 assert_eq!(
165 split_remote("git@gitlab.com:group/sub/name.git"),
166 Some(("gitlab.com".into(), "group/sub/name".into()))
167 );
168 assert_eq!(
169 split_remote("ssh://git@github.com:22/owner/name.git"),
170 Some(("github.com".into(), "owner/name".into()))
171 );
172 assert_eq!(split_remote("not a url"), None);
173 }
174
175 #[test]
176 fn a_host_maps_to_its_forge_and_an_unknown_host_to_none() {
177 assert_eq!(forge_for_host("github.com"), Some(Forge::Github));
178 assert_eq!(forge_for_host("gitlab.com"), Some(Forge::Gitlab));
179 assert_eq!(forge_for_host("gitlab.example.org"), Some(Forge::Gitlab));
180 assert_eq!(forge_for_host("codeberg.org"), None);
181 }
182}