Skip to main content

dot_agent_core/profile/
fusion.rs

1//! Fusion Executor
2//!
3//! 複数Profileからカテゴリ単位で合成し、新Profileを作成する。
4
5use 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/// Fusion指定(Profile:Category のペア)
15#[derive(Debug, Clone)]
16pub struct FusionSpec {
17    /// Profile名
18    pub profile_name: String,
19    /// 対象カテゴリ
20    pub category: String,
21}
22
23impl FusionSpec {
24    /// "profile:category" 形式からパース
25    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/// Fusionコンフリクト
40#[derive(Debug, Clone)]
41pub struct FusionConflict {
42    /// コンフリクトしたファイルパス
43    pub path: PathBuf,
44    /// 競合元のProfile:Category ペア
45    pub sources: Vec<FusionSpec>,
46}
47
48/// Fusion結果
49#[derive(Debug, Clone)]
50pub struct FusionResult {
51    /// 作成されたProfile名
52    pub profile_name: String,
53    /// コピーされたファイル数
54    pub files_copied: usize,
55    /// 検出されたコンフリクト
56    pub conflicts: Vec<FusionConflict>,
57    /// 各Profileからの貢献
58    pub contributions: HashMap<String, usize>,
59}
60
61/// Fusion設定
62#[derive(Debug, Clone)]
63pub struct FusionConfig {
64    /// 分類モード
65    pub mode: ClassificationMode,
66    /// コンフリクト時に後勝ちにするか
67    pub force: bool,
68    /// 最初のProfileから未分類ファイルを含めるか
69    pub include_uncategorized: bool,
70    /// ドライラン(ファイルコピーしない)
71    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/// 収集されたファイル情報
86#[derive(Debug, Clone)]
87pub struct CollectedFile {
88    /// コピー元のフルパス
89    pub src_path: PathBuf,
90    /// 出力Profile内での相対パス
91    pub dest_path: String,
92    /// 元Profile名
93    pub source_profile: String,
94}
95
96/// Fusion実行結果(ドライラン用)
97#[derive(Debug, Clone)]
98pub struct FusionPlan {
99    /// コピー予定のファイル
100    pub files: Vec<CollectedFile>,
101    /// 検出されたコンフリクト
102    pub conflicts: Vec<FusionConflict>,
103    /// 各Profileからの貢献数
104    pub contributions: HashMap<String, usize>,
105}
106
107/// Fusion実行器
108///
109/// 複数のProfile:Categoryから新Profileを作成する。
110pub struct FusionExecutor {
111    specs: Vec<FusionSpec>,
112    config: FusionConfig,
113}
114
115impl FusionExecutor {
116    /// 新規実行器を作成
117    pub fn new(specs: Vec<FusionSpec>, config: FusionConfig) -> Self {
118        Self { specs, config }
119    }
120
121    /// Fusionを計画(ドライラン用)
122    ///
123    /// ファイルのコピーは行わず、計画のみを返す。
124    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        // dest_path -> CollectedFile
132        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            // Validate category exists
142            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                // Check for conflict before inserting
156                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                // Insert or overwrite (later spec wins)
178                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            // Include uncategorized from first profile if requested
191            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        // Sort files by dest_path for deterministic output
208        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    /// Fusionを実行(plan + execute_plan のショートカット)
219    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    /// 計画済みFusionを実行
225    ///
226    /// plan()で取得したFusionPlanを実行する。
227    /// CLI側で計画を表示した後に実行する場合はこちらを使用。
228    pub fn execute_plan(
229        &self,
230        plan: &FusionPlan,
231        manager: &ProfileManager,
232        output_name: &str,
233    ) -> Result<FusionResult> {
234        // Check output profile
235        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        // Create output profile
252        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        // Copy files
259        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        // Create .dot-agent.toml
285        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}