Skip to main content

vgi_forge_forgejo/
version.rs

1//! What an instance is, from `GET /api/v1/version`, and what it can do.
2//!
3//! Forgejo reports `<forgejo version>+gitea-<compatible gitea version>`
4//! (`9.0.0+gitea-1.22.0`, `16.0.0-dev-753-6bcc6da0+gitea-1.22.0`); Gitea,
5//! and Forgejo before v7, report a plain `1.x.y`. Features are switched on
6//! by version so a plan never includes a step the instance cannot run —
7//! and the steps re-check the answer they get, since a version string says
8//! what should be there, not what is.
9
10use serde::{Deserialize, Serialize};
11
12/// Which software, at which version.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase", tag = "type")]
15#[non_exhaustive]
16pub enum Flavor {
17    /// Forgejo 7 or later (`<major>.<minor>.<patch>+gitea-…`).
18    Forgejo {
19        /// Major version.
20        major: u32,
21        /// Minor version.
22        minor: u32,
23    },
24    /// Gitea, or Forgejo before v7 (a Gitea-numbered `1.x`).
25    Gitea {
26        /// Major version (always 1 so far).
27        major: u32,
28        /// Minor version.
29        minor: u32,
30    },
31    /// A version string the adapter could not read. Every optional feature
32    /// is off.
33    Unknown,
34}
35
36/// Optional features the adapter uses, as the version implies them.
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39#[non_exhaustive]
40pub struct Features {
41    /// The `fast-forward-only` merge style (Gitea 1.22, Forgejo 7).
42    pub fast_forward_only: bool,
43    /// The repository Actions variables API (Gitea 1.22; Forgejo 8 to be
44    /// safe). Without it the plan writes the DIDs into the workflow.
45    pub actions_variables: bool,
46    /// Webhook `type: forgejo` (any Forgejo 7+); `gitea` otherwise.
47    pub forgejo_webhooks: bool,
48}
49
50/// The probed instance.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53#[non_exhaustive]
54pub struct InstanceInfo {
55    /// The version string as reported.
56    pub version: String,
57    /// What it parses as.
58    pub flavor: Flavor,
59    /// What that implies.
60    pub features: Features,
61}
62
63impl InstanceInfo {
64    /// Read a `/api/v1/version` string.
65    pub fn from_version(version: &str) -> Self {
66        let flavor = parse(version);
67        let at_least = |maj: u32, min: u32, (a, b): (u32, u32)| (a, b) >= (maj, min);
68        let features = match flavor {
69            Flavor::Forgejo { major, minor } => Features {
70                fast_forward_only: at_least(7, 0, (major, minor)),
71                actions_variables: at_least(8, 0, (major, minor)),
72                forgejo_webhooks: true,
73            },
74            Flavor::Gitea { major, minor } => Features {
75                fast_forward_only: at_least(1, 22, (major, minor)),
76                actions_variables: at_least(1, 22, (major, minor)),
77                forgejo_webhooks: false,
78            },
79            Flavor::Unknown => Features::default(),
80        };
81        InstanceInfo {
82            version: version.to_string(),
83            flavor,
84            features,
85        }
86    }
87}
88
89fn parse(version: &str) -> Flavor {
90    let (own, gitea) = match version.split_once("+gitea-") {
91        Some((own, gitea)) => (own, Some(gitea)),
92        None => (version, None),
93    };
94    let Some((major, minor)) = major_minor(own) else {
95        return Flavor::Unknown;
96    };
97    match gitea {
98        Some(_) => Flavor::Forgejo { major, minor },
99        // A plain `1.x` is Gitea's numbering (or Forgejo's before v7, which
100        // tracked it). A plain `7+` is a Forgejo build without the suffix.
101        None if major >= 7 => Flavor::Forgejo { major, minor },
102        None if major == 1 => Flavor::Gitea { major, minor },
103        None => Flavor::Unknown,
104    }
105}
106
107fn major_minor(v: &str) -> Option<(u32, u32)> {
108    let v = v.trim().trim_start_matches('v');
109    let mut parts = v.split(['.', '-', '+']);
110    let major = parts.next()?.parse().ok()?;
111    let minor = parts.next()?.parse().ok()?;
112    Some((major, minor))
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn versions_parse_and_gate_features() {
121        let f = InstanceInfo::from_version("9.0.0+gitea-1.22.0");
122        assert_eq!(f.flavor, Flavor::Forgejo { major: 9, minor: 0 });
123        assert!(f.features.fast_forward_only && f.features.actions_variables);
124        assert!(f.features.forgejo_webhooks);
125
126        let dev = InstanceInfo::from_version("16.0.0-dev-753-6bcc6da0+gitea-1.22.0");
127        assert_eq!(
128            dev.flavor,
129            Flavor::Forgejo {
130                major: 16,
131                minor: 0
132            }
133        );
134
135        let seven = InstanceInfo::from_version("7.0.5+gitea-1.21.0");
136        assert!(seven.features.fast_forward_only && !seven.features.actions_variables);
137
138        let gitea = InstanceInfo::from_version("1.22.3");
139        assert_eq!(
140            gitea.flavor,
141            Flavor::Gitea {
142                major: 1,
143                minor: 22
144            }
145        );
146        assert!(gitea.features.fast_forward_only && !gitea.features.forgejo_webhooks);
147
148        let old = InstanceInfo::from_version("1.21.11-1");
149        assert_eq!(old.features, Features::default());
150
151        for junk in ["", "dev", "x.y.z", "3.0.0"] {
152            assert_eq!(InstanceInfo::from_version(junk).flavor, Flavor::Unknown);
153        }
154    }
155}