1use std::collections::HashMap;
6use std::fs;
7use std::path::PathBuf;
8
9use crate::category::{CategoryClassifier, ClassificationMode};
10use crate::error::{DotAgentError, Result};
11
12use super::{ProfileManager, ProfileMetadata};
13
14#[derive(Debug, Clone)]
16pub struct FusionSpec {
17 pub profile_name: String,
19 pub category: String,
21}
22
23impl FusionSpec {
24 pub fn parse(s: &str) -> Result<Self> {
26 let parts: Vec<&str> = s.splitn(2, ':').collect();
27 if parts.len() != 2 {
28 return Err(DotAgentError::ConfigParseSimple {
29 message: format!("Invalid fusion spec: '{}'. Expected 'profile:category'", s),
30 });
31 }
32 Ok(Self {
33 profile_name: parts[0].to_string(),
34 category: parts[1].to_string(),
35 })
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct FusionConflict {
42 pub path: PathBuf,
44 pub sources: Vec<FusionSpec>,
46}
47
48#[derive(Debug, Clone)]
50pub struct FusionResult {
51 pub profile_name: String,
53 pub files_copied: usize,
55 pub conflicts: Vec<FusionConflict>,
57 pub contributions: HashMap<String, usize>,
59}
60
61#[derive(Debug, Clone)]
63pub struct FusionConfig {
64 pub mode: ClassificationMode,
66 pub force: bool,
68 pub include_uncategorized: bool,
70 pub dry_run: bool,
72}
73
74impl Default for FusionConfig {
75 fn default() -> Self {
76 Self {
77 mode: ClassificationMode::Glob,
78 force: false,
79 include_uncategorized: false,
80 dry_run: false,
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct CollectedFile {
88 pub src_path: PathBuf,
90 pub dest_path: String,
92 pub source_profile: String,
94}
95
96#[derive(Debug, Clone)]
98pub struct FusionPlan {
99 pub files: Vec<CollectedFile>,
101 pub conflicts: Vec<FusionConflict>,
103 pub contributions: HashMap<String, usize>,
105}
106
107pub struct FusionExecutor {
111 specs: Vec<FusionSpec>,
112 config: FusionConfig,
113}
114
115impl FusionExecutor {
116 pub fn new(specs: Vec<FusionSpec>, config: FusionConfig) -> Self {
118 Self { specs, config }
119 }
120
121 pub fn plan(&self, manager: &ProfileManager) -> Result<FusionPlan> {
125 if self.specs.is_empty() {
126 return Err(DotAgentError::ConfigParseSimple {
127 message: "No fusion specs provided".to_string(),
128 });
129 }
130
131 let mut all_files: HashMap<String, CollectedFile> = HashMap::new();
133 let mut conflicts: Vec<FusionConflict> = Vec::new();
134 let mut contributions: HashMap<String, usize> = HashMap::new();
135
136 for (idx, spec) in self.specs.iter().enumerate() {
137 let profile = manager.get_profile(&spec.profile_name)?;
138 let classifier = CategoryClassifier::from_profile(&profile, self.config.mode)?;
139 let result = classifier.classify(&profile)?;
140
141 if classifier.get_category(&spec.category).is_none() {
143 return Err(DotAgentError::CategoryNotFound {
144 name: spec.category.clone(),
145 });
146 }
147
148 let files = result.files_in_category(&spec.category);
149 let file_count = files.len();
150
151 for file in files {
152 let dest_key = file.to_string_lossy().to_string();
153 let src_path = profile.path.join(file);
154
155 if let Some(existing) = all_files.get(&dest_key) {
157 let conflict_entry = conflicts
158 .iter_mut()
159 .find(|c| c.path.to_string_lossy() == dest_key);
160
161 if let Some(conflict) = conflict_entry {
162 conflict.sources.push(spec.clone());
163 } else {
164 conflicts.push(FusionConflict {
165 path: PathBuf::from(&dest_key),
166 sources: vec![
167 FusionSpec {
168 profile_name: existing.source_profile.clone(),
169 category: spec.category.clone(),
170 },
171 spec.clone(),
172 ],
173 });
174 }
175 }
176
177 all_files.insert(
179 dest_key.clone(),
180 CollectedFile {
181 src_path,
182 dest_path: dest_key,
183 source_profile: spec.profile_name.clone(),
184 },
185 );
186 }
187
188 *contributions.entry(spec.profile_name.clone()).or_insert(0) += file_count;
189
190 if idx == 0 && self.config.include_uncategorized {
192 let uncategorized = result.uncategorized();
193 for file in uncategorized {
194 let dest_key = file.to_string_lossy().to_string();
195 let src_path = profile.path.join(file);
196 all_files
197 .entry(dest_key.clone())
198 .or_insert_with(|| CollectedFile {
199 src_path,
200 dest_path: dest_key,
201 source_profile: spec.profile_name.clone(),
202 });
203 }
204 }
205 }
206
207 let mut files: Vec<_> = all_files.into_values().collect();
209 files.sort_by(|a, b| a.dest_path.cmp(&b.dest_path));
210
211 Ok(FusionPlan {
212 files,
213 conflicts,
214 contributions,
215 })
216 }
217
218 pub fn execute(&self, manager: &ProfileManager, output_name: &str) -> Result<FusionResult> {
220 let plan = self.plan(manager)?;
221 self.execute_plan(&plan, manager, output_name)
222 }
223
224 pub fn execute_plan(
229 &self,
230 plan: &FusionPlan,
231 manager: &ProfileManager,
232 output_name: &str,
233 ) -> Result<FusionResult> {
234 let output_exists = manager.get_profile(output_name).is_ok();
236 if output_exists && !self.config.force {
237 return Err(DotAgentError::ProfileAlreadyExists {
238 name: output_name.to_string(),
239 });
240 }
241
242 if self.config.dry_run {
243 return Ok(FusionResult {
244 profile_name: output_name.to_string(),
245 files_copied: 0,
246 conflicts: plan.conflicts.clone(),
247 contributions: plan.contributions.clone(),
248 });
249 }
250
251 let output_path = manager.profiles_dir().join(output_name);
253 if output_path.exists() && self.config.force {
254 fs::remove_dir_all(&output_path)?;
255 }
256 fs::create_dir_all(&output_path)?;
257
258 let mut copied = 0;
260 for file in &plan.files {
261 let dest_path = output_path.join(&file.dest_path);
262
263 if let Some(parent) = dest_path.parent() {
264 fs::create_dir_all(parent).map_err(|e| {
265 std::io::Error::new(
266 e.kind(),
267 format!("Failed to create directory {:?}: {}", parent, e),
268 )
269 })?;
270 }
271
272 fs::copy(&file.src_path, &dest_path).map_err(|e| {
273 std::io::Error::new(
274 e.kind(),
275 format!(
276 "Failed to copy {:?} -> {:?}: {}",
277 file.src_path, dest_path, e
278 ),
279 )
280 })?;
281 copied += 1;
282 }
283
284 let metadata = ProfileMetadata::new_local(output_name);
286 metadata.save(&output_path)?;
287
288 Ok(FusionResult {
289 profile_name: output_name.to_string(),
290 files_copied: copied,
291 conflicts: plan.conflicts.clone(),
292 contributions: plan.contributions.clone(),
293 })
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_fusion_spec_parse() {
303 let spec = FusionSpec::parse("my-profile:plan").unwrap();
304 assert_eq!(spec.profile_name, "my-profile");
305 assert_eq!(spec.category, "plan");
306
307 assert!(FusionSpec::parse("invalid").is_err());
308 }
309
310 #[test]
311 fn test_fusion_spec_parse_with_colon_in_name() {
312 let spec = FusionSpec::parse("my:profile:plan").unwrap();
313 assert_eq!(spec.profile_name, "my");
314 assert_eq!(spec.category, "profile:plan");
315 }
316}