use crate::packs::dependency_graph::DependencyGraph;
use crate::packs::installer::{InstallOptions, PackInstaller};
use crate::packs::repository::{FileSystemRepository, PackRepository};
use crate::packs::types::{CompositionStrategy, Pack};
use ggen_utils::error::{Error, Result};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Instant;
use tracing::{info, warn};
pub struct PackComposer {
repository: Box<dyn PackRepository>,
}
impl PackComposer {
pub fn new(repository: Box<dyn PackRepository>) -> Self {
Self { repository }
}
pub fn with_default_repo() -> Result<Self> {
let repo = FileSystemRepository::discover()?;
Ok(Self::new(Box::new(repo)))
}
pub async fn compose(
&self,
pack_ids: &[String],
project_name: &str,
options: &CompositionOptions,
) -> Result<CompositionResult> {
let start = Instant::now();
if pack_ids.is_empty() {
return Err(Error::new(
"At least one pack ID must be specified for composition",
));
}
info!("Starting composition of {} packs", pack_ids.len());
let mut packs = Vec::new();
for pack_id in pack_ids {
let pack = self.repository.load(pack_id).await?;
packs.push(pack);
}
let graph = DependencyGraph::from_packs(&packs)?;
let composition_order = graph.topological_sort()?;
info!("Composition order: {:?}", composition_order);
let conflicts = self.detect_composition_conflicts(&packs);
if !conflicts.is_empty() {
warn!("Detected {} conflict(s) during composition", conflicts.len());
for conflict in &conflicts {
warn!(" - {}", conflict);
}
if !options.force_composition {
return Err(Error::new(&format!(
"Composition conflicts detected:\n{}",
conflicts.join("\n")
)));
}
}
let composed_pack = match options.strategy {
CompositionStrategy::Merge => self.merge_packs(&packs, &composition_order)?,
CompositionStrategy::Layer => self.layer_packs(&packs, &composition_order)?,
CompositionStrategy::Custom(ref rules) => {
self.custom_composition(&packs, &composition_order, rules)?
}
};
let plan = self.generate_composition_plan(&packs, &composed_pack, &composition_order);
let output_path = options.output_dir.clone().unwrap_or_else(|| {
PathBuf::from(project_name)
});
if !options.dry_run {
tokio::fs::create_dir_all(&output_path).await?;
}
let duration = start.elapsed();
info!("✓ Composition completed in {:?}", duration);
Ok(CompositionResult {
project_name: project_name.to_string(),
packs_composed: pack_ids.to_vec(),
composed_pack,
composition_order,
conflicts,
plan,
output_path,
duration,
})
}
fn merge_packs(&self, packs: &[Pack], order: &[String]) -> Result<Pack> {
if packs.is_empty() {
return Err(Error::new("No packs to merge"));
}
let first = &packs[0];
let mut merged = Pack {
id: format!("composed-{}", first.id),
name: format!("Composed Pack"),
version: "1.0.0".to_string(),
description: format!("Composed from {} packs", packs.len()),
category: first.category.clone(),
author: first.author.clone(),
repository: None,
license: first.license.clone(),
packages: Vec::new(),
templates: Vec::new(),
sparql_queries: HashMap::new(),
dependencies: Vec::new(),
tags: Vec::new(),
keywords: Vec::new(),
production_ready: packs.iter().all(|p| p.production_ready),
metadata: first.metadata.clone(),
};
let mut seen_packages = HashSet::new();
let mut seen_templates = HashSet::new();
for pack_id in order {
if let Some(pack) = packs.iter().find(|p| p.id == *pack_id) {
for package in &pack.packages {
if seen_packages.insert(package.clone()) {
merged.packages.push(package.clone());
}
}
for template in &pack.templates {
if seen_templates.insert(template.name.clone()) {
merged.templates.push(template.clone());
}
}
merged.sparql_queries.extend(pack.sparql_queries.clone());
for tag in &pack.tags {
if !merged.tags.contains(tag) {
merged.tags.push(tag.clone());
}
}
for keyword in &pack.keywords {
if !merged.keywords.contains(keyword) {
merged.keywords.push(keyword.clone());
}
}
}
}
Ok(merged)
}
fn layer_packs(&self, packs: &[Pack], order: &[String]) -> Result<Pack> {
self.merge_packs(packs, order)
}
fn custom_composition(
&self,
packs: &[Pack],
order: &[String],
_rules: &HashMap<String, serde_json::Value>,
) -> Result<Pack> {
self.merge_packs(packs, order)
}
fn detect_composition_conflicts(&self, packs: &[Pack]) -> Vec<String> {
let mut conflicts = Vec::new();
let mut package_sources: HashMap<String, Vec<String>> = HashMap::new();
for pack in packs {
for package in &pack.packages {
package_sources
.entry(package.clone())
.or_insert_with(Vec::new)
.push(pack.name.clone());
}
}
for (package, sources) in package_sources {
if sources.len() > 1 {
conflicts.push(format!(
"Package '{}' provided by: {}",
package,
sources.join(", ")
));
}
}
let mut template_sources: HashMap<String, Vec<String>> = HashMap::new();
for pack in packs {
for template in &pack.templates {
template_sources
.entry(template.name.clone())
.or_insert_with(Vec::new)
.push(pack.name.clone());
}
}
for (template, sources) in template_sources {
if sources.len() > 1 {
conflicts.push(format!(
"Template '{}' provided by: {}",
template,
sources.join(", ")
));
}
}
conflicts
}
fn generate_composition_plan(
&self,
packs: &[Pack],
composed: &Pack,
order: &[String],
) -> CompositionPlan {
let steps = order
.iter()
.map(|pack_id| {
let pack = packs.iter().find(|p| p.id == *pack_id).unwrap();
CompositionStep {
pack_id: pack.id.clone(),
pack_name: pack.name.clone(),
packages_to_add: pack.packages.len(),
templates_to_add: pack.templates.len(),
}
})
.collect();
CompositionPlan {
total_packs: packs.len(),
total_packages: composed.packages.len(),
total_templates: composed.templates.len(),
composition_order: order.to_vec(),
steps,
}
}
pub async fn install_composed(
&self,
composition_result: &CompositionResult,
install_options: &InstallOptions,
) -> Result<()> {
let installer = PackInstaller::new(self.repository.clone());
self.repository
.save(&composition_result.composed_pack)
.await?;
let report = installer
.install(&composition_result.composed_pack.id, install_options)
.await?;
info!("{}", report.summary());
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CompositionOptions {
pub strategy: CompositionStrategy,
pub output_dir: Option<PathBuf>,
pub force_composition: bool,
pub dry_run: bool,
}
impl Default for CompositionOptions {
fn default() -> Self {
Self {
strategy: CompositionStrategy::Merge,
output_dir: None,
force_composition: false,
dry_run: false,
}
}
}
#[derive(Debug, Clone)]
pub struct CompositionResult {
pub project_name: String,
pub packs_composed: Vec<String>,
pub composed_pack: Pack,
pub composition_order: Vec<String>,
pub conflicts: Vec<String>,
pub plan: CompositionPlan,
pub output_path: PathBuf,
pub duration: std::time::Duration,
}
#[derive(Debug, Clone)]
pub struct CompositionPlan {
pub total_packs: usize,
pub total_packages: usize,
pub total_templates: usize,
pub composition_order: Vec<String>,
pub steps: Vec<CompositionStep>,
}
#[derive(Debug, Clone)]
pub struct CompositionStep {
pub pack_id: String,
pub pack_name: String,
pub packages_to_add: usize,
pub templates_to_add: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::packs::types::{PackDependency, PackMetadata, PackTemplate};
fn create_test_pack(id: &str, packages: Vec<&str>, templates: Vec<&str>) -> Pack {
Pack {
id: id.to_string(),
name: format!("Pack {}", id),
version: "1.0.0".to_string(),
description: format!("Test pack {}", id),
category: "test".to_string(),
author: None,
repository: None,
license: None,
packages: packages.into_iter().map(|s| s.to_string()).collect(),
templates: templates
.into_iter()
.map(|name| PackTemplate {
name: name.to_string(),
path: format!("templates/{}.tmpl", name),
description: format!("Template {}", name),
variables: vec![],
})
.collect(),
sparql_queries: HashMap::new(),
dependencies: vec![],
tags: vec![],
keywords: vec![],
production_ready: true,
metadata: PackMetadata::default(),
}
}
#[test]
fn test_composition_options_default() {
let opts = CompositionOptions::default();
assert!(!opts.force_composition);
assert!(!opts.dry_run);
assert!(opts.output_dir.is_none());
}
#[test]
fn test_detect_package_conflicts() {
let packs = vec![
create_test_pack("pack1", vec!["pkg1", "pkg2"], vec![]),
create_test_pack("pack2", vec!["pkg2", "pkg3"], vec![]),
];
let composer = PackComposer::new(Box::new(
FileSystemRepository::new(PathBuf::from("/tmp"))
));
let conflicts = composer.detect_composition_conflicts(&packs);
assert_eq!(conflicts.len(), 1);
assert!(conflicts[0].contains("pkg2"));
}
#[test]
fn test_detect_template_conflicts() {
let packs = vec![
create_test_pack("pack1", vec![], vec!["main", "config"]),
create_test_pack("pack2", vec![], vec!["config", "utils"]),
];
let composer = PackComposer::new(Box::new(
FileSystemRepository::new(PathBuf::from("/tmp"))
));
let conflicts = composer.detect_composition_conflicts(&packs);
assert_eq!(conflicts.len(), 1);
assert!(conflicts[0].contains("config"));
}
}