use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use crate::category::{CategoryClassifier, ClassificationMode};
use crate::error::{DotAgentError, Result};
use super::{ProfileManager, ProfileMetadata};
#[derive(Debug, Clone)]
pub struct FusionSpec {
pub profile_name: String,
pub category: String,
}
impl FusionSpec {
pub fn parse(s: &str) -> Result<Self> {
let parts: Vec<&str> = s.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(DotAgentError::ConfigParseSimple {
message: format!("Invalid fusion spec: '{}'. Expected 'profile:category'", s),
});
}
Ok(Self {
profile_name: parts[0].to_string(),
category: parts[1].to_string(),
})
}
}
#[derive(Debug, Clone)]
pub struct FusionConflict {
pub path: PathBuf,
pub sources: Vec<FusionSpec>,
}
#[derive(Debug, Clone)]
pub struct FusionResult {
pub profile_name: String,
pub files_copied: usize,
pub conflicts: Vec<FusionConflict>,
pub contributions: HashMap<String, usize>,
}
#[derive(Debug, Clone)]
pub struct FusionConfig {
pub mode: ClassificationMode,
pub force: bool,
pub include_uncategorized: bool,
pub dry_run: bool,
}
impl Default for FusionConfig {
fn default() -> Self {
Self {
mode: ClassificationMode::Glob,
force: false,
include_uncategorized: false,
dry_run: false,
}
}
}
#[derive(Debug, Clone)]
pub struct CollectedFile {
pub src_path: PathBuf,
pub dest_path: String,
pub source_profile: String,
}
#[derive(Debug, Clone)]
pub struct FusionPlan {
pub files: Vec<CollectedFile>,
pub conflicts: Vec<FusionConflict>,
pub contributions: HashMap<String, usize>,
}
pub struct FusionExecutor {
specs: Vec<FusionSpec>,
config: FusionConfig,
}
impl FusionExecutor {
pub fn new(specs: Vec<FusionSpec>, config: FusionConfig) -> Self {
Self { specs, config }
}
pub fn plan(&self, manager: &ProfileManager) -> Result<FusionPlan> {
if self.specs.is_empty() {
return Err(DotAgentError::ConfigParseSimple {
message: "No fusion specs provided".to_string(),
});
}
let mut all_files: HashMap<String, CollectedFile> = HashMap::new();
let mut conflicts: Vec<FusionConflict> = Vec::new();
let mut contributions: HashMap<String, usize> = HashMap::new();
for (idx, spec) in self.specs.iter().enumerate() {
let profile = manager.get_profile(&spec.profile_name)?;
let classifier = CategoryClassifier::from_profile(&profile, self.config.mode)?;
let result = classifier.classify(&profile)?;
if classifier.get_category(&spec.category).is_none() {
return Err(DotAgentError::CategoryNotFound {
name: spec.category.clone(),
});
}
let files = result.files_in_category(&spec.category);
let file_count = files.len();
for file in files {
let dest_key = file.to_string_lossy().to_string();
let src_path = profile.path.join(file);
if let Some(existing) = all_files.get(&dest_key) {
let conflict_entry = conflicts
.iter_mut()
.find(|c| c.path.to_string_lossy() == dest_key);
if let Some(conflict) = conflict_entry {
conflict.sources.push(spec.clone());
} else {
conflicts.push(FusionConflict {
path: PathBuf::from(&dest_key),
sources: vec![
FusionSpec {
profile_name: existing.source_profile.clone(),
category: spec.category.clone(),
},
spec.clone(),
],
});
}
}
all_files.insert(
dest_key.clone(),
CollectedFile {
src_path,
dest_path: dest_key,
source_profile: spec.profile_name.clone(),
},
);
}
*contributions.entry(spec.profile_name.clone()).or_insert(0) += file_count;
if idx == 0 && self.config.include_uncategorized {
let uncategorized = result.uncategorized();
for file in uncategorized {
let dest_key = file.to_string_lossy().to_string();
let src_path = profile.path.join(file);
all_files
.entry(dest_key.clone())
.or_insert_with(|| CollectedFile {
src_path,
dest_path: dest_key,
source_profile: spec.profile_name.clone(),
});
}
}
}
let mut files: Vec<_> = all_files.into_values().collect();
files.sort_by(|a, b| a.dest_path.cmp(&b.dest_path));
Ok(FusionPlan {
files,
conflicts,
contributions,
})
}
pub fn execute(&self, manager: &ProfileManager, output_name: &str) -> Result<FusionResult> {
let plan = self.plan(manager)?;
self.execute_plan(&plan, manager, output_name)
}
pub fn execute_plan(
&self,
plan: &FusionPlan,
manager: &ProfileManager,
output_name: &str,
) -> Result<FusionResult> {
let output_exists = manager.get_profile(output_name).is_ok();
if output_exists && !self.config.force {
return Err(DotAgentError::ProfileAlreadyExists {
name: output_name.to_string(),
});
}
if self.config.dry_run {
return Ok(FusionResult {
profile_name: output_name.to_string(),
files_copied: 0,
conflicts: plan.conflicts.clone(),
contributions: plan.contributions.clone(),
});
}
let output_path = manager.profiles_dir().join(output_name);
if output_path.exists() && self.config.force {
fs::remove_dir_all(&output_path)?;
}
fs::create_dir_all(&output_path)?;
let mut copied = 0;
for file in &plan.files {
let dest_path = output_path.join(&file.dest_path);
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("Failed to create directory {:?}: {}", parent, e),
)
})?;
}
fs::copy(&file.src_path, &dest_path).map_err(|e| {
std::io::Error::new(
e.kind(),
format!(
"Failed to copy {:?} -> {:?}: {}",
file.src_path, dest_path, e
),
)
})?;
copied += 1;
}
let metadata = ProfileMetadata::new_local(output_name);
metadata.save(&output_path)?;
Ok(FusionResult {
profile_name: output_name.to_string(),
files_copied: copied,
conflicts: plan.conflicts.clone(),
contributions: plan.contributions.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fusion_spec_parse() {
let spec = FusionSpec::parse("my-profile:plan").unwrap();
assert_eq!(spec.profile_name, "my-profile");
assert_eq!(spec.category, "plan");
assert!(FusionSpec::parse("invalid").is_err());
}
#[test]
fn test_fusion_spec_parse_with_colon_in_name() {
let spec = FusionSpec::parse("my:profile:plan").unwrap();
assert_eq!(spec.profile_name, "my");
assert_eq!(spec.category, "profile:plan");
}
}