Skip to main content

torii_lib/platforms/
package.rs

1//! GitLab Package Registry management (`torii package …`).
2//!
3//! gitorii's release pipeline uploads cross-compiled binaries into
4//! GitLab's Generic Package Registry under `gitorii/v0.7.X/…`. Over
5//! time these accumulate and eat the namespace's storage quota
6//! (free tier: 5 GB). This module is the CLI surface to inspect and
7//! prune them.
8//!
9//! GitHub doesn't have a directly equivalent Package Registry for
10//! Generic binaries — its binary distribution model is Release
11//! Assets attached to Releases. That lives in `release.rs` (0.7.10).
12//! On GitHub-detected projects, the factory here errors out and
13//! points the user at `torii release`.
14
15use super::gitlab::GitLabPackageClient;
16use crate::error::{Result, ToriiError};
17use chrono::{DateTime, Duration, Utc};
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Package {
22    pub id: String,
23    pub name: String,
24    pub version: String,
25    /// GitLab package type: generic | npm | maven | conan | pypi | composer | nuget | helm
26    pub package_type: String,
27    pub status: String,
28    pub created_at: String,
29    pub web_url: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PackageFile {
34    pub id: String,
35    pub package_id: String,
36    pub file_name: String,
37    pub size_bytes: u64,
38    pub created_at: String,
39}
40
41#[derive(Debug, Clone, Default)]
42pub struct PackageListFilters {
43    /// Filter by package type (e.g. "generic" — what our release pipeline uses).
44    pub package_type: Option<String>,
45    /// Substring match on package name.
46    pub name_search: Option<String>,
47    /// Page size, clamped to [1, 100].
48    pub per_page: usize,
49}
50
51#[allow(dead_code)]
52pub trait PackageClient: Send {
53    fn list(&self, owner: &str, repo: &str, filters: &PackageListFilters) -> Result<Vec<Package>>;
54    fn delete(&self, owner: &str, repo: &str, id: &str) -> Result<()>;
55    fn list_files(&self, owner: &str, repo: &str, id: &str) -> Result<Vec<PackageFile>>;
56}
57
58// ============================================================================
59// GitLab Package Registry
60// ============================================================================
61
62pub fn get_package_client(platform: &str) -> Result<Box<dyn PackageClient>> {
63    match platform.to_lowercase().as_str() {
64        "gitlab"    => Ok(Box::new(GitLabPackageClient::new()?)),
65        "github"    => Err(ToriiError::Unsupported("GitHub doesn't have a Generic Package Registry equivalent to GitLab's. \
66             Binary release assets on GitHub are managed through Releases: use `torii release` instead.".to_string())),
67        "gitea"     => Err(ToriiError::Unsupported("Gitea/Codeberg has a Package Registry but its API isn't wired into torii yet. \
68             For binary assets, use Releases (see `torii release`).".to_string())),
69        "sourcehut" => Err(ToriiError::Unsupported("Sourcehut has no Package Registry concept. Binaries are distributed via the \
70             project's own homepage or builds.sr.ht's `triggers` (uploaded externally).".to_string())),
71        "radicle"   => Err(ToriiError::Unsupported("Radicle is peer-to-peer and has no central package registry. \
72             Distribute binaries via the project's own channel or mirror to a registry host.".to_string())),
73        "bitbucket" => Err(ToriiError::Unsupported("Bitbucket Cloud has no Package Registry. Binary distribution happens via the \
74             Downloads tab (flat file list) or external hosting.".to_string())),
75        "azure"     => Err(ToriiError::Unsupported("Azure Artifacts exists but lives at the organisation level (feeds), not per-repo. \
76             The mapping isn't 1:1 with torii's owner/repo abstraction. Wired in a future release \
77             once the org-feed-package addressing is designed; for now use the Azure DevOps UI \
78             (https://dev.azure.com/{org}/_packaging).".to_string())),
79        other => Err(ToriiError::Unsupported(format!("Unsupported platform: {}. Supported for `torii package`: gitlab", other))),
80    }
81}
82
83/// Filter packages older than N days (i.e. KEEP only the ones older
84/// than the cutoff — used by batch-delete to figure out which old
85/// entries to remove). Conservative: packages with unparseable
86/// timestamps are KEPT (we don't auto-delete state we can't reason
87/// about) — matches `pipeline::filter_older_than` semantics.
88pub fn filter_older_than(packages: Vec<Package>, days: i64) -> Vec<Package> {
89    let cutoff = Utc::now() - Duration::days(days);
90    packages
91        .into_iter()
92        .filter(|p| match DateTime::parse_from_rfc3339(&p.created_at) {
93            Ok(dt) => dt.with_timezone(&Utc) < cutoff,
94            Err(_) => true,
95        })
96        .collect()
97}
98
99/// Filter packages matching exact version. Used by batch delete.
100pub fn filter_by_version(packages: Vec<Package>, version: &str) -> Vec<Package> {
101    packages
102        .into_iter()
103        .filter(|p| p.version == version)
104        .collect()
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::platforms::gitlab::package::{parse_gitlab_package, parse_gitlab_package_file};
111
112    #[test]
113    fn parse_gitlab_package_basic() {
114        let json = serde_json::json!({
115            "id": 12345u64,
116            "name": "gitorii",
117            "version": "v0.7.9",
118            "package_type": "generic",
119            "status": "default",
120            "created_at": "2026-05-19T22:00:00Z",
121            "_links": { "web_path": "/paskidev/gitorii/-/packages/12345" }
122        });
123        let p = parse_gitlab_package(&json).unwrap();
124        assert_eq!(p.id, "12345");
125        assert_eq!(p.name, "gitorii");
126        assert_eq!(p.version, "v0.7.9");
127        assert_eq!(p.package_type, "generic");
128    }
129
130    #[test]
131    fn parse_gitlab_package_file_basic() {
132        let json = serde_json::json!({
133            "id": 99u64,
134            "file_name": "torii-linux-x86_64",
135            "size": 20221192u64,
136            "created_at": "2026-05-19T22:00:00Z"
137        });
138        let pf = parse_gitlab_package_file(&json, "12345").unwrap();
139        assert_eq!(pf.id, "99");
140        assert_eq!(pf.package_id, "12345");
141        assert_eq!(pf.file_name, "torii-linux-x86_64");
142        assert_eq!(pf.size_bytes, 20221192);
143    }
144
145    fn mk(v: &str, created: &str) -> Package {
146        Package {
147            id: "1".into(),
148            name: "gitorii".into(),
149            version: v.into(),
150            package_type: "generic".into(),
151            status: "default".into(),
152            created_at: created.into(),
153            web_url: String::new(),
154        }
155    }
156
157    #[test]
158    fn filter_older_than_keeps_old_drops_recent_keeps_unparseable() {
159        let now = Utc::now();
160        let recent = (now - Duration::days(2)).to_rfc3339();
161        let ancient = (now - Duration::days(100)).to_rfc3339();
162        let kept = filter_older_than(
163            vec![
164                mk("v0.7.0", &recent),
165                mk("v0.1.0", &ancient),
166                mk("v?.?.?", "not a date"),
167            ],
168            30,
169        );
170        // filter_older_than returns entries OLDER than the cutoff (i.e.
171        // the ones we'd be willing to delete). 2-day-old recent → not
172        // older → dropped. 100-day-old ancient → older → kept. Unparseable
173        // timestamp → kept (conservative: don't delete what we can't
174        // reason about).
175        assert_eq!(kept.len(), 2);
176        assert!(kept.iter().any(|p| p.version == "v0.1.0"));
177        assert!(kept.iter().any(|p| p.version == "v?.?.?"));
178    }
179}