Skip to main content

rootle_manager/
reference.rs

1//! The install-reference grammar: what `rootle provider install`
2//! accepts and how it resolves to repo, short name, and pin.
3
4use super::{ManagerError, Result};
5
6/// A resolved install reference (the grammar from the plan):
7/// `gitlab` | `owner/repo` | `https://github.com/owner/repo`, each
8/// optionally `@tag`-pinned — or a plain-HTTP URL naming the platform
9/// tarball directly (plans/0014 #1a: arbitrary artifact hosts).
10#[derive(Debug, Clone, PartialEq)]
11pub struct ProviderReference {
12    /// owner/repo — or the full URL for a plain-HTTP install.
13    pub repo: String,
14    /// The bare name (repo stem minus the rootle- prefix, or the stem
15    /// itself when unprefixed).
16    pub name: String,
17    pub tag: Option<String>,
18    /// Plain-HTTP source: the tarball URL when the reference is not a
19    /// releases-API source. Install-and-pin — `update`/`upgrade`
20    /// never track these (plans/0014 #1b).
21    pub tarball: Option<String>,
22}
23
24impl ProviderReference {
25    pub fn parse(input: &str) -> Result<ProviderReference> {
26        let input = input.trim();
27        if input.is_empty() {
28            return Err(ManagerError::User("empty provider reference".into()));
29        }
30        let (path, tag) = match input.split_once('@') {
31            Some((p, t)) => (p, Some(t.to_string())),
32            None => (input, None),
33        };
34        // Plain-HTTP artifact hosts (plans/0014 #1a): a non-GitHub URL
35        // names the platform tarball itself; the mandatory `.sha256`
36        // sidecar rides at `<url>.sha256`. The integrity model is
37        // host-agnostic — the github.com restriction was v1 scope,
38        // not a trust boundary.
39        let github = path
40            .strip_prefix("https://github.com/")
41            .or_else(|| path.strip_prefix("http://github.com/"));
42        if github.is_none() && (path.starts_with("https://") || path.starts_with("http://")) {
43            return parse_tarball_url(path, tag);
44        }
45        let repo = if let Some(rest) = github {
46            rest.trim_end_matches('/')
47                .trim_end_matches(".git")
48                .to_string()
49        } else {
50            path.to_string()
51        };
52        let has_slash = repo.contains('/');
53        let short = if has_slash {
54            let stem = repo.rsplit('/').next().unwrap_or(&repo).to_string();
55            stem.strip_prefix("rootle-").unwrap_or(&stem).to_string()
56        } else {
57            repo.clone()
58        };
59        let repo = if has_slash {
60            repo
61        } else {
62            // Bare name → the rootle-<name> convention (gh's gh- rule).
63            format!("rootledev/rootle-{repo}")
64        };
65        let Some((owner, name)) = repo.split_once('/') else {
66            return Err(ManagerError::User(format!(
67                "provider reference {input:?} must be owner/repo, a GitHub URL, or a bare name"
68            )));
69        };
70        if owner.is_empty() || name.is_empty() {
71            return Err(ManagerError::User(format!("malformed reference {input:?}")));
72        }
73        Ok(ProviderReference {
74            repo,
75            name: short,
76            tag,
77            tarball: None,
78        })
79    }
80}
81
82/// A plain-HTTP install reference: the URL names the platform tarball
83/// (`rootle-<name>[-vX.Y.Z]-<target>.tar.gz`); name and tag derive
84/// from the filename, an explicit `@tag` wins. The tag is receipt
85/// provenance only — plain-HTTP installs are install-and-pin, never
86/// tracked by `update`/`upgrade`.
87fn parse_tarball_url(url: &str, tag: Option<String>) -> Result<ProviderReference> {
88    let file = url.rsplit('/').next().unwrap_or_default();
89    let target = super::release::platform_target();
90    let suffix = format!("-{target}.tar.gz");
91    let stem = file.strip_suffix(&suffix).ok_or_else(|| {
92        ManagerError::User(format!(
93            "plain-HTTP install needs the platform tarball: a URL ending in {suffix}"
94        ))
95    })?;
96    let stem = stem.strip_prefix("rootle-").unwrap_or(stem);
97    let (name, derived) = match stem.rsplit_once('-') {
98        Some((n, v)) if is_versionish(v) => (n, Some(normalize_tag(v))),
99        _ => (stem, None),
100    };
101    if name.is_empty() {
102        return Err(ManagerError::User(format!(
103            "cannot derive a provider name from {url:?}"
104        )));
105    }
106    Ok(ProviderReference {
107        repo: url.to_string(),
108        name: name.to_string(),
109        tag: tag.or(derived),
110        tarball: Some(url.to_string()),
111    })
112}
113
114/// `0.1.0` / `v0.1.0`-shaped filename tails.
115fn is_versionish(s: &str) -> bool {
116    let s = s.strip_prefix('v').unwrap_or(s);
117    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit() || b == b'.')
118}
119
120/// Release tags are v-prefixed; keep the receipt consistent.
121fn normalize_tag(v: &str) -> String {
122    if v.starts_with('v') {
123        v.to_string()
124    } else {
125        format!("v{v}")
126    }
127}
128
129/// The binary name inside a release tarball (`rootle-<name>`).
130pub(super) fn binary_name_of(r: &ProviderReference) -> String {
131    format!("rootle-{}", r.name)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn ref_grammar() {
140        // Bare name → the convention repo.
141        assert_eq!(
142            ProviderReference::parse("gitlab").unwrap(),
143            ProviderReference {
144                repo: "rootledev/rootle-gitlab".into(),
145                name: "gitlab".into(),
146                tag: None,
147                tarball: None
148            }
149        );
150        // owner/repo keeps its name; prefix stripped for the short name.
151        assert_eq!(
152            ProviderReference::parse("rootledev/rootle-gitlab").unwrap(),
153            ProviderReference {
154                repo: "rootledev/rootle-gitlab".into(),
155                name: "gitlab".into(),
156                tag: None,
157                tarball: None
158            }
159        );
160        // Full URL.
161        assert_eq!(
162            ProviderReference::parse("https://github.com/rootledev/rootle-gitlab").unwrap(),
163            ProviderReference {
164                repo: "rootledev/rootle-gitlab".into(),
165                name: "gitlab".into(),
166                tag: None,
167                tarball: None
168            }
169        );
170        // Tag pin.
171        assert_eq!(
172            ProviderReference::parse("rootledev/rootle-gitlab@v0.1.0")
173                .unwrap()
174                .tag,
175            Some("v0.1.0".into())
176        );
177        // Bare name with tag.
178        assert_eq!(
179            ProviderReference::parse("gitlab@v0.2.0").unwrap().tag,
180            Some("v0.2.0".into())
181        );
182        // Malformed.
183        assert!(ProviderReference::parse("").is_err());
184        assert!(ProviderReference::parse("justtext/").is_err());
185        // Unprefixed repo: stem is the name.
186        assert_eq!(
187            ProviderReference::parse("someone/myprovider").unwrap().name,
188            "myprovider"
189        );
190    }
191
192    #[test]
193    fn plain_http_tarball_urls() {
194        let target = super::super::release::platform_target();
195        let url =
196            format!("https://artifacts.corp.example/providers/rootle-gitlab-0.1.0-{target}.tar.gz");
197        let r = ProviderReference::parse(&url).unwrap();
198        assert_eq!(r.name, "gitlab");
199        assert_eq!(r.tag, Some("v0.1.0".into()));
200        assert_eq!(r.tarball, Some(url.clone()));
201        // The URL is kept whole as the source (receipt provenance).
202        assert_eq!(r.repo, url);
203
204        // Versionless filename: no derived tag.
205        let bare = format!("https://artifacts.corp.example/p/rootle-gitlab-{target}.tar.gz");
206        let r = ProviderReference::parse(&bare).unwrap();
207        assert_eq!((r.name.as_str(), r.tag), ("gitlab", None));
208
209        // An explicit @tag wins over the filename.
210        let pinned = ProviderReference::parse(&format!("{url}@v9.9.9")).unwrap();
211        assert_eq!(pinned.tag, Some("v9.9.9".into()));
212
213        // Wrong platform / not a tarball: a crisp user error.
214        assert!(
215            ProviderReference::parse("https://artifacts.corp.example/rootle-gitlab.tar.gz")
216                .is_err()
217        );
218        assert!(ProviderReference::parse("https://artifacts.corp.example/rootle-gitlab").is_err());
219    }
220}