Skip to main content

nargo_changes/
lib.rs

1//! Change set management for HXO framework.
2//!
3//! This crate provides tools for managing version changes, detecting file changes,
4//! generating changelogs, and integrating with version control systems.
5
6#![warn(missing_docs)]
7
8use chrono::Utc;
9use filetime::FileTime;
10use nargo_types::{Error, Result, Span};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::{
14    collections::HashMap,
15    fmt::Display,
16    fs::{self, read, File},
17    io::Write,
18    path::{Path, PathBuf},
19    process::Command,
20};
21use walkdir::WalkDir;
22
23/// Change set type.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Copy)]
25pub enum ChangeType {
26    /// A breaking change.
27    #[serde(rename = "breaking")]
28    Breaking,
29    /// A new feature.
30    #[serde(rename = "feature")]
31    Feature,
32    /// A bug fix.
33    #[serde(rename = "fix")]
34    Fix,
35    /// A documentation change.
36    #[serde(rename = "docs")]
37    Docs,
38    /// A refactoring change.
39    #[serde(rename = "refactor")]
40    Refactor,
41    /// A performance improvement.
42    #[serde(rename = "perf")]
43    Perf,
44    /// A test change.
45    #[serde(rename = "test")]
46    Test,
47    /// A build change.
48    #[serde(rename = "build")]
49    Build,
50    /// A chore change.
51    #[serde(rename = "chore")]
52    Chore,
53}
54
55/// Change set data structure.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ChangeSet {
58    /// The ID of the change set.
59    pub id: String,
60    /// The type of change.
61    pub r#type: ChangeType,
62    /// A summary of the change.
63    pub summary: String,
64    /// Detailed description of the change.
65    pub description: Option<String>,
66    /// The author of the change.
67    pub author: Option<String>,
68    /// The packages affected by the change.
69    pub packages: Vec<String>,
70    /// Whether the change is a prerelease.
71    pub prerelease: bool,
72}
73
74/// Change set template.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ChangeSetTemplate {
77    /// The name of the template.
78    pub name: String,
79    /// The default change type.
80    pub default_type: ChangeType,
81    /// The default description template.
82    pub description_template: Option<String>,
83    /// The default packages.
84    pub default_packages: Vec<String>,
85    /// Whether the change is a prerelease by default.
86    pub default_prerelease: bool,
87}
88
89/// Change set preset.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ChangeSetPreset {
92    /// The name of the preset.
93    pub name: String,
94    /// The description of the preset.
95    pub description: Option<String>,
96    /// The template to use for this preset.
97    pub template: String,
98    /// Additional metadata for the preset.
99    pub metadata: HashMap<String, String>,
100}
101
102/// Change set manager.
103pub struct ChangeSetManager {
104    /// The directory where change sets are stored.
105    pub changes_dir: PathBuf,
106}
107
108impl ChangeSetManager {
109    /// Creates a new change set manager.
110    pub fn new(changes_dir: &Path) -> Self {
111        Self { changes_dir: changes_dir.to_path_buf() }
112    }
113
114    /// Creates a new change set.
115    pub fn create_change_set(&self, change_set: &ChangeSet) -> Result<PathBuf> {
116        // Ensure the changes directory exists
117        fs::create_dir_all(&self.changes_dir)?;
118
119        // Generate the file path
120        let file_name = format!("{}-{}.json", change_set.id, change_set.r#type.as_str());
121        let file_path = self.changes_dir.join(file_name);
122
123        // Write the change set to file
124        let mut file = File::create(&file_path)?;
125        let content = serde_json::to_string_pretty(change_set).map_err(|e| Error::external_error("json".to_string(), e.to_string(), Span::unknown()))?;
126        file.write_all(content.as_bytes())?;
127
128        Ok(file_path)
129    }
130
131    /// Reads all change sets from the changes directory.
132    pub fn read_change_sets(&self) -> Result<Vec<ChangeSet>> {
133        let mut change_sets = Vec::new();
134
135        for entry in WalkDir::new(&self.changes_dir).into_iter().filter_map(|e| e.ok()).filter(|e| e.file_type().is_file()).filter(|e| e.path().extension().map(|ext| ext == "json").unwrap_or(false)) {
136            let content = fs::read_to_string(entry.path())?;
137            let change_set: ChangeSet = serde_json::from_str(&content).map_err(|e| Error::external_error("json".to_string(), e.to_string(), Span::unknown()))?;
138            change_sets.push(change_set);
139        }
140
141        Ok(change_sets)
142    }
143
144    /// Generates a changelog from the change sets.
145    pub fn generate_changelog(&self, version: &str, date: &str) -> Result<String> {
146        let change_sets = self.read_change_sets()?;
147        let mut changelog = format!("# Changelog\n\n## [{}] - {}\n\n", version, date);
148
149        // Group change sets by type
150        let mut breaking = Vec::new();
151        let mut features = Vec::new();
152        let mut fixes = Vec::new();
153        let mut others = Vec::new();
154
155        for change_set in &change_sets {
156            match change_set.r#type {
157                ChangeType::Breaking => breaking.push(change_set),
158                ChangeType::Feature => features.push(change_set),
159                ChangeType::Fix => fixes.push(change_set),
160                _ => others.push(change_set),
161            }
162        }
163
164        // Add breaking changes
165        if !breaking.is_empty() {
166            changelog.push_str("### Breaking Changes\n\n");
167            for change in &breaking {
168                changelog.push_str(&format!("- {}\n", change.summary));
169                if let Some(desc) = &change.description {
170                    changelog.push_str(&format!("  {}\n", desc));
171                }
172            }
173            changelog.push_str("\n");
174        }
175
176        // Add features
177        if !features.is_empty() {
178            changelog.push_str("### Features\n\n");
179            for change in &features {
180                changelog.push_str(&format!("- {}\n", change.summary));
181                if let Some(desc) = &change.description {
182                    changelog.push_str(&format!("  {}\n", desc));
183                }
184            }
185            changelog.push_str("\n");
186        }
187
188        // Add fixes
189        if !fixes.is_empty() {
190            changelog.push_str("### Bug Fixes\n\n");
191            for change in &fixes {
192                changelog.push_str(&format!("- {}\n", change.summary));
193                if let Some(desc) = &change.description {
194                    changelog.push_str(&format!("  {}\n", desc));
195                }
196            }
197            changelog.push_str("\n");
198        }
199
200        // Add other changes
201        if !others.is_empty() {
202            changelog.push_str("### Other Changes\n\n");
203            for change in &others {
204                changelog.push_str(&format!("- [{}] {}\n", change.r#type.as_str(), change.summary));
205                if let Some(desc) = &change.description {
206                    changelog.push_str(&format!("  {}\n", desc));
207                }
208            }
209            changelog.push_str("\n");
210        }
211
212        Ok(changelog)
213    }
214
215    /// Clears all change sets after generating a changelog.
216    pub fn clear_change_sets(&self) -> Result<()> {
217        for entry in WalkDir::new(&self.changes_dir).into_iter().filter_map(|e| e.ok()).filter(|e| e.file_type().is_file()).filter(|e| e.path().extension().map(|ext| ext == "json").unwrap_or(false)) {
218            fs::remove_file(entry.path())?;
219        }
220
221        Ok(())
222    }
223
224    /// Merges multiple change sets into a single change set.
225    pub fn merge_change_sets(&self, change_sets: &[ChangeSet]) -> Result<ChangeSet> {
226        if change_sets.is_empty() {
227            return Err(Error::external_error("changes".to_string(), "No change sets to merge".to_string(), Span::unknown()));
228        }
229
230        // Determine the highest priority change type
231        let merged_type = change_sets.iter().max_by(|a, b| Self::change_type_priority(a.r#type).cmp(&Self::change_type_priority(b.r#type))).unwrap().r#type.clone();
232
233        // Merge summaries
234        let merged_summary = change_sets.iter().map(|cs| cs.summary.clone()).collect::<Vec<_>>().join("; ");
235
236        // Merge descriptions
237        let merged_description = change_sets.iter().filter_map(|cs| cs.description.clone()).collect::<Vec<_>>().join("\n\n");
238
239        // Merge packages (dedup)
240        let mut merged_packages = HashMap::new();
241        for cs in change_sets {
242            for pkg in &cs.packages {
243                merged_packages.insert(pkg.clone(), ());
244            }
245        }
246        let merged_packages = merged_packages.keys().cloned().collect::<Vec<_>>();
247
248        // Check if any change set is a prerelease
249        let merged_prerelease = change_sets.iter().any(|cs| cs.prerelease);
250
251        // Create the merged change set
252        let merged_change_set = ChangeSet {
253            id: format!("merged-{}", chrono::Utc::now().timestamp()),
254            r#type: merged_type,
255            summary: merged_summary,
256            description: if merged_description.is_empty() { None } else { Some(merged_description) },
257            author: None, // Merged change sets don't have a single author
258            packages: merged_packages,
259            prerelease: merged_prerelease,
260        };
261
262        Ok(merged_change_set)
263    }
264
265    /// Resolves conflicts between change sets.
266    pub fn resolve_conflicts(&self, change_sets: &[ChangeSet]) -> Result<Vec<ChangeSet>> {
267        // Simple conflict resolution: group by type and summary
268        let mut resolved: HashMap<(ChangeType, String), ChangeSet> = HashMap::new();
269
270        for cs in change_sets {
271            let key = (cs.r#type.clone(), cs.summary.clone());
272            if let Some(existing) = resolved.get_mut(&key) {
273                // Merge packages
274                let mut packages = existing.packages.clone();
275                for pkg in &cs.packages {
276                    if !packages.contains(pkg) {
277                        packages.push(pkg.clone());
278                    }
279                }
280                existing.packages = packages;
281
282                // Merge descriptions
283                if let Some(desc) = &cs.description {
284                    if let Some(existing_desc) = &existing.description {
285                        existing.description = Some(format!("{}\n\n{}", existing_desc, desc));
286                    }
287                    else {
288                        existing.description = Some(desc.clone());
289                    }
290                }
291
292                // Set prerelease if any is true
293                if cs.prerelease {
294                    existing.prerelease = true;
295                }
296            }
297            else {
298                resolved.insert(key, cs.clone());
299            }
300        }
301
302        Ok(resolved.values().cloned().collect())
303    }
304
305    /// Gets the priority of a change type for merging.
306    /// Higher priority change types override lower ones.
307    fn change_type_priority(change_type: ChangeType) -> u8 {
308        match change_type {
309            ChangeType::Breaking => 10,
310            ChangeType::Feature => 8,
311            ChangeType::Fix => 6,
312            ChangeType::Perf => 5,
313            ChangeType::Refactor => 4,
314            ChangeType::Docs => 3,
315            ChangeType::Test => 2,
316            ChangeType::Build => 1,
317            ChangeType::Chore => 0,
318        }
319    }
320
321    /// Loads a change set template.
322    pub fn load_template(&self, template_name: &str) -> Result<ChangeSetTemplate> {
323        let template_dir = self.changes_dir.join("templates");
324        let template_path = template_dir.join(format!("{}.json", template_name));
325
326        if !template_path.exists() {
327            return Err(Error::external_error("changes".to_string(), format!("Template not found: {}", template_name), Span::unknown()));
328        }
329
330        let content = fs::read_to_string(template_path)?;
331        let template: ChangeSetTemplate = serde_json::from_str(&content).map_err(|e| Error::external_error("json".to_string(), e.to_string(), Span::unknown()))?;
332
333        Ok(template)
334    }
335
336    /// Saves a change set template.
337    pub fn save_template(&self, template: &ChangeSetTemplate) -> Result<()> {
338        let template_dir = self.changes_dir.join("templates");
339        fs::create_dir_all(&template_dir)?;
340
341        let template_path = template_dir.join(format!("{}.json", template.name));
342        let content = serde_json::to_string_pretty(template).map_err(|e| Error::external_error("json".to_string(), e.to_string(), Span::unknown()))?;
343
344        let mut file = File::create(template_path)?;
345        file.write_all(content.as_bytes())?;
346
347        Ok(())
348    }
349
350    /// Loads a change set preset.
351    pub fn load_preset(&self, preset_name: &str) -> Result<ChangeSetPreset> {
352        let preset_dir = self.changes_dir.join("presets");
353        let preset_path = preset_dir.join(format!("{}.json", preset_name));
354
355        if !preset_path.exists() {
356            return Err(Error::external_error("changes".to_string(), format!("Preset not found: {}", preset_name), Span::unknown()));
357        }
358
359        let content = fs::read_to_string(preset_path)?;
360        let preset: ChangeSetPreset = serde_json::from_str(&content).map_err(|e| Error::external_error("json".to_string(), e.to_string(), Span::unknown()))?;
361
362        Ok(preset)
363    }
364
365    /// Saves a change set preset.
366    pub fn save_preset(&self, preset: &ChangeSetPreset) -> Result<()> {
367        let preset_dir = self.changes_dir.join("presets");
368        fs::create_dir_all(&preset_dir)?;
369
370        let preset_path = preset_dir.join(format!("{}.json", preset.name));
371        let content = serde_json::to_string_pretty(preset).map_err(|e| Error::external_error("json".to_string(), e.to_string(), Span::unknown()))?;
372
373        let mut file = File::create(preset_path)?;
374        file.write_all(content.as_bytes())?;
375
376        Ok(())
377    }
378
379    /// Creates a change set from a template.
380    pub fn create_change_set_from_template(&self, template_name: &str, summary: &str, author: Option<String>) -> Result<PathBuf> {
381        let template = self.load_template(template_name)?;
382
383        let change_set = ChangeSet { id: format!("{}", chrono::Utc::now().timestamp()), r#type: template.default_type, summary: summary.to_string(), description: template.description_template, author, packages: template.default_packages, prerelease: template.default_prerelease };
384
385        self.create_change_set(&change_set)
386    }
387
388    /// Creates a change set from a preset.
389    pub fn create_change_set_from_preset(&self, preset_name: &str, summary: &str, author: Option<String>) -> Result<PathBuf> {
390        let preset = self.load_preset(preset_name)?;
391        self.create_change_set_from_template(&preset.template, summary, author)
392    }
393
394    /// Generates an enhanced changelog with more details.
395    pub fn generate_enhanced_changelog(&self, version: &str, date: &str, include_authors: bool) -> Result<String> {
396        let change_sets = self.read_change_sets()?;
397        let mut changelog = format!("# Changelog\n\n## [{}] - {}\n\n", version, date);
398
399        // Group change sets by type
400        let mut grouped = HashMap::new();
401        for cs in &change_sets {
402            grouped.entry(cs.r#type.clone()).or_insert_with(Vec::new).push(cs);
403        }
404
405        // Define the order of change types
406        let type_order = [ChangeType::Breaking, ChangeType::Feature, ChangeType::Fix, ChangeType::Perf, ChangeType::Refactor, ChangeType::Docs, ChangeType::Test, ChangeType::Build, ChangeType::Chore];
407
408        // Add changes in order
409        for change_type in &type_order {
410            if let Some(cs_list) = grouped.get(change_type) {
411                if !cs_list.is_empty() {
412                    // Add section header
413                    let section_title = match change_type {
414                        ChangeType::Breaking => "Breaking Changes",
415                        ChangeType::Feature => "Features",
416                        ChangeType::Fix => "Bug Fixes",
417                        ChangeType::Perf => "Performance Improvements",
418                        ChangeType::Refactor => "Code Refactoring",
419                        ChangeType::Docs => "Documentation",
420                        ChangeType::Test => "Tests",
421                        ChangeType::Build => "Build System",
422                        ChangeType::Chore => "Chores",
423                    };
424                    changelog.push_str(&format!("### {}\n\n", section_title));
425
426                    // Add each change
427                    for cs in cs_list {
428                        changelog.push_str(&format!("- {}\n", cs.summary));
429                        if let Some(desc) = &cs.description {
430                            changelog.push_str(&format!("  {}\n", desc));
431                        }
432                        if include_authors && cs.author.is_some() {
433                            changelog.push_str(&format!("  **Author:** {}\n", cs.author.as_ref().unwrap()));
434                        }
435                        if !cs.packages.is_empty() {
436                            changelog.push_str(&format!("  **Packages:** {}\n", cs.packages.join(", ")));
437                        }
438                        changelog.push_str("\n");
439                    }
440                }
441            }
442        }
443
444        Ok(changelog)
445    }
446
447    /// Gets change statistics for all change sets.
448    pub fn get_change_stats(&self) -> Result<ChangeStats> {
449        let change_sets = self.read_change_sets()?;
450        Ok(ChangeStats::from_change_sets(&change_sets))
451    }
452
453    /// Analyzes change trends over a specified time period.
454    pub fn analyze_change_trend(&self, time_period: &str) -> Result<ChangeTrend> {
455        let change_sets = self.read_change_sets()?;
456        Ok(ChangeTrend::from_change_sets(&change_sets, time_period))
457    }
458
459    /// Generates a comprehensive change report including statistics and trends.
460    pub fn generate_change_report(&self, time_period: &str) -> Result<String> {
461        let stats = self.get_change_stats()?;
462        let trend = self.analyze_change_trend(time_period)?;
463
464        let mut report = String::new();
465        report.push_str("# Change Report\n\n");
466        report.push_str("## Statistics\n\n");
467        report.push_str(&stats.generate_summary());
468        report.push_str("\n## Trend Analysis\n\n");
469        report.push_str(&trend.generate_summary());
470
471        Ok(report)
472    }
473}
474
475impl ChangeType {
476    /// Returns the string representation of the change type.
477    pub fn as_str(&self) -> &str {
478        match self {
479            ChangeType::Breaking => "breaking",
480            ChangeType::Feature => "feature",
481            ChangeType::Fix => "fix",
482            ChangeType::Docs => "docs",
483            ChangeType::Refactor => "refactor",
484            ChangeType::Perf => "perf",
485            ChangeType::Test => "test",
486            ChangeType::Build => "build",
487            ChangeType::Chore => "chore",
488        }
489    }
490}
491
492/// File change type.
493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
494pub enum FileChangeType {
495    /// A new file was added.
496    Added,
497    /// An existing file was modified.
498    Modified,
499    /// A file was deleted.
500    Deleted,
501}
502
503/// File change information.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct FileChange {
506    /// The path to the file.
507    pub path: PathBuf,
508    /// The type of change.
509    pub r#type: FileChangeType,
510    /// The previous hash (if applicable).
511    pub old_hash: Option<String>,
512    /// The new hash (if applicable).
513    pub new_hash: Option<String>,
514    /// The modification time (seconds since epoch).
515    pub modified_time: Option<u64>,
516}
517
518/// File change detector.
519pub struct FileChangeDetector {
520    /// The base directory to scan for changes.
521    pub base_dir: PathBuf,
522    /// The list of file patterns to include.
523    pub include_patterns: Vec<String>,
524    /// The list of file patterns to exclude.
525    pub exclude_patterns: Vec<String>,
526}
527
528impl FileChangeDetector {
529    /// Creates a new file change detector.
530    pub fn new(base_dir: &Path) -> Self {
531        Self { base_dir: base_dir.to_path_buf(), include_patterns: vec!["**/*".to_string()], exclude_patterns: vec!["target/**".to_string(), ".git/**".to_string(), "node_modules/**".to_string()] }
532    }
533
534    /// Sets the include patterns.
535    pub fn with_include_patterns(mut self, patterns: Vec<String>) -> Self {
536        self.include_patterns = patterns;
537        self
538    }
539
540    /// Sets the exclude patterns.
541    pub fn with_exclude_patterns(mut self, patterns: Vec<String>) -> Self {
542        self.exclude_patterns = patterns;
543        self
544    }
545
546    /// Computes the hash of a file.
547    pub fn compute_file_hash(&self, path: &Path) -> Result<String> {
548        let content = read(path)?;
549        let mut hasher = Sha256::new();
550        hasher.update(&content);
551        let hash = hasher.finalize();
552        Ok(format!("{:x}", hash))
553    }
554
555    /// Scans for file changes compared to a previous state.
556    pub fn scan_changes(&self, previous_state: Option<&HashMap<PathBuf, String>>) -> Result<Vec<FileChange>> {
557        let mut current_state = HashMap::new();
558        let mut changes = Vec::new();
559
560        // Scan all files in the base directory
561        for entry in WalkDir::new(&self.base_dir).into_iter().filter_map(|e| e.ok()).filter(|e| e.file_type().is_file()) {
562            let path = entry.path();
563            let relative_path = path.strip_prefix(&self.base_dir).unwrap_or(path);
564            let relative_path_str = relative_path.to_str().unwrap_or("");
565
566            // Check if the file should be included
567            let should_include = self.include_patterns.iter().any(|pattern| glob::Pattern::new(pattern).unwrap().matches(relative_path_str));
568
569            if !should_include {
570                continue;
571            }
572
573            // Check if the file should be excluded
574            let should_exclude = self.exclude_patterns.iter().any(|pattern| glob::Pattern::new(pattern).unwrap().matches(relative_path_str));
575
576            if should_exclude {
577                continue;
578            }
579
580            // Compute the current hash
581            let current_hash = self.compute_file_hash(path)?;
582            current_state.insert(relative_path.to_path_buf(), current_hash.clone());
583
584            // Check if this is a new file or a modified file
585            if let Some(prev_state) = previous_state {
586                if let Some(old_hash) = prev_state.get(relative_path) {
587                    if old_hash != &current_hash {
588                        changes.push(FileChange { path: relative_path.to_path_buf(), r#type: FileChangeType::Modified, old_hash: Some(old_hash.clone()), new_hash: Some(current_hash), modified_time: Some(FileTime::from_last_modification_time(&entry.metadata().map_err(|e| Error::external_error("fs".to_string(), e.to_string(), Span::unknown()))?).unix_seconds() as u64) });
589                    }
590                }
591                else {
592                    changes.push(FileChange { path: relative_path.to_path_buf(), r#type: FileChangeType::Added, old_hash: None, new_hash: Some(current_hash), modified_time: Some(FileTime::from_last_modification_time(&entry.metadata().map_err(|e| Error::external_error("fs".to_string(), e.to_string(), Span::unknown()))?).unix_seconds() as u64) });
593                }
594            }
595        }
596
597        // Check for deleted files
598        if let Some(prev_state) = previous_state {
599            for (path, old_hash) in prev_state {
600                if !current_state.contains_key(path) {
601                    changes.push(FileChange { path: path.clone(), r#type: FileChangeType::Deleted, old_hash: Some(old_hash.clone()), new_hash: None, modified_time: None });
602                }
603            }
604        }
605
606        Ok(changes)
607    }
608
609    /// Generates a summary of the changes.
610    pub fn generate_change_summary(&self, changes: &[FileChange]) -> String {
611        let mut summary = String::new();
612        let mut added = 0;
613        let mut modified = 0;
614        let mut deleted = 0;
615
616        for change in changes {
617            match change.r#type {
618                FileChangeType::Added => added += 1,
619                FileChangeType::Modified => modified += 1,
620                FileChangeType::Deleted => deleted += 1,
621            }
622        }
623
624        summary.push_str(&format!("File changes summary:\n"));
625        summary.push_str(&format!("- Added: {}\n", added));
626        summary.push_str(&format!("- Modified: {}\n", modified));
627        summary.push_str(&format!("- Deleted: {}\n", deleted));
628
629        if !changes.is_empty() {
630            summary.push_str("\nDetailed changes:\n");
631            for change in changes {
632                let change_type_str = match change.r#type {
633                    FileChangeType::Added => "Added",
634                    FileChangeType::Modified => "Modified",
635                    FileChangeType::Deleted => "Deleted",
636                };
637                summary.push_str(&format!("- {}: {}\n", change_type_str, change.path.display()));
638            }
639        }
640
641        summary
642    }
643}
644
645/// Change preview.
646pub struct ChangePreview {
647    /// The file changes.
648    pub file_changes: Vec<FileChange>,
649    /// The change sets.
650    pub change_sets: Vec<ChangeSet>,
651}
652
653impl ChangePreview {
654    /// Creates a new change preview.
655    pub fn new(file_changes: Vec<FileChange>, change_sets: Vec<ChangeSet>) -> Self {
656        Self { file_changes, change_sets }
657    }
658
659    /// Generates a preview of the changes.
660    pub fn generate_preview(&self) -> String {
661        let mut preview = String::new();
662
663        // Add file changes summary
664        let detector = FileChangeDetector::new(Path::new("."));
665        preview.push_str(&detector.generate_change_summary(&self.file_changes));
666
667        // Add change sets summary
668        if !self.change_sets.is_empty() {
669            preview.push_str("\nChange sets:\n");
670            for change_set in &self.change_sets {
671                preview.push_str(&format!("- [{}] {}\n", change_set.r#type.as_str(), change_set.summary));
672            }
673        }
674
675        preview
676    }
677}
678
679/// Change statistics.
680#[derive(Debug, Clone, Serialize, Deserialize)]
681pub struct ChangeStats {
682    /// Total number of change sets.
683    pub total_change_sets: usize,
684    /// Change sets grouped by type.
685    pub change_sets_by_type: HashMap<ChangeType, usize>,
686    /// Change sets grouped by package.
687    pub change_sets_by_package: HashMap<String, usize>,
688    /// Total number of breaking changes.
689    pub breaking_changes: usize,
690    /// Total number of features.
691    pub features: usize,
692    /// Total number of bug fixes.
693    pub bug_fixes: usize,
694    /// Total number of other changes.
695    pub other_changes: usize,
696    /// Average changes per package.
697    pub avg_changes_per_package: f64,
698    /// Most common change type.
699    pub most_common_change_type: Option<ChangeType>,
700    /// Most affected package.
701    pub most_affected_package: Option<String>,
702}
703
704/// Change trend data point.
705#[derive(Debug, Clone, Serialize, Deserialize)]
706pub struct ChangeTrendPoint {
707    /// The timestamp for this data point.
708    pub timestamp: chrono::DateTime<Utc>,
709    /// Number of change sets in this period.
710    pub change_set_count: usize,
711    /// Change sets by type in this period.
712    pub change_sets_by_type: HashMap<ChangeType, usize>,
713    /// Number of packages affected in this period.
714    pub packages_affected: usize,
715}
716
717/// Change trend analysis.
718#[derive(Debug, Clone, Serialize, Deserialize)]
719pub struct ChangeTrend {
720    /// The time period for the trend analysis.
721    pub time_period: String,
722    /// The trend data points.
723    pub data_points: Vec<ChangeTrendPoint>,
724    /// Overall statistics for the trend period.
725    pub overall_stats: ChangeStats,
726    /// Change rate over time (changes per day).
727    pub change_rate: f64,
728    /// Trend direction (positive, negative, or stable).
729    pub trend_direction: String,
730    /// Most active period.
731    pub most_active_period: Option<chrono::DateTime<Utc>>,
732}
733
734impl ChangeStats {
735    /// Creates a new ChangeStats instance from a list of change sets.
736    pub fn from_change_sets(change_sets: &[ChangeSet]) -> Self {
737        let total_change_sets = change_sets.len();
738        let mut change_sets_by_type = HashMap::new();
739        let mut change_sets_by_package = HashMap::new();
740        let mut breaking_changes = 0;
741        let mut features = 0;
742        let mut bug_fixes = 0;
743        let mut other_changes = 0;
744
745        // Count changes by type and package
746        for change_set in change_sets {
747            // Count by type
748            *change_sets_by_type.entry(change_set.r#type.clone()).or_insert(0) += 1;
749
750            // Count by package
751            for package in &change_set.packages {
752                *change_sets_by_package.entry(package.clone()).or_insert(0) += 1;
753            }
754
755            // Count specific types
756            match change_set.r#type {
757                ChangeType::Breaking => breaking_changes += 1,
758                ChangeType::Feature => features += 1,
759                ChangeType::Fix => bug_fixes += 1,
760                _ => other_changes += 1,
761            }
762        }
763
764        // Calculate average changes per package
765        let avg_changes_per_package = if change_sets_by_package.is_empty() { 0.0 } else { change_sets.iter().map(|cs| cs.packages.len()).sum::<usize>() as f64 / change_sets_by_package.len() as f64 };
766
767        // Find most common change type
768        let most_common_change_type = change_sets_by_type.iter().max_by(|a, b| a.1.cmp(b.1)).map(|(ctype, _)| ctype.clone());
769
770        // Find most affected package
771        let most_affected_package = change_sets_by_package.iter().max_by(|a, b| a.1.cmp(b.1)).map(|(pkg, _)| pkg.clone());
772
773        Self { total_change_sets, change_sets_by_type, change_sets_by_package, breaking_changes, features, bug_fixes, other_changes, avg_changes_per_package, most_common_change_type, most_affected_package }
774    }
775
776    /// Generates a summary of the change statistics.
777    pub fn generate_summary(&self) -> String {
778        let mut summary = String::new();
779
780        summary.push_str(&format!("Change Statistics Summary:\n"));
781        summary.push_str(&format!("- Total Change Sets: {}\n", self.total_change_sets));
782        summary.push_str(&format!("- Breaking Changes: {}\n", self.breaking_changes));
783        summary.push_str(&format!("- Features: {}\n", self.features));
784        summary.push_str(&format!("- Bug Fixes: {}\n", self.bug_fixes));
785        summary.push_str(&format!("- Other Changes: {}\n", self.other_changes));
786        summary.push_str(&format!("- Average Changes per Package: {:.2}\n", self.avg_changes_per_package));
787
788        if let Some(ctype) = &self.most_common_change_type {
789            summary.push_str(&format!("- Most Common Change Type: {}\n", ctype.as_str()));
790        }
791
792        if let Some(pkg) = &self.most_affected_package {
793            summary.push_str(&format!("- Most Affected Package: {}\n", pkg));
794        }
795
796        summary.push_str("\nChanges by Type:\n");
797        for (ctype, count) in &self.change_sets_by_type {
798            summary.push_str(&format!("- {}: {}\n", ctype.as_str(), count));
799        }
800
801        summary.push_str("\nChanges by Package:\n");
802        for (pkg, count) in &self.change_sets_by_package {
803            summary.push_str(&format!("- {}: {}\n", pkg, count));
804        }
805
806        summary
807    }
808}
809
810impl ChangeTrend {
811    /// Creates a new ChangeTrend instance from a list of change sets and a time period.
812    pub fn from_change_sets(change_sets: &[ChangeSet], time_period: &str) -> Self {
813        // For simplicity, we'll create a basic trend analysis
814        // In a real implementation, you would group changes by time periods
815        let stats = ChangeStats::from_change_sets(change_sets);
816
817        // Create a single data point for now
818        let data_point = ChangeTrendPoint { timestamp: chrono::Utc::now(), change_set_count: change_sets.len(), change_sets_by_type: stats.change_sets_by_type.clone(), packages_affected: stats.change_sets_by_package.len() };
819
820        // Calculate change rate (changes per day)
821        // For simplicity, we'll assume a 30-day period
822        let change_rate = change_sets.len() as f64 / 30.0;
823
824        // Determine trend direction
825        let trend_direction = if change_rate > 1.0 {
826            "positive"
827        }
828        else if change_rate < 0.5 {
829            "negative"
830        }
831        else {
832            "stable"
833        };
834
835        Self { time_period: time_period.to_string(), data_points: vec![data_point], overall_stats: stats, change_rate, trend_direction: trend_direction.to_string(), most_active_period: Some(chrono::Utc::now()) }
836    }
837
838    /// Generates a summary of the change trend.
839    pub fn generate_summary(&self) -> String {
840        let mut summary = String::new();
841
842        summary.push_str(&format!("Change Trend Analysis ({}):\n", self.time_period));
843        summary.push_str(&format!("- Change Rate: {:.2} changes per day\n", self.change_rate));
844        summary.push_str(&format!("- Trend Direction: {}\n", self.trend_direction));
845
846        if let Some(period) = &self.most_active_period {
847            summary.push_str(&format!("- Most Active Period: {}\n", period.format("%Y-%m-%d %H:%M:%S")));
848        }
849
850        summary.push_str("\nOverall Statistics:\n");
851        summary.push_str(&self.overall_stats.generate_summary());
852
853        summary
854    }
855}
856
857/// Version control system type.
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub enum VcsType {
860    /// Git version control system.
861    Git,
862    /// Subversion (SVN) version control system.
863    Svn,
864    /// Mercurial (Hg) version control system.
865    Mercurial,
866    /// No version control system detected.
867    None,
868}
869
870/// Version control system integration.
871pub struct VcsIntegration {
872    /// The base directory of the repository.
873    pub repo_dir: PathBuf,
874    /// The detected version control system type.
875    pub vcs_type: VcsType,
876}
877
878impl VcsIntegration {
879    /// Creates a new VCS integration.
880    pub fn new(repo_dir: &Path) -> Self {
881        let vcs_type = Self::detect_vcs(repo_dir);
882        Self { repo_dir: repo_dir.to_path_buf(), vcs_type }
883    }
884
885    /// Detects the version control system type in the given directory.
886    pub fn detect_vcs(repo_dir: &Path) -> VcsType {
887        if repo_dir.join(".git").exists() {
888            VcsType::Git
889        }
890        else if repo_dir.join(".svn").exists() {
891            VcsType::Svn
892        }
893        else if repo_dir.join(".hg").exists() {
894            VcsType::Mercurial
895        }
896        else {
897            VcsType::None
898        }
899    }
900
901    /// Gets the detected version control system type.
902    pub fn get_vcs_type(&self) -> VcsType {
903        self.vcs_type.clone()
904    }
905
906    /// Checks if the directory is a git repository.
907    pub fn is_git_repo(&self) -> bool {
908        self.repo_dir.join(".git").exists()
909    }
910
911    /// Checks if the directory is an SVN repository.
912    pub fn is_svn_repo(&self) -> bool {
913        self.repo_dir.join(".svn").exists()
914    }
915
916    /// Checks if the directory is a Mercurial repository.
917    pub fn is_hg_repo(&self) -> bool {
918        self.repo_dir.join(".hg").exists()
919    }
920
921    /// Gets the current branch for the detected VCS.
922    pub fn get_current_branch(&self) -> Result<String> {
923        match self.vcs_type {
924            VcsType::Git => self.get_git_branch(),
925            VcsType::Svn => self.get_svn_branch(),
926            VcsType::Mercurial => self.get_hg_branch(),
927            VcsType::None => Err(Error::external_error("vcs".to_string(), "No version control system detected".to_string(), Span::unknown())),
928        }
929    }
930
931    /// Gets the current git branch.
932    pub fn get_git_branch(&self) -> Result<String> {
933        let output = Command::new("git").arg("branch").arg("--show-current").current_dir(&self.repo_dir).output()?;
934
935        if !output.status.success() {
936            return Err(Error::external_error("vcs".to_string(), format!("Failed to get current branch: {}", String::from_utf8_lossy(&output.stderr)), Span::unknown()));
937        }
938
939        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
940    }
941
942    /// Gets the current SVN branch.
943    pub fn get_svn_branch(&self) -> Result<String> {
944        let output = Command::new("svn").arg("info").current_dir(&self.repo_dir).output()?;
945
946        if !output.status.success() {
947            return Err(Error::external_error("vcs".to_string(), format!("Failed to get SVN info: {}", String::from_utf8_lossy(&output.stderr)), Span::unknown()));
948        }
949
950        let output_str = String::from_utf8_lossy(&output.stdout).to_string();
951        for line in output_str.lines() {
952            if line.starts_with("URL:") {
953                let url = line.split(": ").nth(1).unwrap_or("");
954                // Extract branch name from URL (assuming standard SVN layout)
955                if let Some(branch_part) = url.split("/branches/").nth(1) {
956                    return Ok(branch_part.split("/").next().unwrap_or("").to_string());
957                }
958                else if url.contains("/trunk/") {
959                    return Ok("trunk".to_string());
960                }
961                else if url.contains("/tags/") {
962                    return Ok("tags".to_string());
963                }
964            }
965        }
966
967        Ok("unknown".to_string())
968    }
969
970    /// Gets the current Mercurial branch.
971    pub fn get_hg_branch(&self) -> Result<String> {
972        let output = Command::new("hg").arg("branch").current_dir(&self.repo_dir).output()?;
973
974        if !output.status.success() {
975            return Err(Error::external_error("vcs".to_string(), format!("Failed to get Mercurial branch: {}", String::from_utf8_lossy(&output.stderr)), Span::unknown()));
976        }
977
978        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
979    }
980
981    /// Gets the latest commit hash for the detected VCS.
982    pub fn get_latest_commit(&self) -> Result<String> {
983        match self.vcs_type {
984            VcsType::Git => self.get_git_commit(),
985            VcsType::Svn => self.get_svn_commit(),
986            VcsType::Mercurial => self.get_hg_commit(),
987            VcsType::None => Err(Error::external_error("vcs".to_string(), "No version control system detected".to_string(), Span::unknown())),
988        }
989    }
990
991    /// Gets the latest git commit hash.
992    pub fn get_git_commit(&self) -> Result<String> {
993        let output = Command::new("git").arg("rev-parse").arg("HEAD").current_dir(&self.repo_dir).output()?;
994
995        if !output.status.success() {
996            return Err(Error::external_error("vcs".to_string(), format!("Failed to get latest commit: {}", String::from_utf8_lossy(&output.stderr)), Span::unknown()));
997        }
998
999        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1000    }
1001
1002    /// Gets the latest SVN revision.
1003    pub fn get_svn_commit(&self) -> Result<String> {
1004        let output = Command::new("svn").arg("info").current_dir(&self.repo_dir).output()?;
1005
1006        if !output.status.success() {
1007            return Err(Error::external_error("vcs".to_string(), format!("Failed to get SVN info: {}", String::from_utf8_lossy(&output.stderr)), Span::unknown()));
1008        }
1009
1010        let output_str = String::from_utf8_lossy(&output.stdout).to_string();
1011        for line in output_str.lines() {
1012            if line.starts_with("Revision:") {
1013                return Ok(line.split(": ").nth(1).unwrap_or("").to_string());
1014            }
1015        }
1016
1017        Err(Error::external_error("vcs".to_string(), "Failed to extract SVN revision".to_string(), Span::unknown()))
1018    }
1019
1020    /// Gets the latest Mercurial commit hash.
1021    pub fn get_hg_commit(&self) -> Result<String> {
1022        let output = Command::new("hg").arg("identify").arg("--id").current_dir(&self.repo_dir).output()?;
1023
1024        if !output.status.success() {
1025            return Err(Error::external_error("vcs".to_string(), format!("Failed to get Mercurial commit: {}", String::from_utf8_lossy(&output.stderr)), Span::unknown()));
1026        }
1027
1028        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1029    }
1030
1031    /// Gets the status for the detected VCS.
1032    pub fn get_status(&self) -> Result<String> {
1033        match self.vcs_type {
1034            VcsType::Git => self.get_git_status(),
1035            VcsType::Svn => self.get_svn_status(),
1036            VcsType::Mercurial => self.get_hg_status(),
1037            VcsType::None => Err(Error::external_error("vcs".to_string(), "No version control system detected".to_string(), Span::unknown())),
1038        }
1039    }
1040
1041    /// Gets the git status.
1042    pub fn get_git_status(&self) -> Result<String> {
1043        let output = Command::new("git").arg("status").arg("--porcelain").current_dir(&self.repo_dir).output()?;
1044
1045        if !output.status.success() {
1046            return Err(Error::external_error("vcs".to_string(), format!("Failed to get git status: {}", String::from_utf8_lossy(&output.stderr)), Span::unknown()));
1047        }
1048
1049        Ok(String::from_utf8_lossy(&output.stdout).to_string())
1050    }
1051
1052    /// Gets the SVN status.
1053    pub fn get_svn_status(&self) -> Result<String> {
1054        let output = Command::new("svn").arg("status").current_dir(&self.repo_dir).output()?;
1055
1056        if !output.status.success() {
1057            return Err(Error::external_error("vcs".to_string(), format!("Failed to get SVN status: {}", String::from_utf8_lossy(&output.stderr)), Span::unknown()));
1058        }
1059
1060        Ok(String::from_utf8_lossy(&output.stdout).to_string())
1061    }
1062
1063    /// Gets the Mercurial status.
1064    pub fn get_hg_status(&self) -> Result<String> {
1065        let output = Command::new("hg").arg("status").current_dir(&self.repo_dir).output()?;
1066
1067        if !output.status.success() {
1068            return Err(Error::external_error("vcs".to_string(), format!("Failed to get Mercurial status: {}", String::from_utf8_lossy(&output.stderr)), Span::unknown()));
1069        }
1070
1071        Ok(String::from_utf8_lossy(&output.stdout).to_string())
1072    }
1073
1074    /// Generates a change summary for the detected VCS.
1075    pub fn generate_change_summary(&self) -> Result<String> {
1076        match self.vcs_type {
1077            VcsType::Git => self.generate_git_change_summary(),
1078            VcsType::Svn => self.generate_svn_change_summary(),
1079            VcsType::Mercurial => self.generate_hg_change_summary(),
1080            VcsType::None => Err(Error::external_error("vcs".to_string(), "No version control system detected".to_string(), Span::unknown())),
1081        }
1082    }
1083
1084    /// Generates a change summary from git.
1085    pub fn generate_git_change_summary(&self) -> Result<String> {
1086        let status = self.get_git_status()?;
1087        let mut summary = String::new();
1088        let mut added = 0;
1089        let mut modified = 0;
1090        let mut deleted = 0;
1091        let mut renamed = 0;
1092
1093        for line in status.lines() {
1094            if line.len() < 3 {
1095                continue;
1096            }
1097
1098            let status_code = &line[0..2];
1099            match status_code {
1100                "A " => added += 1,
1101                "M " => modified += 1,
1102                "D " => deleted += 1,
1103                "R " => renamed += 1,
1104                _ => {}
1105            }
1106        }
1107
1108        summary.push_str(&format!("Git change summary:\n"));
1109        summary.push_str(&format!("- Added: {}\n", added));
1110        summary.push_str(&format!("- Modified: {}\n", modified));
1111        summary.push_str(&format!("- Deleted: {}\n", deleted));
1112        summary.push_str(&format!("- Renamed: {}\n", renamed));
1113
1114        if !status.is_empty() {
1115            summary.push_str("\nDetailed changes:\n");
1116            summary.push_str(&status);
1117        }
1118
1119        Ok(summary)
1120    }
1121
1122    /// Generates a change summary from SVN.
1123    pub fn generate_svn_change_summary(&self) -> Result<String> {
1124        let status = self.get_svn_status()?;
1125        let mut summary = String::new();
1126        let mut added = 0;
1127        let mut modified = 0;
1128        let mut deleted = 0;
1129        let mut other = 0;
1130
1131        for line in status.lines() {
1132            if line.is_empty() {
1133                continue;
1134            }
1135
1136            let status_char = line.chars().next().unwrap_or(' ');
1137            match status_char {
1138                'A' => added += 1,
1139                'M' => modified += 1,
1140                'D' => deleted += 1,
1141                _ => other += 1,
1142            }
1143        }
1144
1145        summary.push_str(&format!("SVN change summary:\n"));
1146        summary.push_str(&format!("- Added: {}\n", added));
1147        summary.push_str(&format!("- Modified: {}\n", modified));
1148        summary.push_str(&format!("- Deleted: {}\n", deleted));
1149        if other > 0 {
1150            summary.push_str(&format!("- Other: {}\n", other));
1151        }
1152
1153        if !status.is_empty() {
1154            summary.push_str("\nDetailed changes:\n");
1155            summary.push_str(&status);
1156        }
1157
1158        Ok(summary)
1159    }
1160
1161    /// Generates a change summary from Mercurial.
1162    pub fn generate_hg_change_summary(&self) -> Result<String> {
1163        let status = self.get_hg_status()?;
1164        let mut summary = String::new();
1165        let mut added = 0;
1166        let mut modified = 0;
1167        let mut deleted = 0;
1168        let mut renamed = 0;
1169        let mut other = 0;
1170
1171        for line in status.lines() {
1172            if line.is_empty() {
1173                continue;
1174            }
1175
1176            let status_char = line.chars().next().unwrap_or(' ');
1177            match status_char {
1178                'A' => added += 1,
1179                'M' => modified += 1,
1180                'D' => deleted += 1,
1181                'R' => renamed += 1,
1182                _ => other += 1,
1183            }
1184        }
1185
1186        summary.push_str(&format!("Mercurial change summary:\n"));
1187        summary.push_str(&format!("- Added: {}\n", added));
1188        summary.push_str(&format!("- Modified: {}\n", modified));
1189        summary.push_str(&format!("- Deleted: {}\n", deleted));
1190        if renamed > 0 {
1191            summary.push_str(&format!("- Renamed: {}\n", renamed));
1192        }
1193        if other > 0 {
1194            summary.push_str(&format!("- Other: {}\n", other));
1195        }
1196
1197        if !status.is_empty() {
1198            summary.push_str("\nDetailed changes:\n");
1199            summary.push_str(&status);
1200        }
1201
1202        Ok(summary)
1203    }
1204}