1#![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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Copy)]
25pub enum ChangeType {
26 #[serde(rename = "breaking")]
28 Breaking,
29 #[serde(rename = "feature")]
31 Feature,
32 #[serde(rename = "fix")]
34 Fix,
35 #[serde(rename = "docs")]
37 Docs,
38 #[serde(rename = "refactor")]
40 Refactor,
41 #[serde(rename = "perf")]
43 Perf,
44 #[serde(rename = "test")]
46 Test,
47 #[serde(rename = "build")]
49 Build,
50 #[serde(rename = "chore")]
52 Chore,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ChangeSet {
58 pub id: String,
60 pub r#type: ChangeType,
62 pub summary: String,
64 pub description: Option<String>,
66 pub author: Option<String>,
68 pub packages: Vec<String>,
70 pub prerelease: bool,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ChangeSetTemplate {
77 pub name: String,
79 pub default_type: ChangeType,
81 pub description_template: Option<String>,
83 pub default_packages: Vec<String>,
85 pub default_prerelease: bool,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ChangeSetPreset {
92 pub name: String,
94 pub description: Option<String>,
96 pub template: String,
98 pub metadata: HashMap<String, String>,
100}
101
102pub struct ChangeSetManager {
104 pub changes_dir: PathBuf,
106}
107
108impl ChangeSetManager {
109 pub fn new(changes_dir: &Path) -> Self {
111 Self { changes_dir: changes_dir.to_path_buf() }
112 }
113
114 pub fn create_change_set(&self, change_set: &ChangeSet) -> Result<PathBuf> {
116 fs::create_dir_all(&self.changes_dir)?;
118
119 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 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 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 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 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 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 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 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 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 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 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 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 let merged_summary = change_sets.iter().map(|cs| cs.summary.clone()).collect::<Vec<_>>().join("; ");
235
236 let merged_description = change_sets.iter().filter_map(|cs| cs.description.clone()).collect::<Vec<_>>().join("\n\n");
238
239 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 let merged_prerelease = change_sets.iter().any(|cs| cs.prerelease);
250
251 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, packages: merged_packages,
259 prerelease: merged_prerelease,
260 };
261
262 Ok(merged_change_set)
263 }
264
265 pub fn resolve_conflicts(&self, change_sets: &[ChangeSet]) -> Result<Vec<ChangeSet>> {
267 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 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 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 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 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 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 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 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 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 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 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 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 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 let type_order = [ChangeType::Breaking, ChangeType::Feature, ChangeType::Fix, ChangeType::Perf, ChangeType::Refactor, ChangeType::Docs, ChangeType::Test, ChangeType::Build, ChangeType::Chore];
407
408 for change_type in &type_order {
410 if let Some(cs_list) = grouped.get(change_type) {
411 if !cs_list.is_empty() {
412 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
494pub enum FileChangeType {
495 Added,
497 Modified,
499 Deleted,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct FileChange {
506 pub path: PathBuf,
508 pub r#type: FileChangeType,
510 pub old_hash: Option<String>,
512 pub new_hash: Option<String>,
514 pub modified_time: Option<u64>,
516}
517
518pub struct FileChangeDetector {
520 pub base_dir: PathBuf,
522 pub include_patterns: Vec<String>,
524 pub exclude_patterns: Vec<String>,
526}
527
528impl FileChangeDetector {
529 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 pub fn with_include_patterns(mut self, patterns: Vec<String>) -> Self {
536 self.include_patterns = patterns;
537 self
538 }
539
540 pub fn with_exclude_patterns(mut self, patterns: Vec<String>) -> Self {
542 self.exclude_patterns = patterns;
543 self
544 }
545
546 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 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 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 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 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 let current_hash = self.compute_file_hash(path)?;
582 current_state.insert(relative_path.to_path_buf(), current_hash.clone());
583
584 if let Some(prev_state) = previous_state {
586 if let Some(old_hash) = prev_state.get(relative_path) {
587 if old_hash != ¤t_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 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 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
645pub struct ChangePreview {
647 pub file_changes: Vec<FileChange>,
649 pub change_sets: Vec<ChangeSet>,
651}
652
653impl ChangePreview {
654 pub fn new(file_changes: Vec<FileChange>, change_sets: Vec<ChangeSet>) -> Self {
656 Self { file_changes, change_sets }
657 }
658
659 pub fn generate_preview(&self) -> String {
661 let mut preview = String::new();
662
663 let detector = FileChangeDetector::new(Path::new("."));
665 preview.push_str(&detector.generate_change_summary(&self.file_changes));
666
667 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#[derive(Debug, Clone, Serialize, Deserialize)]
681pub struct ChangeStats {
682 pub total_change_sets: usize,
684 pub change_sets_by_type: HashMap<ChangeType, usize>,
686 pub change_sets_by_package: HashMap<String, usize>,
688 pub breaking_changes: usize,
690 pub features: usize,
692 pub bug_fixes: usize,
694 pub other_changes: usize,
696 pub avg_changes_per_package: f64,
698 pub most_common_change_type: Option<ChangeType>,
700 pub most_affected_package: Option<String>,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize)]
706pub struct ChangeTrendPoint {
707 pub timestamp: chrono::DateTime<Utc>,
709 pub change_set_count: usize,
711 pub change_sets_by_type: HashMap<ChangeType, usize>,
713 pub packages_affected: usize,
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize)]
719pub struct ChangeTrend {
720 pub time_period: String,
722 pub data_points: Vec<ChangeTrendPoint>,
724 pub overall_stats: ChangeStats,
726 pub change_rate: f64,
728 pub trend_direction: String,
730 pub most_active_period: Option<chrono::DateTime<Utc>>,
732}
733
734impl ChangeStats {
735 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 for change_set in change_sets {
747 *change_sets_by_type.entry(change_set.r#type.clone()).or_insert(0) += 1;
749
750 for package in &change_set.packages {
752 *change_sets_by_package.entry(package.clone()).or_insert(0) += 1;
753 }
754
755 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 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 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 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 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 pub fn from_change_sets(change_sets: &[ChangeSet], time_period: &str) -> Self {
813 let stats = ChangeStats::from_change_sets(change_sets);
816
817 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 let change_rate = change_sets.len() as f64 / 30.0;
823
824 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 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#[derive(Debug, Clone, PartialEq, Eq)]
859pub enum VcsType {
860 Git,
862 Svn,
864 Mercurial,
866 None,
868}
869
870pub struct VcsIntegration {
872 pub repo_dir: PathBuf,
874 pub vcs_type: VcsType,
876}
877
878impl VcsIntegration {
879 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 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 pub fn get_vcs_type(&self) -> VcsType {
903 self.vcs_type.clone()
904 }
905
906 pub fn is_git_repo(&self) -> bool {
908 self.repo_dir.join(".git").exists()
909 }
910
911 pub fn is_svn_repo(&self) -> bool {
913 self.repo_dir.join(".svn").exists()
914 }
915
916 pub fn is_hg_repo(&self) -> bool {
918 self.repo_dir.join(".hg").exists()
919 }
920
921 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}