Skip to main content

mnemo/lifecycle/
github.rs

1//! Interrogation de l'API GitHub Releases.
2//!
3//! On utilise le client HTTP bloquant `ureq` (TLS rustls, sans runtime async),
4//! et on isole la couche réseau de la couche d'analyse, cette dernière (pure)
5//! étant testée sans réseau dans [`parse_github_latest_release`].
6
7use anyhow::{Context, Result};
8use serde::Deserialize;
9
10/// Informations minimales extraites d'une release GitHub.
11#[derive(Debug, Clone, Deserialize)]
12pub struct ReleaseInfo {
13    /// Tag de la release (ex. `v0.5.0`).
14    pub tag_name: String,
15    /// Vrai s'il s'agit d'une pré-release. Exposé pour le filtrage et les tests
16    /// (`/releases/latest` exclut déjà nativement les pré-releases).
17    #[serde(default)]
18    #[allow(dead_code)]
19    pub prerelease: bool,
20}
21
22/// Propriétaire du dépôt (surcharge possible via `MNEMO_OWNER`).
23pub fn owner() -> String {
24    std::env::var("MNEMO_OWNER").unwrap_or_else(|_| "Vesperis-group".to_string())
25}
26
27/// Nom du dépôt (surcharge possible via `MNEMO_REPO`).
28pub fn repo() -> String {
29    std::env::var("MNEMO_REPO").unwrap_or_else(|_| "mnemo".to_string())
30}
31
32/// Base de l'API GitHub (surcharge possible via `MNEMO_GITHUB_API`, utile pour
33/// les tests qui pointent vers un serveur local).
34pub fn api_base() -> String {
35    std::env::var("MNEMO_GITHUB_API")
36        .unwrap_or_else(|_| "https://api.github.com".to_string())
37        .trim_end_matches('/')
38        .to_string()
39}
40
41/// Base des téléchargements de release (surcharge via `MNEMO_GITHUB_BASE`).
42pub fn download_base() -> String {
43    std::env::var("MNEMO_GITHUB_BASE")
44        .unwrap_or_else(|_| "https://github.com".to_string())
45        .trim_end_matches('/')
46        .to_string()
47}
48
49/// Analyse la réponse JSON de `/releases/latest` (fonction pure, testable).
50pub fn parse_github_latest_release(json: &str) -> Result<ReleaseInfo> {
51    let info: ReleaseInfo =
52        serde_json::from_str(json).context("réponse GitHub illisible (JSON invalide)")?;
53    if info.tag_name.trim().is_empty() {
54        anyhow::bail!("réponse GitHub sans `tag_name`");
55    }
56    Ok(info)
57}
58
59/// URL de l'asset de release pour un tag et un nom de fichier donnés.
60pub fn asset_url(tag: &str, file_name: &str) -> String {
61    format!(
62        "{}/{}/{}/releases/download/{}/{}",
63        download_base(),
64        owner(),
65        repo(),
66        tag,
67        file_name
68    )
69}
70
71/// Récupère la dernière release stable via l'API GitHub.
72///
73/// `/releases/latest` exclut nativement les brouillons et pré-releases.
74pub fn fetch_latest_release() -> Result<ReleaseInfo> {
75    let url = format!(
76        "{}/repos/{}/{}/releases/latest",
77        api_base(),
78        owner(),
79        repo()
80    );
81    let body = http_get_string(&url).with_context(|| format!("échec de la requête vers {url}"))?;
82    parse_github_latest_release(&body)
83}
84
85/// GET HTTP renvoyant le corps en texte.
86pub fn http_get_string(url: &str) -> Result<String> {
87    let mut resp = ureq::get(url)
88        .header("User-Agent", "mnemo-cli")
89        .header("Accept", "application/vnd.github+json")
90        .call()
91        .map_err(|err| map_ureq_error(url, err))?;
92    resp.body_mut()
93        .read_to_string()
94        .context("corps de réponse HTTP illisible")
95}
96
97/// GET HTTP renvoyant le corps en octets (assets binaires).
98pub fn http_get_bytes(url: &str) -> Result<Vec<u8>> {
99    let mut resp = ureq::get(url)
100        .header("User-Agent", "mnemo-cli")
101        .call()
102        .map_err(|err| map_ureq_error(url, err))?;
103    let mut buf = Vec::new();
104    use std::io::Read;
105    // `as_reader()` lit le corps en flux, sans la limite de taille par défaut
106    // (10 Mio) des aides `read_to_vec()` : les assets de release peuvent être
107    // plus volumineux.
108    resp.body_mut()
109        .as_reader()
110        .read_to_end(&mut buf)
111        .context("téléchargement interrompu")?;
112    Ok(buf)
113}
114
115/// Transforme une erreur `ureq` en message clair (statut HTTP ou transport).
116fn map_ureq_error(url: &str, err: ureq::Error) -> anyhow::Error {
117    match err {
118        ureq::Error::StatusCode(code) => {
119            anyhow::anyhow!("réponse HTTP {code} pour {url}")
120        }
121        other => anyhow::anyhow!("erreur réseau pour {url} : {other}"),
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn parse_release_stable() {
131        let json = r#"{"tag_name":"v0.5.0","prerelease":false,"name":"v0.5.0"}"#;
132        let info = parse_github_latest_release(json).unwrap();
133        assert_eq!(info.tag_name, "v0.5.0");
134        assert!(!info.prerelease);
135    }
136
137    #[test]
138    fn parse_release_prerelease() {
139        let json = r#"{"tag_name":"v0.6.0-rc1","prerelease":true}"#;
140        let info = parse_github_latest_release(json).unwrap();
141        assert_eq!(info.tag_name, "v0.6.0-rc1");
142        assert!(info.prerelease);
143    }
144
145    #[test]
146    fn parse_release_json_invalide() {
147        assert!(parse_github_latest_release("pas du json").is_err());
148        assert!(parse_github_latest_release(r#"{"name":"x"}"#).is_err());
149        assert!(parse_github_latest_release(r#"{"tag_name":""}"#).is_err());
150    }
151
152    #[test]
153    fn url_asset_construite() {
154        std::env::set_var("MNEMO_GITHUB_BASE", "http://localhost:9");
155        std::env::set_var("MNEMO_OWNER", "acme");
156        std::env::set_var("MNEMO_REPO", "tool");
157        let url = asset_url("v0.5.0", "mnemo-v0.5.0-x86_64-unknown-linux-musl.tar.gz");
158        assert_eq!(
159            url,
160            "http://localhost:9/acme/tool/releases/download/v0.5.0/mnemo-v0.5.0-x86_64-unknown-linux-musl.tar.gz"
161        );
162        std::env::remove_var("MNEMO_GITHUB_BASE");
163        std::env::remove_var("MNEMO_OWNER");
164        std::env::remove_var("MNEMO_REPO");
165    }
166}