1use chrono::{DateTime, Local};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum BranchStatus {
8 Active,
10 Normal,
12 #[allow(dead_code)]
14 Wip,
15 Stale,
17 Merged,
19}
20
21impl BranchStatus {
22 pub fn label(&self) -> &'static str {
24 match self {
25 Self::Active => "HEAD",
26 Self::Normal => "",
27 Self::Wip => "WIP",
28 Self::Stale => "stale",
29 Self::Merged => "merged",
30 }
31 }
32
33 pub fn color_index(&self) -> usize {
35 match self {
36 Self::Active => 0, Self::Normal => 1, Self::Wip => 2, Self::Stale => 3, Self::Merged => 4, }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct BranchRelation {
48 pub base: String,
50 pub branch: String,
52 pub merge_base: String,
54 pub ahead_count: usize,
56 pub behind_count: usize,
58 pub is_merged: bool,
60}
61
62impl BranchRelation {
63 pub fn new(base: String, branch: String) -> Self {
65 Self {
66 base,
67 branch,
68 merge_base: String::new(),
69 ahead_count: 0,
70 behind_count: 0,
71 is_merged: false,
72 }
73 }
74
75 pub fn summary(&self) -> String {
77 if self.is_merged {
78 "merged".to_string()
79 } else if self.ahead_count == 0 && self.behind_count == 0 {
80 "up to date".to_string()
81 } else {
82 let mut parts = Vec::new();
83 if self.ahead_count > 0 {
84 parts.push(format!("{} ahead", self.ahead_count));
85 }
86 if self.behind_count > 0 {
87 parts.push(format!("{} behind", self.behind_count));
88 }
89 parts.join(", ")
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum HealthWarning {
97 Stale,
99 LongLived,
101 FarBehind,
103 LargeDivergence,
105}
106
107impl HealthWarning {
108 pub fn description(&self) -> &'static str {
110 match self {
111 Self::Stale => "No activity for 30+ days",
112 Self::LongLived => "Branch exists for 60+ days",
113 Self::FarBehind => "50+ commits behind main",
114 Self::LargeDivergence => "Large divergence from main",
115 }
116 }
117
118 pub fn icon(&self) -> &'static str {
120 match self {
121 Self::Stale => "⚠",
122 Self::LongLived => "⏳",
123 Self::FarBehind => "⬇",
124 Self::LargeDivergence => "⚡",
125 }
126 }
127}
128
129#[derive(Debug, Clone, Default)]
131pub struct BranchHealth {
132 pub warnings: Vec<HealthWarning>,
134}
135
136impl BranchHealth {
137 pub fn new() -> Self {
139 Self {
140 warnings: Vec::new(),
141 }
142 }
143
144 pub fn add_warning(&mut self, warning: HealthWarning) {
146 if !self.warnings.contains(&warning) {
147 self.warnings.push(warning);
148 }
149 }
150
151 pub fn is_healthy(&self) -> bool {
153 self.warnings.is_empty()
154 }
155
156 pub fn warning_count(&self) -> usize {
158 self.warnings.len()
159 }
160
161 pub fn warning_icons(&self) -> String {
163 self.warnings
164 .iter()
165 .map(|w| w.icon())
166 .collect::<Vec<_>>()
167 .join("")
168 }
169}
170
171#[derive(Debug, Clone)]
173pub struct TopologyBranch {
174 pub name: String,
176 pub head_hash: String,
178 pub status: BranchStatus,
180 pub last_activity: DateTime<Local>,
182 pub relation: Option<BranchRelation>,
184 pub commit_count: usize,
186 pub health: BranchHealth,
188}
189
190impl TopologyBranch {
191 pub fn new(name: String, head_hash: String, last_activity: DateTime<Local>) -> Self {
193 Self {
194 name,
195 head_hash,
196 status: BranchStatus::Normal,
197 last_activity,
198 relation: None,
199 commit_count: 0,
200 health: BranchHealth::new(),
201 }
202 }
203
204 pub fn with_status(mut self, status: BranchStatus) -> Self {
206 self.status = status;
207 self
208 }
209
210 pub fn with_relation(mut self, relation: BranchRelation) -> Self {
212 self.relation = Some(relation);
213 self
214 }
215
216 pub fn with_commit_count(mut self, count: usize) -> Self {
218 self.commit_count = count;
219 self
220 }
221
222 pub fn is_stale(&self, threshold_days: i64) -> bool {
224 let now = Local::now();
225 let diff = now.signed_duration_since(self.last_activity);
226 diff.num_days() >= threshold_days
227 }
228
229 pub fn is_ahead(&self) -> bool {
231 self.relation.as_ref().is_some_and(|r| r.ahead_count > 0)
232 }
233
234 pub fn is_behind(&self) -> bool {
236 self.relation.as_ref().is_some_and(|r| r.behind_count > 0)
237 }
238
239 pub fn calculate_health(&mut self, config: &TopologyConfig) {
241 self.health = BranchHealth::new();
242
243 if self.is_stale(config.stale_threshold_days) {
245 self.health.add_warning(HealthWarning::Stale);
246 }
247
248 let now = Local::now();
250 let age_days = now.signed_duration_since(self.last_activity).num_days();
251 if age_days >= config.long_lived_threshold_days {
252 self.health.add_warning(HealthWarning::LongLived);
253 }
254
255 if let Some(ref relation) = self.relation {
257 if relation.behind_count >= config.far_behind_threshold {
258 self.health.add_warning(HealthWarning::FarBehind);
259 }
260
261 if relation.ahead_count >= config.divergence_threshold
263 && relation.behind_count >= config.divergence_threshold
264 {
265 self.health.add_warning(HealthWarning::LargeDivergence);
266 }
267 }
268 }
269}
270
271#[derive(Debug, Clone)]
273pub struct TopologyConfig {
274 pub stale_threshold_days: i64,
276 pub long_lived_threshold_days: i64,
278 pub far_behind_threshold: usize,
280 pub divergence_threshold: usize,
282 pub max_branches: usize,
284}
285
286impl Default for TopologyConfig {
287 fn default() -> Self {
288 Self {
289 stale_threshold_days: 30,
290 long_lived_threshold_days: 60,
291 far_behind_threshold: 50,
292 divergence_threshold: 20,
293 max_branches: 50,
294 }
295 }
296}
297
298#[derive(Debug, Clone)]
300pub struct BranchTopology {
301 pub main_branch: String,
303 pub branches: Vec<TopologyBranch>,
305 pub max_column: usize,
307 pub config: TopologyConfig,
309}
310
311impl BranchTopology {
312 pub fn new(main_branch: String) -> Self {
314 Self {
315 main_branch,
316 branches: Vec::new(),
317 max_column: 0,
318 config: TopologyConfig::default(),
319 }
320 }
321
322 pub fn add_branch(&mut self, branch: TopologyBranch) {
324 self.branches.push(branch);
325 }
326
327 pub fn branch_count(&self) -> usize {
329 self.branches.len()
330 }
331
332 pub fn active_branch(&self) -> Option<&TopologyBranch> {
334 self.branches
335 .iter()
336 .find(|b| b.status == BranchStatus::Active)
337 }
338
339 pub fn stale_count(&self) -> usize {
341 self.branches
342 .iter()
343 .filter(|b| b.status == BranchStatus::Stale)
344 .count()
345 }
346
347 pub fn merged_count(&self) -> usize {
349 self.branches
350 .iter()
351 .filter(|b| b.status == BranchStatus::Merged)
352 .count()
353 }
354
355 pub fn calculate_all_health(&mut self) {
357 let config = self.config.clone();
358 for branch in &mut self.branches {
359 if branch.name != self.main_branch && branch.status != BranchStatus::Merged {
361 branch.calculate_health(&config);
362 }
363 }
364 }
365
366 pub fn unhealthy_count(&self) -> usize {
368 self.branches
369 .iter()
370 .filter(|b| !b.health.is_healthy())
371 .count()
372 }
373
374 pub fn warning_count(&self, warning: HealthWarning) -> usize {
376 self.branches
377 .iter()
378 .filter(|b| b.health.warnings.contains(&warning))
379 .count()
380 }
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum RecommendedAction {
390 Delete,
392 Rebase,
394 Merge,
396 Review,
398 Keep,
400}
401
402impl RecommendedAction {
403 pub fn label(&self) -> &'static str {
405 match self {
406 Self::Delete => "Delete",
407 Self::Rebase => "Rebase",
408 Self::Merge => "Merge",
409 Self::Review => "Review",
410 Self::Keep => "Keep",
411 }
412 }
413
414 pub fn icon(&self) -> &'static str {
416 match self {
417 Self::Delete => "🗑",
418 Self::Rebase => "↻",
419 Self::Merge => "⤵",
420 Self::Review => "👁",
421 Self::Keep => "✓",
422 }
423 }
424
425 pub fn color(&self) -> &'static str {
427 match self {
428 Self::Delete => "red",
429 Self::Rebase => "yellow",
430 Self::Merge => "green",
431 Self::Review => "cyan",
432 Self::Keep => "white",
433 }
434 }
435
436 pub fn description(&self) -> &'static str {
438 match self {
439 Self::Delete => "Branch is merged or inactive for 60+ days",
440 Self::Rebase => "Branch is significantly behind the base branch",
441 Self::Merge => "Branch has changes ready to merge",
442 Self::Review => "Long-lived branch needs attention",
443 Self::Keep => "Branch is in good condition",
444 }
445 }
446}
447
448#[derive(Debug, Clone)]
450pub struct BranchRecommendation {
451 pub branch_name: String,
453 pub action: RecommendedAction,
455 pub reason: String,
457 pub priority: u8,
459 pub ahead: usize,
461 pub behind: usize,
462 pub days_inactive: i64,
464}
465
466impl BranchRecommendation {
467 pub fn new(
469 branch_name: String,
470 action: RecommendedAction,
471 reason: impl Into<String>,
472 priority: u8,
473 ) -> Self {
474 Self {
475 branch_name,
476 action,
477 reason: reason.into(),
478 priority,
479 ahead: 0,
480 behind: 0,
481 days_inactive: 0,
482 }
483 }
484
485 pub fn with_counts(mut self, ahead: usize, behind: usize) -> Self {
487 self.ahead = ahead;
488 self.behind = behind;
489 self
490 }
491
492 pub fn with_days_inactive(mut self, days: i64) -> Self {
494 self.days_inactive = days;
495 self
496 }
497}
498
499#[derive(Debug, Clone, Default)]
501pub struct BranchRecommendations {
502 pub recommendations: Vec<BranchRecommendation>,
504 pub delete_count: usize,
506 pub rebase_count: usize,
508 pub merge_count: usize,
510 pub review_count: usize,
512 pub total_branches: usize,
514}
515
516impl BranchRecommendations {
517 pub fn new() -> Self {
519 Self::default()
520 }
521
522 pub fn add(&mut self, recommendation: BranchRecommendation) {
524 match recommendation.action {
525 RecommendedAction::Delete => self.delete_count += 1,
526 RecommendedAction::Rebase => self.rebase_count += 1,
527 RecommendedAction::Merge => self.merge_count += 1,
528 RecommendedAction::Review => self.review_count += 1,
529 RecommendedAction::Keep => {}
530 }
531 self.recommendations.push(recommendation);
532 }
533
534 pub fn sort_by_priority(&mut self) {
536 self.recommendations
537 .sort_by(|a, b| b.priority.cmp(&a.priority));
538 }
539
540 pub fn by_action(&self, action: RecommendedAction) -> Vec<&BranchRecommendation> {
542 self.recommendations
543 .iter()
544 .filter(|r| r.action == action)
545 .collect()
546 }
547
548 pub fn deletable_branches(&self) -> Vec<&str> {
550 self.recommendations
551 .iter()
552 .filter(|r| r.action == RecommendedAction::Delete)
553 .map(|r| r.branch_name.as_str())
554 .collect()
555 }
556
557 pub fn get_recommendation(&self, branch_name: &str) -> Option<&BranchRecommendation> {
559 self.recommendations
560 .iter()
561 .find(|r| r.branch_name == branch_name)
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 fn create_test_branch(name: &str) -> TopologyBranch {
570 TopologyBranch::new(name.to_string(), "abc1234".to_string(), Local::now())
571 }
572
573 #[test]
574 fn test_branch_status_label() {
575 assert_eq!(BranchStatus::Active.label(), "HEAD");
576 assert_eq!(BranchStatus::Normal.label(), "");
577 assert_eq!(BranchStatus::Stale.label(), "stale");
578 assert_eq!(BranchStatus::Merged.label(), "merged");
579 }
580
581 #[test]
582 fn test_branch_relation_summary_merged() {
583 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
584 relation.is_merged = true;
585 assert_eq!(relation.summary(), "merged");
586 }
587
588 #[test]
589 fn test_branch_relation_summary_up_to_date() {
590 let relation = BranchRelation::new("main".to_string(), "feature".to_string());
591 assert_eq!(relation.summary(), "up to date");
592 }
593
594 #[test]
595 fn test_branch_relation_summary_ahead() {
596 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
597 relation.ahead_count = 3;
598 assert_eq!(relation.summary(), "3 ahead");
599 }
600
601 #[test]
602 fn test_branch_relation_summary_behind() {
603 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
604 relation.behind_count = 2;
605 assert_eq!(relation.summary(), "2 behind");
606 }
607
608 #[test]
609 fn test_branch_relation_summary_ahead_and_behind() {
610 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
611 relation.ahead_count = 3;
612 relation.behind_count = 2;
613 assert_eq!(relation.summary(), "3 ahead, 2 behind");
614 }
615
616 #[test]
617 fn test_topology_branch_new() {
618 let branch = create_test_branch("feature");
619 assert_eq!(branch.name, "feature");
620 assert_eq!(branch.status, BranchStatus::Normal);
621 }
622
623 #[test]
624 fn test_topology_branch_with_status() {
625 let branch = create_test_branch("feature").with_status(BranchStatus::Active);
626 assert_eq!(branch.status, BranchStatus::Active);
627 }
628
629 #[test]
630 fn test_topology_branch_is_ahead() {
631 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
632 relation.ahead_count = 3;
633 let branch = create_test_branch("feature").with_relation(relation);
634 assert!(branch.is_ahead());
635 }
636
637 #[test]
638 fn test_topology_branch_is_behind() {
639 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
640 relation.behind_count = 2;
641 let branch = create_test_branch("feature").with_relation(relation);
642 assert!(branch.is_behind());
643 }
644
645 #[test]
646 fn test_branch_topology_new() {
647 let topology = BranchTopology::new("main".to_string());
648 assert_eq!(topology.main_branch, "main");
649 assert_eq!(topology.branch_count(), 0);
650 }
651
652 #[test]
653 fn test_branch_topology_add_branch() {
654 let mut topology = BranchTopology::new("main".to_string());
655 topology.add_branch(create_test_branch("feature"));
656 assert_eq!(topology.branch_count(), 1);
657 }
658
659 #[test]
660 fn test_branch_topology_active_branch() {
661 let mut topology = BranchTopology::new("main".to_string());
662 topology.add_branch(create_test_branch("feature"));
663 topology.add_branch(create_test_branch("main").with_status(BranchStatus::Active));
664
665 let active = topology.active_branch();
666 assert!(active.is_some());
667 assert_eq!(active.unwrap().name, "main");
668 }
669
670 #[test]
671 fn test_branch_topology_stale_count() {
672 let mut topology = BranchTopology::new("main".to_string());
673 topology.add_branch(create_test_branch("feature1"));
674 topology.add_branch(create_test_branch("feature2").with_status(BranchStatus::Stale));
675 topology.add_branch(create_test_branch("feature3").with_status(BranchStatus::Stale));
676
677 assert_eq!(topology.stale_count(), 2);
678 }
679
680 #[test]
681 fn test_branch_topology_merged_count() {
682 let mut topology = BranchTopology::new("main".to_string());
683 topology.add_branch(create_test_branch("feature1"));
684 topology.add_branch(create_test_branch("feature2").with_status(BranchStatus::Merged));
685
686 assert_eq!(topology.merged_count(), 1);
687 }
688}