Skip to main content

release_kit/
detect.rs

1//! Forge, repository, and technology detection.
2//!
3//! One pass reads `git remote get-url origin`: the path is the project, the
4//! host chooses the forge. An unrecognized host is never defaulted — a wrong
5//! guess runs protection calls against the wrong API and fails partway
6//! through a setup — so callers refuse and name the override flags instead.
7//! The technology is read from the version file, exactly as the bindings
8//! define it.
9
10use std::path::Path;
11use std::process::Command;
12
13/// A supported forge.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Forge {
16    /// github.com, driven through `gh`.
17    Github,
18    /// gitlab.com or a self-hosted GitLab, driven through `glab`.
19    Gitlab,
20}
21
22impl Forge {
23    /// The wire and directory name.
24    #[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    /// Parse a `--forge` value.
33    #[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    /// The forge CLI this forge is driven through.
43    #[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    /// The environment variable that substitutes this forge's CLI.
52    #[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    /// The lowest forge-CLI version this binary calls.
61    ///
62    /// `gh auth status --active`, which `rk doctor` runs to judge only the
63    /// credential this tool would use, first shipped in gh 2.57.0, the
64    /// newest of the gh calls: `gh issue develop` needs 2.19.0. The
65    /// default naming this binary depends on is a property of the GraphQL
66    /// API — `CreateLinkedBranchInput.name` is optional and documented to
67    /// default to the issue number and title — so no higher floor buys
68    /// correctness.
69    ///
70    /// The `glab` floor is the version whose source this behavior was
71    /// written against. Only `glab api` is called, which is much older, so
72    /// the floor is stricter than the calls need.
73    #[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    /// The command that raises a CLI below [`Self::cli_floor`].
82    #[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    /// Every supported forge, in a stable order.
91    pub const ALL: [Self; 2] = [Self::Github, Self::Gitlab];
92}
93
94/// What one detection pass observed; every field is an observation, and
95/// refusing on what is absent is the caller's decision.
96#[derive(Debug, Default)]
97pub struct Detection {
98    /// The remote's host, where a remote exists and parses.
99    pub host: Option<String>,
100    /// The project path from the remote: no scheme, no `.git` suffix.
101    pub repo: Option<String>,
102    /// The forge the host maps to; `None` with a `host` present means the
103    /// host is unrecognized.
104    pub forge: Option<Forge>,
105}
106
107/// Read the `origin` remote of `dir` and map it, without judging.
108///
109/// The read answers for `dir` alone: the variables a running hook
110/// exports are scrubbed, so an inherited `GIT_DIR` cannot answer with
111/// another repository's remote. The child runs with `dir` as its working
112/// directory rather than through `-C`: where `dir` is the kernel's link
113/// to a held directory, the change of directory happens in the child
114/// before the exec closes the descriptor, so the child stands in the held
115/// directory itself, whatever the pathname has since become.
116#[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/// The forge a host maps to. `gitlab.com` and hosts that name gitlab map to
142/// GitLab; a self-hosted instance on a host name that says nothing needs
143/// `--forge`.
144#[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/// Host and project path from a git remote URL, for the URL and `scp`-like
156/// forms. The path drops a leading slash and a `.git` suffix.
157#[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/// The technology of a repository, read from its version file.
182///
183/// `Cargo.toml` means rust, `pyproject.toml` means python, a `VERSION`
184/// file means bash. Where several are present the first in that order
185/// answers, which is what the dependency verbs key on; a landing reads
186/// them all through [`technologies_of`].
187#[must_use]
188pub fn tech_of(dir: &Path) -> Option<&'static str> {
189    technologies_of(dir).first().copied()
190}
191
192/// Every technology the version files name, in the bindings' order:
193/// `Cargo.toml` is rust, `pyproject.toml` is python, and a `VERSION` file
194/// is bash. Zero or many.
195#[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/// What one observation of a target found.
209///
210/// Every technology its version files name, and what its origin remote
211/// says. Every field is an observation; deciding on it is the
212/// resolution's job.
213#[derive(Debug, Default)]
214pub struct Observation {
215    /// The technologies present, in the bindings' order.
216    pub technologies: Vec<&'static str>,
217    /// The remote's host, where a remote exists and parses.
218    pub host: Option<String>,
219    /// The project path from the remote.
220    pub repo: Option<String>,
221    /// The forge the host maps to.
222    pub forge: Option<Forge>,
223}
224
225/// Observe `dir` once: its version files and its origin remote.
226#[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}