use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use super::piece::Piece;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepertoirePackage {
pub name: String,
pub source_url: String,
pub install_path: PathBuf,
pub pieces: Vec<String>,
}
pub struct RepertoireManager {
base_dir: PathBuf,
}
impl RepertoireManager {
pub fn new() -> Result<Self> {
let base_dir = dirs::home_dir()
.ok_or_else(|| anyhow!("Could not determine home directory"))?
.join(".ccswarm")
.join("repertoire");
Ok(Self { base_dir })
}
pub fn with_base_dir(base_dir: PathBuf) -> Self {
Self { base_dir }
}
pub async fn add(&self, url: &str) -> Result<RepertoirePackage> {
let name = extract_package_name(url)?;
let install_path = self.base_dir.join(&name);
if install_path.exists() {
return Err(anyhow!(
"Package '{}' is already installed at {}. Use 'repertoire remove {}' first.",
name,
install_path.display(),
name
));
}
tokio::fs::create_dir_all(&self.base_dir)
.await
.context("Failed to create repertoire directory")?;
info!("Cloning {} into {}", url, install_path.display());
let output = tokio::process::Command::new("git")
.args([
"clone",
"--depth",
"1",
url,
&install_path.to_string_lossy(),
])
.output()
.await
.context("Failed to run git clone")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("git clone failed: {}", stderr));
}
let pieces = self.discover_pieces(&install_path).await?;
let package = RepertoirePackage {
name,
source_url: url.to_string(),
install_path,
pieces,
};
self.save_metadata(&package).await?;
Ok(package)
}
pub async fn list(&self) -> Result<Vec<RepertoirePackage>> {
let mut packages = Vec::new();
if !self.base_dir.exists() {
return Ok(packages);
}
let mut entries = tokio::fs::read_dir(&self.base_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_dir() {
let metadata_path = path.join(".repertoire.json");
if metadata_path.exists() {
let content = tokio::fs::read_to_string(&metadata_path).await?;
if let Ok(package) = serde_json::from_str::<RepertoirePackage>(&content) {
packages.push(package);
}
} else {
let pieces = self.discover_pieces(&path).await.unwrap_or_default();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
packages.push(RepertoirePackage {
name,
source_url: String::new(),
install_path: path,
pieces,
});
}
}
}
Ok(packages)
}
pub async fn remove(&self, name: &str) -> Result<()> {
let install_path = self.base_dir.join(name);
if !install_path.exists() {
return Err(anyhow!("Package '{}' is not installed", name));
}
tokio::fs::remove_dir_all(&install_path)
.await
.with_context(|| format!("Failed to remove package at {}", install_path.display()))?;
info!("Removed repertoire package '{}'", name);
Ok(())
}
pub async fn load_all_pieces(&self) -> Result<Vec<Piece>> {
let mut all_pieces = Vec::new();
let packages = self.list().await?;
for package in &packages {
match self.load_pieces_from_package(package).await {
Ok(pieces) => all_pieces.extend(pieces),
Err(e) => {
warn!(
"Failed to load pieces from package '{}': {}",
package.name, e
);
}
}
}
Ok(all_pieces)
}
async fn discover_pieces(&self, dir: &Path) -> Result<Vec<String>> {
let mut pieces = Vec::new();
for search_dir in &[dir.to_path_buf(), dir.join("pieces")] {
if !search_dir.exists() {
continue;
}
let mut entries = tokio::fs::read_dir(search_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if let Some(ext) = path.extension()
&& (ext == "yaml" || ext == "yml")
&& let Some(name) = path.file_stem()
{
pieces.push(name.to_string_lossy().to_string());
}
}
}
Ok(pieces)
}
async fn load_pieces_from_package(&self, package: &RepertoirePackage) -> Result<Vec<Piece>> {
let mut pieces = Vec::new();
for search_dir in &[
package.install_path.clone(),
package.install_path.join("pieces"),
] {
if !search_dir.exists() {
continue;
}
let mut entries = tokio::fs::read_dir(search_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if let Some(ext) = path.extension()
&& (ext == "yaml" || ext == "yml")
{
let content = tokio::fs::read_to_string(&path).await?;
match serde_yaml::from_str::<Piece>(&content) {
Ok(piece) => pieces.push(piece),
Err(e) => {
warn!("Failed to parse piece at {}: {}", path.display(), e);
}
}
}
}
}
Ok(pieces)
}
async fn save_metadata(&self, package: &RepertoirePackage) -> Result<()> {
let metadata_path = package.install_path.join(".repertoire.json");
let content = serde_json::to_string_pretty(package)?;
tokio::fs::write(&metadata_path, content).await?;
Ok(())
}
}
fn extract_package_name(url: &str) -> Result<String> {
let url = url.trim_end_matches('/').trim_end_matches(".git");
url.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("Could not extract package name from URL: {}", url))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_package_name() {
assert_eq!(
extract_package_name("https://github.com/user/my-pieces").unwrap(),
"my-pieces"
);
assert_eq!(
extract_package_name("https://github.com/user/my-pieces.git").unwrap(),
"my-pieces"
);
assert_eq!(
extract_package_name("git@github.com:user/workflow-pack.git").unwrap(),
"workflow-pack"
);
}
}