Skip to main content

gitstack/topology/
types.rs

1//! トポロジービュー用のデータ構造定義
2
3use chrono::{DateTime, Local};
4
5/// ブランチステータス
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum BranchStatus {
8    /// 現在のブランチ(HEAD)
9    Active,
10    /// 通常のブランチ
11    Normal,
12    /// 未コミット変更あり(WIP)
13    #[allow(dead_code)]
14    Wip,
15    /// 非活性(30日以上コミットなし)
16    Stale,
17    /// マージ済み
18    Merged,
19}
20
21impl BranchStatus {
22    /// ステータスの表示文字列を取得
23    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    /// ステータスに対応する色インデックスを取得
34    pub fn color_index(&self) -> usize {
35        match self {
36            Self::Active => 0, // Green
37            Self::Normal => 1, // Blue
38            Self::Wip => 2,    // Yellow
39            Self::Stale => 3,  // Gray
40            Self::Merged => 4, // Magenta
41        }
42    }
43}
44
45/// ブランチ関係情報
46#[derive(Debug, Clone)]
47pub struct BranchRelation {
48    /// ベースブランチ名
49    pub base: String,
50    /// 対象ブランチ名
51    pub branch: String,
52    /// マージベースのコミットハッシュ
53    pub merge_base: String,
54    /// ベースより進んでいるコミット数
55    pub ahead_count: usize,
56    /// ベースより遅れているコミット数
57    pub behind_count: usize,
58    /// マージ済みかどうか
59    pub is_merged: bool,
60}
61
62impl BranchRelation {
63    /// 新しいブランチ関係を作成
64    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    /// 状態サマリーを取得
76    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/// ブランチ健全性の警告種別
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum HealthWarning {
97    /// 非活性(30日以上コミットなし)
98    Stale,
99    /// 長寿命(60日以上経過)
100    LongLived,
101    /// mainから大幅に遅れている(50コミット以上)
102    FarBehind,
103    /// 大きな乖離(ahead/behind両方が多い)
104    LargeDivergence,
105}
106
107impl HealthWarning {
108    /// 警告の説明を取得
109    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    /// 警告アイコンを取得
119    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/// ブランチの健全性評価
130#[derive(Debug, Clone, Default)]
131pub struct BranchHealth {
132    /// 健全性の警告リスト
133    pub warnings: Vec<HealthWarning>,
134}
135
136impl BranchHealth {
137    /// 新しい健全性評価を作成
138    pub fn new() -> Self {
139        Self {
140            warnings: Vec::new(),
141        }
142    }
143
144    /// 警告を追加
145    pub fn add_warning(&mut self, warning: HealthWarning) {
146        if !self.warnings.contains(&warning) {
147            self.warnings.push(warning);
148        }
149    }
150
151    /// 健全かどうか(警告がない)
152    pub fn is_healthy(&self) -> bool {
153        self.warnings.is_empty()
154    }
155
156    /// 警告数を取得
157    pub fn warning_count(&self) -> usize {
158        self.warnings.len()
159    }
160
161    /// 警告アイコンを連結して取得
162    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/// トポロジー用ブランチ情報
172#[derive(Debug, Clone)]
173pub struct TopologyBranch {
174    /// ブランチ名
175    pub name: String,
176    /// HEADコミットのハッシュ
177    pub head_hash: String,
178    /// ブランチステータス
179    pub status: BranchStatus,
180    /// 最終活動日時
181    pub last_activity: DateTime<Local>,
182    /// ベースブランチとの関係
183    pub relation: Option<BranchRelation>,
184    /// ブランチ上のコミット数
185    pub commit_count: usize,
186    /// 健全性評価
187    pub health: BranchHealth,
188}
189
190impl TopologyBranch {
191    /// 新しいトポロジーブランチを作成
192    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    /// ステータスを設定
205    pub fn with_status(mut self, status: BranchStatus) -> Self {
206        self.status = status;
207        self
208    }
209
210    /// 関係情報を設定
211    pub fn with_relation(mut self, relation: BranchRelation) -> Self {
212        self.relation = Some(relation);
213        self
214    }
215
216    /// コミット数を設定
217    pub fn with_commit_count(mut self, count: usize) -> Self {
218        self.commit_count = count;
219        self
220    }
221
222    /// 非活性かどうか判定(指定日数以上前)
223    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    /// ベースブランチより進んでいるか
230    pub fn is_ahead(&self) -> bool {
231        self.relation.as_ref().is_some_and(|r| r.ahead_count > 0)
232    }
233
234    /// ベースブランチより遅れているか
235    pub fn is_behind(&self) -> bool {
236        self.relation.as_ref().is_some_and(|r| r.behind_count > 0)
237    }
238
239    /// 健全性を計算・更新
240    pub fn calculate_health(&mut self, config: &TopologyConfig) {
241        self.health = BranchHealth::new();
242
243        // Stale警告(デフォルト30日)
244        if self.is_stale(config.stale_threshold_days) {
245            self.health.add_warning(HealthWarning::Stale);
246        }
247
248        // LongLived警告(60日以上)
249        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        // FarBehind警告(50コミット以上遅れ)
256        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            // LargeDivergence警告(ahead/behind両方が多い)
262            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/// トポロジー設定
272#[derive(Debug, Clone)]
273pub struct TopologyConfig {
274    /// 非活性と見なす日数
275    pub stale_threshold_days: i64,
276    /// 長寿命と見なす日数
277    pub long_lived_threshold_days: i64,
278    /// 大幅に遅れていると見なすコミット数
279    pub far_behind_threshold: usize,
280    /// 乖離が大きいと見なすコミット数(ahead/behind両方)
281    pub divergence_threshold: usize,
282    /// 表示するブランチの最大数
283    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/// ブランチトポロジー全体
299#[derive(Debug, Clone)]
300pub struct BranchTopology {
301    /// メインブランチ名
302    pub main_branch: String,
303    /// ブランチ一覧
304    pub branches: Vec<TopologyBranch>,
305    /// 最大カラム数(レイアウト用)
306    pub max_column: usize,
307    /// 設定
308    pub config: TopologyConfig,
309}
310
311impl BranchTopology {
312    /// 新しいトポロジーを作成
313    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    /// ブランチを追加
323    pub fn add_branch(&mut self, branch: TopologyBranch) {
324        self.branches.push(branch);
325    }
326
327    /// ブランチ数を取得
328    pub fn branch_count(&self) -> usize {
329        self.branches.len()
330    }
331
332    /// アクティブブランチを取得
333    pub fn active_branch(&self) -> Option<&TopologyBranch> {
334        self.branches
335            .iter()
336            .find(|b| b.status == BranchStatus::Active)
337    }
338
339    /// 非活性ブランチの数を取得
340    pub fn stale_count(&self) -> usize {
341        self.branches
342            .iter()
343            .filter(|b| b.status == BranchStatus::Stale)
344            .count()
345    }
346
347    /// マージ済みブランチの数を取得
348    pub fn merged_count(&self) -> usize {
349        self.branches
350            .iter()
351            .filter(|b| b.status == BranchStatus::Merged)
352            .count()
353    }
354
355    /// 全ブランチの健全性を計算
356    pub fn calculate_all_health(&mut self) {
357        let config = self.config.clone();
358        for branch in &mut self.branches {
359            // mainブランチとマージ済みブランチは健全性チェックをスキップ
360            if branch.name != self.main_branch && branch.status != BranchStatus::Merged {
361                branch.calculate_health(&config);
362            }
363        }
364    }
365
366    /// 健全性警告があるブランチの数を取得
367    pub fn unhealthy_count(&self) -> usize {
368        self.branches
369            .iter()
370            .filter(|b| !b.health.is_healthy())
371            .count()
372    }
373
374    /// 特定の警告を持つブランチの数を取得
375    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// =============================================================================
384// ブランチ推奨アクション
385// =============================================================================
386
387/// 推奨アクションの種類
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum RecommendedAction {
390    /// 削除推奨(マージ済み or 60日以上未更新)
391    Delete,
392    /// リベース推奨(baseブランチから大幅に遅れている)
393    Rebase,
394    /// マージ推奨(ahead > 0 でマージ候補)
395    Merge,
396    /// レビュー推奨(長寿命だがアクティブ)
397    Review,
398    /// 保持(特にアクションなし)
399    Keep,
400}
401
402impl RecommendedAction {
403    /// アクションの表示文字列を取得
404    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    /// アクションのアイコンを取得
415    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    /// アクションの色を取得
426    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    /// 説明文を取得
437    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/// ブランチごとの推奨アクション
449#[derive(Debug, Clone)]
450pub struct BranchRecommendation {
451    /// ブランチ名
452    pub branch_name: String,
453    /// 推奨アクション
454    pub action: RecommendedAction,
455    /// 推奨理由
456    pub reason: String,
457    /// 優先度(高いほど優先)
458    pub priority: u8,
459    /// ahead/behindカウント(該当する場合)
460    pub ahead: usize,
461    pub behind: usize,
462    /// 最終活動からの経過日数
463    pub days_inactive: i64,
464}
465
466impl BranchRecommendation {
467    /// 新しい推奨を作成
468    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    /// ahead/behindカウントを設定
486    pub fn with_counts(mut self, ahead: usize, behind: usize) -> Self {
487        self.ahead = ahead;
488        self.behind = behind;
489        self
490    }
491
492    /// 非活動日数を設定
493    pub fn with_days_inactive(mut self, days: i64) -> Self {
494        self.days_inactive = days;
495        self
496    }
497}
498
499/// ブランチ推奨分析結果
500#[derive(Debug, Clone, Default)]
501pub struct BranchRecommendations {
502    /// 推奨一覧(優先度順)
503    pub recommendations: Vec<BranchRecommendation>,
504    /// 削除推奨のブランチ数
505    pub delete_count: usize,
506    /// リベース推奨のブランチ数
507    pub rebase_count: usize,
508    /// マージ推奨のブランチ数
509    pub merge_count: usize,
510    /// レビュー推奨のブランチ数
511    pub review_count: usize,
512    /// 分析対象のブランチ総数
513    pub total_branches: usize,
514}
515
516impl BranchRecommendations {
517    /// 新しい推奨結果を作成
518    pub fn new() -> Self {
519        Self::default()
520    }
521
522    /// 推奨を追加
523    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    /// 優先度順にソート
535    pub fn sort_by_priority(&mut self) {
536        self.recommendations
537            .sort_by(|a, b| b.priority.cmp(&a.priority));
538    }
539
540    /// 特定のアクションの推奨を取得
541    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    /// 削除推奨のブランチ名一覧を取得
549    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    /// ブランチ名から推奨アクションを取得
558    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}