use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageCollection {
pub name: String,
pub description: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
pub packages: Vec<PackageMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageMetadata {
pub name: String,
pub version: String,
pub summary: String,
pub repository: String,
pub platforms: Vec<String>,
pub keywords: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionInfo {
pub url: String,
pub name: String,
pub added_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_refresh: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cached_at: Option<String>,
}
pub struct CollectionManager {
ccgo_home: PathBuf,
}
impl CollectionManager {
pub fn new() -> Self {
Self {
ccgo_home: Self::get_ccgo_home(),
}
}
fn get_ccgo_home() -> PathBuf {
if let Ok(home) = std::env::var("CCGO_HOME") {
PathBuf::from(home)
} else if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(".ccgo")
} else {
PathBuf::from(".ccgo")
}
}
fn collections_dir(&self) -> PathBuf {
self.ccgo_home.join("collections")
}
fn index_file(&self) -> PathBuf {
self.collections_dir().join("index.json")
}
fn cache_file(&self, collection_name: &str) -> PathBuf {
self.collections_dir()
.join("cache")
.join(format!("{}.json", collection_name))
}
fn ensure_dirs(&self) -> Result<()> {
let collections_dir = self.collections_dir();
if !collections_dir.exists() {
fs::create_dir_all(&collections_dir)
.context("Failed to create collections directory")?;
}
let cache_dir = collections_dir.join("cache");
if !cache_dir.exists() {
fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
}
Ok(())
}
pub fn load_index(&self) -> Result<Vec<CollectionInfo>> {
let index_file = self.index_file();
if !index_file.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&index_file).context("Failed to read index file")?;
let collections: Vec<CollectionInfo> =
serde_json::from_str(&content).context("Failed to parse index file")?;
Ok(collections)
}
pub fn save_index(&self, collections: &[CollectionInfo]) -> Result<()> {
self.ensure_dirs()?;
let content =
serde_json::to_string_pretty(collections).context("Failed to serialize index")?;
fs::write(self.index_file(), content).context("Failed to write index file")?;
Ok(())
}
pub fn add_collection(&self, url: &str) -> Result<CollectionInfo> {
self.ensure_dirs()?;
let mut collections = self.load_index()?;
if collections.iter().any(|c| c.url == url) {
bail!("Collection already exists: {}", url);
}
let collection = self.fetch_collection(url)?;
let info = CollectionInfo {
url: url.to_string(),
name: collection.name.clone(),
added_at: chrono::Local::now().to_rfc3339(),
last_refresh: Some(chrono::Local::now().to_rfc3339()),
cached_at: Some(chrono::Local::now().to_rfc3339()),
};
self.save_collection_cache(&info.name, &collection)?;
collections.push(info.clone());
self.save_index(&collections)?;
Ok(info)
}
pub fn remove_collection(&self, name_or_url: &str) -> Result<()> {
let mut collections = self.load_index()?;
let pos = collections
.iter()
.position(|c| c.name == name_or_url || c.url == name_or_url)
.with_context(|| format!("Collection not found: {}", name_or_url))?;
let removed = collections.remove(pos);
let cache_file = self.cache_file(&removed.name);
if cache_file.exists() {
fs::remove_file(&cache_file).context("Failed to remove cache file")?;
}
self.save_index(&collections)?;
Ok(())
}
pub fn refresh_collection(&self, name_or_url: &str) -> Result<CollectionInfo> {
let mut collections = self.load_index()?;
let info = collections
.iter_mut()
.find(|c| c.name == name_or_url || c.url == name_or_url)
.with_context(|| format!("Collection not found: {}", name_or_url))?;
let url = info.url.clone();
let collection = self.fetch_collection(&url)?;
info.last_refresh = Some(chrono::Local::now().to_rfc3339());
info.cached_at = Some(chrono::Local::now().to_rfc3339());
let name = info.name.clone();
self.save_collection_cache(&name, &collection)?;
let result = info.clone();
self.save_index(&collections)?;
Ok(result)
}
pub fn refresh_all(&self) -> Result<Vec<(String, Result<()>)>> {
let collections = self.load_index()?;
let mut results = Vec::new();
for info in &collections {
let result = self.refresh_collection(&info.name).map(|_| ());
results.push((info.name.clone(), result));
}
Ok(results)
}
fn fetch_collection(&self, url: &str) -> Result<PackageCollection> {
if url.starts_with("file://")
|| (!url.starts_with("http://") && !url.starts_with("https://"))
{
let path = url.strip_prefix("file://").unwrap_or(url);
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read collection from {}", path))?;
let collection: PackageCollection =
serde_json::from_str(&content).context("Failed to parse collection JSON")?;
return Ok(collection);
}
self.fetch_collection_http(url)
}
fn fetch_collection_http(&self, url: &str) -> Result<PackageCollection> {
use std::time::Duration;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.user_agent(format!("ccgo/{}", env!("CARGO_PKG_VERSION")))
.build()
.context("Failed to create HTTP client")?;
let response = client
.get(url)
.send()
.with_context(|| format!("Failed to fetch collection from {}", url))?;
let status = response.status();
if !status.is_success() {
bail!(
"Failed to fetch collection from {}: HTTP {}",
url,
status.as_u16()
);
}
let collection: PackageCollection = response
.json()
.with_context(|| format!("Failed to parse collection JSON from {}", url))?;
Ok(collection)
}
fn save_collection_cache(&self, name: &str, collection: &PackageCollection) -> Result<()> {
let cache_file = self.cache_file(name);
let content =
serde_json::to_string_pretty(collection).context("Failed to serialize collection")?;
fs::write(&cache_file, content).context("Failed to write cache file")?;
Ok(())
}
pub fn load_collection(&self, name: &str) -> Result<PackageCollection> {
let cache_file = self.cache_file(name);
if !cache_file.exists() {
bail!(
"Collection cache not found: {}. Try refreshing with 'ccgo collection refresh {}'",
name,
name
);
}
let content = fs::read_to_string(&cache_file).context("Failed to read cache file")?;
let collection: PackageCollection =
serde_json::from_str(&content).context("Failed to parse cached collection")?;
Ok(collection)
}
pub fn load_all_collections(&self) -> Result<Vec<(CollectionInfo, PackageCollection)>> {
let index = self.load_index()?;
let mut results = Vec::new();
for info in index {
match self.load_collection(&info.name) {
Ok(collection) => results.push((info, collection)),
Err(e) => {
eprintln!("⚠️ Failed to load collection '{}': {}", info.name, e);
}
}
}
Ok(results)
}
pub fn search(
&self,
query: &str,
collection_filter: Option<&str>,
) -> Result<Vec<(String, PackageMetadata)>> {
let collections = self.load_all_collections()?;
let mut results = Vec::new();
let query_lower = query.to_lowercase();
for (info, collection) in collections {
if let Some(filter) = collection_filter {
if info.name != filter {
continue;
}
}
for package in collection.packages {
let matches = package.name.to_lowercase().contains(&query_lower)
|| package.summary.to_lowercase().contains(&query_lower)
|| package
.keywords
.iter()
.any(|k| k.to_lowercase().contains(&query_lower));
if matches {
results.push((info.name.clone(), package));
}
}
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collection_serialization() {
let collection = PackageCollection {
name: "test-collection".to_string(),
description: "Test collection".to_string(),
version: "1.0.0".to_string(),
author: Some("Test Author".to_string()),
homepage: None,
packages: vec![PackageMetadata {
name: "json".to_string(),
version: "3.11.0".to_string(),
summary: "JSON for Modern C++".to_string(),
repository: "https://github.com/nlohmann/json".to_string(),
platforms: vec!["all".to_string()],
keywords: vec!["json".to_string(), "parser".to_string()],
license: Some("MIT".to_string()),
homepage: None,
}],
};
let json = serde_json::to_string_pretty(&collection).unwrap();
let deserialized: PackageCollection = serde_json::from_str(&json).unwrap();
assert_eq!(collection.name, deserialized.name);
assert_eq!(collection.packages.len(), deserialized.packages.len());
}
#[test]
fn test_collection_info_serialization() {
let info = CollectionInfo {
url: "https://example.com/collection.json".to_string(),
name: "example".to_string(),
added_at: "2026-01-17T00:00:00Z".to_string(),
last_refresh: None,
cached_at: None,
};
let json = serde_json::to_string(&info).unwrap();
let deserialized: CollectionInfo = serde_json::from_str(&json).unwrap();
assert_eq!(info.url, deserialized.url);
assert_eq!(info.name, deserialized.name);
}
}