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#[cfg(test)]
384mod tests {
385 use super::*;
386
387 fn create_test_branch(name: &str) -> TopologyBranch {
388 TopologyBranch::new(name.to_string(), "abc1234".to_string(), Local::now())
389 }
390
391 #[test]
392 fn test_branch_status_label() {
393 assert_eq!(BranchStatus::Active.label(), "HEAD");
394 assert_eq!(BranchStatus::Normal.label(), "");
395 assert_eq!(BranchStatus::Stale.label(), "stale");
396 assert_eq!(BranchStatus::Merged.label(), "merged");
397 }
398
399 #[test]
400 fn test_branch_relation_summary_merged() {
401 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
402 relation.is_merged = true;
403 assert_eq!(relation.summary(), "merged");
404 }
405
406 #[test]
407 fn test_branch_relation_summary_up_to_date() {
408 let relation = BranchRelation::new("main".to_string(), "feature".to_string());
409 assert_eq!(relation.summary(), "up to date");
410 }
411
412 #[test]
413 fn test_branch_relation_summary_ahead() {
414 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
415 relation.ahead_count = 3;
416 assert_eq!(relation.summary(), "3 ahead");
417 }
418
419 #[test]
420 fn test_branch_relation_summary_behind() {
421 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
422 relation.behind_count = 2;
423 assert_eq!(relation.summary(), "2 behind");
424 }
425
426 #[test]
427 fn test_branch_relation_summary_ahead_and_behind() {
428 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
429 relation.ahead_count = 3;
430 relation.behind_count = 2;
431 assert_eq!(relation.summary(), "3 ahead, 2 behind");
432 }
433
434 #[test]
435 fn test_topology_branch_new() {
436 let branch = create_test_branch("feature");
437 assert_eq!(branch.name, "feature");
438 assert_eq!(branch.status, BranchStatus::Normal);
439 }
440
441 #[test]
442 fn test_topology_branch_with_status() {
443 let branch = create_test_branch("feature").with_status(BranchStatus::Active);
444 assert_eq!(branch.status, BranchStatus::Active);
445 }
446
447 #[test]
448 fn test_topology_branch_is_ahead() {
449 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
450 relation.ahead_count = 3;
451 let branch = create_test_branch("feature").with_relation(relation);
452 assert!(branch.is_ahead());
453 }
454
455 #[test]
456 fn test_topology_branch_is_behind() {
457 let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
458 relation.behind_count = 2;
459 let branch = create_test_branch("feature").with_relation(relation);
460 assert!(branch.is_behind());
461 }
462
463 #[test]
464 fn test_branch_topology_new() {
465 let topology = BranchTopology::new("main".to_string());
466 assert_eq!(topology.main_branch, "main");
467 assert_eq!(topology.branch_count(), 0);
468 }
469
470 #[test]
471 fn test_branch_topology_add_branch() {
472 let mut topology = BranchTopology::new("main".to_string());
473 topology.add_branch(create_test_branch("feature"));
474 assert_eq!(topology.branch_count(), 1);
475 }
476
477 #[test]
478 fn test_branch_topology_active_branch() {
479 let mut topology = BranchTopology::new("main".to_string());
480 topology.add_branch(create_test_branch("feature"));
481 topology.add_branch(create_test_branch("main").with_status(BranchStatus::Active));
482
483 let active = topology.active_branch();
484 assert!(active.is_some());
485 assert_eq!(active.unwrap().name, "main");
486 }
487
488 #[test]
489 fn test_branch_topology_stale_count() {
490 let mut topology = BranchTopology::new("main".to_string());
491 topology.add_branch(create_test_branch("feature1"));
492 topology.add_branch(create_test_branch("feature2").with_status(BranchStatus::Stale));
493 topology.add_branch(create_test_branch("feature3").with_status(BranchStatus::Stale));
494
495 assert_eq!(topology.stale_count(), 2);
496 }
497
498 #[test]
499 fn test_branch_topology_merged_count() {
500 let mut topology = BranchTopology::new("main".to_string());
501 topology.add_branch(create_test_branch("feature1"));
502 topology.add_branch(create_test_branch("feature2").with_status(BranchStatus::Merged));
503
504 assert_eq!(topology.merged_count(), 1);
505 }
506}