use serde::{Deserialize, Serialize};
use reqwest::blocking::Client;
use chrono::{DateTime, Utc, Duration};
use crate::error::{Result, ToriiError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Package {
pub id: String,
pub name: String,
pub version: String,
pub package_type: String,
pub status: String,
pub created_at: String,
pub web_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageFile {
pub id: String,
pub package_id: String,
pub file_name: String,
pub size_bytes: u64,
pub created_at: String,
}
#[derive(Debug, Clone, Default)]
pub struct PackageListFilters {
pub package_type: Option<String>,
pub name_search: Option<String>,
pub per_page: usize,
}
#[allow(dead_code)]
pub trait PackageClient: Send {
fn list(&self, owner: &str, repo: &str, filters: &PackageListFilters) -> Result<Vec<Package>>;
fn delete(&self, owner: &str, repo: &str, id: &str) -> Result<()>;
fn list_files(&self, owner: &str, repo: &str, id: &str) -> Result<Vec<PackageFile>>;
}
pub struct GitLabPackageClient {
token: String,
base_url: String,
}
impl GitLabPackageClient {
pub fn new() -> Result<Self> {
let token = crate::auth::resolve_token("gitlab", ".").value
.ok_or_else(|| ToriiError::InvalidConfig(
"GitLab token not found. Run: torii auth set gitlab YOUR_TOKEN".to_string()
))?;
let base_url = std::env::var("GITLAB_URL")
.unwrap_or_else(|_| "https://gitlab.com/api/v4".to_string());
Ok(Self { token, base_url })
}
fn client(&self) -> Client {
Client::builder().user_agent("gitorii-cli").build().unwrap()
}
fn project_path(owner: &str, repo: &str) -> String {
crate::url::encode(&format!("{}/{}", owner, repo))
}
}
impl PackageClient for GitLabPackageClient {
fn list(&self, owner: &str, repo: &str, filters: &PackageListFilters) -> Result<Vec<Package>> {
let mut url = format!(
"{}/projects/{}/packages?per_page={}",
self.base_url, Self::project_path(owner, repo),
filters.per_page.clamp(1, 100)
);
if let Some(t) = &filters.package_type {
url.push_str(&format!("&package_type={}", t));
}
if let Some(n) = &filters.name_search {
url.push_str(&format!("&package_name={}", crate::url::encode(n)));
}
let resp = self.client().get(&url)
.header("PRIVATE-TOKEN", &self.token)
.send()
.map_err(|e| ToriiError::InvalidConfig(format!("GitLab API error: {}", e)))?;
let status = resp.status();
let json: serde_json::Value = resp.json()
.map_err(|e| ToriiError::InvalidConfig(format!("GitLab API parse error: {}", e)))?;
if !status.is_success() {
let msg = json["message"].as_str()
.or_else(|| json["error"].as_str())
.unwrap_or("(no message)");
return Err(ToriiError::InvalidConfig(format!(
"GitLab API {}: {} (url: {})", status, msg, url
)));
}
let arr = json.as_array()
.ok_or_else(|| ToriiError::InvalidConfig(format!(
"GitLab returned non-array for {}. Body: {}", url, json
)))?;
arr.iter().map(parse_gitlab_package).collect()
}
fn delete(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
let url = format!(
"{}/projects/{}/packages/{}",
self.base_url, Self::project_path(owner, repo), id
);
let resp = self.client().delete(&url)
.header("PRIVATE-TOKEN", &self.token)
.send()
.map_err(|e| ToriiError::InvalidConfig(format!("GitLab API error: {}", e)))?;
if !resp.status().is_success() {
let s = resp.status();
let body = resp.text().unwrap_or_default();
return Err(ToriiError::InvalidConfig(format!(
"GitLab API {} delete failed: {}", s, body
)));
}
Ok(())
}
fn list_files(&self, owner: &str, repo: &str, id: &str) -> Result<Vec<PackageFile>> {
let url = format!(
"{}/projects/{}/packages/{}/package_files?per_page=100",
self.base_url, Self::project_path(owner, repo), id
);
let resp = self.client().get(&url)
.header("PRIVATE-TOKEN", &self.token)
.send()
.map_err(|e| ToriiError::InvalidConfig(format!("GitLab API error: {}", e)))?;
let status = resp.status();
let json: serde_json::Value = resp.json()
.map_err(|e| ToriiError::InvalidConfig(format!("GitLab API parse error: {}", e)))?;
if !status.is_success() {
let msg = json["message"].as_str().unwrap_or("(no message)");
return Err(ToriiError::InvalidConfig(format!(
"GitLab API {}: {} (url: {})", status, msg, url
)));
}
let arr = json.as_array()
.ok_or_else(|| ToriiError::InvalidConfig(format!(
"GitLab returned non-array for {}. Body: {}", url, json
)))?;
arr.iter().map(|v| parse_gitlab_package_file(v, id)).collect()
}
}
fn parse_gitlab_package(v: &serde_json::Value) -> Result<Package> {
let id = v["id"].as_u64().map(|n| n.to_string())
.or_else(|| v["id"].as_str().map(String::from))
.ok_or_else(|| ToriiError::InvalidConfig("GitLab package missing id".into()))?;
Ok(Package {
id,
name: v["name"].as_str().unwrap_or("").to_string(),
version: v["version"].as_str().unwrap_or("").to_string(),
package_type: v["package_type"].as_str().unwrap_or("").to_string(),
status: v["status"].as_str().unwrap_or("").to_string(),
created_at: v["created_at"].as_str().unwrap_or("").to_string(),
web_url: v["_links"]["web_path"].as_str().unwrap_or("").to_string(),
})
}
fn parse_gitlab_package_file(v: &serde_json::Value, package_id: &str) -> Result<PackageFile> {
let id = v["id"].as_u64().map(|n| n.to_string())
.or_else(|| v["id"].as_str().map(String::from))
.ok_or_else(|| ToriiError::InvalidConfig("GitLab package_file missing id".into()))?;
Ok(PackageFile {
id,
package_id: package_id.to_string(),
file_name: v["file_name"].as_str().unwrap_or("").to_string(),
size_bytes: v["size"].as_u64().unwrap_or(0),
created_at: v["created_at"].as_str().unwrap_or("").to_string(),
})
}
pub fn get_package_client(platform: &str) -> Result<Box<dyn PackageClient>> {
match platform.to_lowercase().as_str() {
"gitlab" => Ok(Box::new(GitLabPackageClient::new()?)),
"github" => Err(ToriiError::InvalidConfig(
"GitHub doesn't have a Generic Package Registry equivalent to GitLab's. \
Binary release assets on GitHub are managed through Releases: use `torii release` instead.".to_string()
)),
other => Err(ToriiError::InvalidConfig(
format!("Unsupported platform: {}. Supported for `torii package`: gitlab", other)
)),
}
}
pub fn filter_older_than(packages: Vec<Package>, days: i64) -> Vec<Package> {
let cutoff = Utc::now() - Duration::days(days);
packages.into_iter().filter(|p| {
match DateTime::parse_from_rfc3339(&p.created_at) {
Ok(dt) => dt.with_timezone(&Utc) < cutoff,
Err(_) => true,
}
}).collect()
}
pub fn filter_by_version(packages: Vec<Package>, version: &str) -> Vec<Package> {
packages.into_iter().filter(|p| p.version == version).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_gitlab_package_basic() {
let json = serde_json::json!({
"id": 12345u64,
"name": "gitorii",
"version": "v0.7.9",
"package_type": "generic",
"status": "default",
"created_at": "2026-05-19T22:00:00Z",
"_links": { "web_path": "/paskidev/gitorii/-/packages/12345" }
});
let p = parse_gitlab_package(&json).unwrap();
assert_eq!(p.id, "12345");
assert_eq!(p.name, "gitorii");
assert_eq!(p.version, "v0.7.9");
assert_eq!(p.package_type, "generic");
}
#[test]
fn parse_gitlab_package_file_basic() {
let json = serde_json::json!({
"id": 99u64,
"file_name": "torii-linux-x86_64",
"size": 20221192u64,
"created_at": "2026-05-19T22:00:00Z"
});
let pf = parse_gitlab_package_file(&json, "12345").unwrap();
assert_eq!(pf.id, "99");
assert_eq!(pf.package_id, "12345");
assert_eq!(pf.file_name, "torii-linux-x86_64");
assert_eq!(pf.size_bytes, 20221192);
}
fn mk(v: &str, created: &str) -> Package {
Package {
id: "1".into(),
name: "gitorii".into(),
version: v.into(),
package_type: "generic".into(),
status: "default".into(),
created_at: created.into(),
web_url: String::new(),
}
}
#[test]
fn filter_older_than_keeps_old_drops_recent_keeps_unparseable() {
let now = Utc::now();
let recent = (now - Duration::days(2)).to_rfc3339();
let ancient = (now - Duration::days(100)).to_rfc3339();
let kept = filter_older_than(vec![
mk("v0.7.0", &recent),
mk("v0.1.0", &ancient),
mk("v?.?.?", "not a date"),
], 30);
assert_eq!(kept.len(), 2);
assert!(kept.iter().any(|p| p.version == "v0.1.0"));
assert!(kept.iter().any(|p| p.version == "v?.?.?"));
}
}