Skip to main content

gitstack/app/
types.rs

1//! Application type definitions (InputMode, QuickAction, SidebarPanel, CommitType, etc.)
2
3use std::collections::HashSet;
4
5use crate::compare::{BranchCompare, CompareTab};
6use crate::git::{BlameLine, FileHistoryEntry, FilePatch, StashEntry};
7use crate::i18n::Language;
8use crate::navigation::ListNavigation;
9use crate::pr::PrCreateState;
10use crate::related_files::RelatedFiles;
11use crate::review_queue::ReviewQueue;
12use crate::stats::{
13    ChangeCouplingAnalysis, CodeOwnership, CommitImpactAnalysis, CommitQualityAnalysis,
14    FileHeatmap, ProjectHealth, RepoStats,
15};
16
17// ===== View state structs =====
18
19/// Stats view state (cache + navigation)
20#[derive(Default)]
21pub struct StatsViewState {
22    pub cache: Option<RepoStats>,
23    pub nav: ListNavigation,
24}
25
26/// Heatmap view state
27#[derive(Default)]
28pub struct HeatmapViewState {
29    pub cache: Option<FileHeatmap>,
30    pub nav: ListNavigation,
31}
32
33/// File history view state
34#[derive(Default)]
35pub struct FileHistoryViewState {
36    pub cache: Option<Vec<FileHistoryEntry>>,
37    pub path: Option<String>,
38    pub nav: ListNavigation,
39}
40
41/// Blame view state
42#[derive(Default)]
43pub struct BlameViewState {
44    pub cache: Option<Vec<BlameLine>>,
45    pub path: Option<String>,
46    pub nav: ListNavigation,
47}
48
49/// Code ownership view state
50#[derive(Default)]
51pub struct OwnershipViewState {
52    pub cache: Option<CodeOwnership>,
53    pub nav: ListNavigation,
54}
55
56/// Stash view state
57#[derive(Default)]
58pub struct StashViewState {
59    pub cache: Option<Vec<StashEntry>>,
60    pub nav: ListNavigation,
61}
62
63/// Patch view state
64#[derive(Default)]
65pub struct PatchViewState {
66    pub cache: Option<FilePatch>,
67    pub scroll_offset: usize,
68}
69
70/// Branch compare view state
71#[derive(Default)]
72pub struct BranchCompareViewState {
73    pub cache: Option<BranchCompare>,
74    pub tab: CompareTab,
75    pub nav: ListNavigation,
76}
77
78/// Related files view state
79#[derive(Default)]
80pub struct RelatedFilesViewState {
81    pub cache: Option<RelatedFiles>,
82    pub nav: ListNavigation,
83}
84
85/// Impact score view state
86#[derive(Default)]
87pub struct ImpactScoreViewState {
88    pub cache: Option<CommitImpactAnalysis>,
89    pub nav: ListNavigation,
90}
91
92/// Change coupling view state
93#[derive(Default)]
94pub struct ChangeCouplingViewState {
95    pub cache: Option<ChangeCouplingAnalysis>,
96    pub nav: ListNavigation,
97}
98
99/// Quality score view state
100#[derive(Default)]
101pub struct QualityScoreViewState {
102    pub cache: Option<CommitQualityAnalysis>,
103    pub nav: ListNavigation,
104}
105
106/// Health dashboard view state
107#[derive(Default)]
108pub struct HealthViewState {
109    pub cache: Option<ProjectHealth>,
110}
111
112/// Review queue view state
113#[derive(Default)]
114pub struct ReviewQueueViewState {
115    pub cache: Option<ReviewQueue>,
116    pub nav: ListNavigation,
117}
118
119/// PR create view state (wraps PrCreateState)
120#[derive(Default)]
121pub struct PrCreateViewState(pub PrCreateState);
122
123/// Commit detail panel state
124#[derive(Default)]
125pub struct CommitDetailState {
126    pub scroll: usize,
127    pub h_scroll: usize,
128    pub selected_file: usize,
129    pub expanded_files: HashSet<usize>,
130}
131
132/// File diff cache state
133#[derive(Default)]
134pub struct FileDiffState {
135    pub cache: Option<FilePatch>,
136    pub(crate) cache_path: Option<String>,
137    pub scroll: usize,
138}
139
140/// Status message level
141#[derive(Debug, Clone, Copy, PartialEq)]
142pub enum StatusMessageLevel {
143    Info,
144    Success,
145    Error,
146}
147
148/// Input mode
149#[derive(Debug, Clone, Copy, PartialEq, Default)]
150pub enum InputMode {
151    #[default]
152    Normal,
153    Filter,
154    BranchSelect,
155    BranchCreate,
156    StatusView,
157    CommitInput,
158    TopologyView,
159    StatsView,
160    HeatmapView,
161    FileHistoryView,
162    TimelineView,
163    BlameView,
164    OwnershipView,
165    StashView,
166    PatchView,
167    PresetSave,
168    BranchCompareView,
169    RelatedFilesView,
170    ImpactScoreView,
171    ChangeCouplingView,
172    QualityScoreView,
173    QuickActionView,
174    ReviewQueueView,
175    PrCreate,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum QuickAction {
180    RiskSummary,
181    ReviewPack,
182    NextActions,
183    Verify,
184    HandoffClaude,
185    HandoffCodex,
186    HandoffCopilot,
187    Timeline,
188    Ownership,
189    ImpactScore,
190    ChangeCoupling,
191    QualityScore,
192}
193
194impl QuickAction {
195    pub fn id(&self) -> &'static str {
196        match self {
197            Self::RiskSummary => "risk-summary",
198            Self::ReviewPack => "review-pack",
199            Self::NextActions => "next-actions",
200            Self::Verify => "verify",
201            Self::HandoffClaude => "handoff-claude",
202            Self::HandoffCodex => "handoff-codex",
203            Self::HandoffCopilot => "handoff-copilot",
204            Self::Timeline => "timeline",
205            Self::Ownership => "ownership",
206            Self::ImpactScore => "impact-score",
207            Self::ChangeCoupling => "change-coupling",
208            Self::QualityScore => "quality-score",
209        }
210    }
211
212    pub fn title(&self, lang: Language) -> &'static str {
213        match self {
214            Self::RiskSummary => lang.quick_risk_summary(),
215            Self::ReviewPack => lang.quick_review_pack(),
216            Self::NextActions => lang.quick_next_actions(),
217            Self::Verify => lang.quick_verify(),
218            Self::HandoffClaude => lang.quick_handoff_claude(),
219            Self::HandoffCodex => lang.quick_handoff_codex(),
220            Self::HandoffCopilot => lang.quick_handoff_copilot(),
221            Self::Timeline => lang.quick_timeline(),
222            Self::Ownership => lang.quick_ownership(),
223            Self::ImpactScore => lang.quick_impact_score(),
224            Self::ChangeCoupling => lang.quick_change_coupling(),
225            Self::QualityScore => lang.quick_quality_score(),
226        }
227    }
228
229    pub fn all() -> &'static [QuickAction] {
230        &[
231            Self::RiskSummary,
232            Self::ReviewPack,
233            Self::NextActions,
234            Self::Verify,
235            Self::HandoffClaude,
236            Self::HandoffCodex,
237            Self::HandoffCopilot,
238            Self::Timeline,
239            Self::Ownership,
240            Self::ImpactScore,
241            Self::ChangeCoupling,
242            Self::QualityScore,
243        ]
244    }
245}
246
247/// Dashboard-style sidebar panel
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
249pub enum SidebarPanel {
250    #[default]
251    Commits, // 2: Commit history (default)
252    Status,   // 1: Repository info
253    Branches, // 3: Branch list
254    Files,    // 4: File change list
255    Stash,    // 5: Stash list
256}
257
258impl SidebarPanel {
259    /// Get panel from number key
260    pub fn from_number(n: u8) -> Option<Self> {
261        match n {
262            1 => Some(Self::Status),
263            2 => Some(Self::Commits),
264            3 => Some(Self::Branches),
265            4 => Some(Self::Files),
266            5 => Some(Self::Stash),
267            _ => None,
268        }
269    }
270
271    /// Get panel number
272    pub fn number(&self) -> u8 {
273        match self {
274            Self::Status => 1,
275            Self::Commits => 2,
276            Self::Branches => 3,
277            Self::Files => 4,
278            Self::Stash => 5,
279        }
280    }
281
282    /// Get panel label
283    pub fn label(&self, lang: Language) -> &'static str {
284        match self {
285            Self::Status => lang.status(),
286            Self::Commits => lang.commits(),
287            Self::Branches => lang.branches(),
288            Self::Files => lang.files(),
289            Self::Stash => lang.stash(),
290        }
291    }
292
293    /// Go to next panel
294    pub fn next(self) -> Self {
295        match self {
296            Self::Status => Self::Commits,
297            Self::Commits => Self::Branches,
298            Self::Branches => Self::Files,
299            Self::Files => Self::Stash,
300            Self::Stash => Self::Status,
301        }
302    }
303
304    /// Go to previous panel
305    pub fn prev(self) -> Self {
306        match self {
307            Self::Status => Self::Stash,
308            Self::Commits => Self::Status,
309            Self::Branches => Self::Commits,
310            Self::Files => Self::Branches,
311            Self::Stash => Self::Files,
312        }
313    }
314
315    /// Return all panels in order
316    pub fn all() -> &'static [SidebarPanel] {
317        &[
318            Self::Status,
319            Self::Commits,
320            Self::Branches,
321            Self::Files,
322            Self::Stash,
323        ]
324    }
325}
326
327/// Commit type (Conventional Commits)
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
329pub enum CommitType {
330    Feat,
331    Fix,
332    Docs,
333    Style,
334    Refactor,
335    Test,
336    Chore,
337    Perf,
338}
339
340impl CommitType {
341    /// Get commit message prefix
342    pub fn prefix(&self) -> &'static str {
343        match self {
344            Self::Feat => "feat: ",
345            Self::Fix => "fix: ",
346            Self::Docs => "docs: ",
347            Self::Style => "style: ",
348            Self::Refactor => "refactor: ",
349            Self::Test => "test: ",
350            Self::Chore => "chore: ",
351            Self::Perf => "perf: ",
352        }
353    }
354
355    /// Get corresponding key
356    pub fn key(&self) -> char {
357        match self {
358            Self::Feat => 'f',
359            Self::Fix => 'x',
360            Self::Docs => 'd',
361            Self::Style => 's',
362            Self::Refactor => 'r',
363            Self::Test => 't',
364            Self::Chore => 'c',
365            Self::Perf => 'p',
366        }
367    }
368
369    /// Get all commit types
370    pub fn all() -> &'static [CommitType] {
371        &[
372            Self::Feat,
373            Self::Fix,
374            Self::Docs,
375            Self::Style,
376            Self::Refactor,
377            Self::Test,
378            Self::Chore,
379            Self::Perf,
380        ]
381    }
382
383    /// Get display name
384    pub fn name(&self) -> &'static str {
385        match self {
386            Self::Feat => "feat",
387            Self::Fix => "fix",
388            Self::Docs => "docs",
389            Self::Style => "style",
390            Self::Refactor => "refactor",
391            Self::Test => "test",
392            Self::Chore => "chore",
393            Self::Perf => "perf",
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    // ===== View state defaults =====
403
404    #[test]
405    fn test_stats_view_state_default() {
406        let s = StatsViewState::default();
407        assert!(s.cache.is_none());
408        assert_eq!(s.nav.selected_index, 0);
409    }
410
411    #[test]
412    fn test_heatmap_view_state_default() {
413        let s = HeatmapViewState::default();
414        assert!(s.cache.is_none());
415    }
416
417    #[test]
418    fn test_file_history_view_state_default() {
419        let s = FileHistoryViewState::default();
420        assert!(s.cache.is_none());
421        assert!(s.path.is_none());
422    }
423
424    #[test]
425    fn test_blame_view_state_default() {
426        let s = BlameViewState::default();
427        assert!(s.cache.is_none());
428        assert!(s.path.is_none());
429    }
430
431    #[test]
432    fn test_ownership_view_state_default() {
433        let s = OwnershipViewState::default();
434        assert!(s.cache.is_none());
435    }
436
437    #[test]
438    fn test_stash_view_state_default() {
439        let s = StashViewState::default();
440        assert!(s.cache.is_none());
441    }
442
443    #[test]
444    fn test_patch_view_state_default() {
445        let s = PatchViewState::default();
446        assert!(s.cache.is_none());
447        assert_eq!(s.scroll_offset, 0);
448    }
449
450    #[test]
451    fn test_branch_compare_view_state_default() {
452        let s = BranchCompareViewState::default();
453        assert!(s.cache.is_none());
454        assert_eq!(s.tab, CompareTab::default());
455    }
456
457    #[test]
458    fn test_related_files_view_state_default() {
459        let s = RelatedFilesViewState::default();
460        assert!(s.cache.is_none());
461    }
462
463    #[test]
464    fn test_impact_score_view_state_default() {
465        let s = ImpactScoreViewState::default();
466        assert!(s.cache.is_none());
467    }
468
469    #[test]
470    fn test_change_coupling_view_state_default() {
471        let s = ChangeCouplingViewState::default();
472        assert!(s.cache.is_none());
473    }
474
475    #[test]
476    fn test_quality_score_view_state_default() {
477        let s = QualityScoreViewState::default();
478        assert!(s.cache.is_none());
479    }
480
481    #[test]
482    fn test_health_view_state_default() {
483        let s = HealthViewState::default();
484        assert!(s.cache.is_none());
485    }
486
487    #[test]
488    fn test_commit_detail_state_default() {
489        let s = CommitDetailState::default();
490        assert_eq!(s.scroll, 0);
491        assert_eq!(s.h_scroll, 0);
492        assert_eq!(s.selected_file, 0);
493        assert!(s.expanded_files.is_empty());
494    }
495
496    #[test]
497    fn test_file_diff_state_default() {
498        let s = FileDiffState::default();
499        assert!(s.cache.is_none());
500        assert!(s.cache_path.is_none());
501        assert_eq!(s.scroll, 0);
502    }
503
504    // ===== QuickAction =====
505
506    #[test]
507    fn test_quick_action_all_returns_12_items() {
508        assert_eq!(QuickAction::all().len(), 12);
509    }
510
511    #[test]
512    fn test_quick_action_id_mapping() {
513        assert_eq!(QuickAction::RiskSummary.id(), "risk-summary");
514        assert_eq!(QuickAction::ReviewPack.id(), "review-pack");
515        assert_eq!(QuickAction::NextActions.id(), "next-actions");
516        assert_eq!(QuickAction::Verify.id(), "verify");
517        assert_eq!(QuickAction::HandoffClaude.id(), "handoff-claude");
518        assert_eq!(QuickAction::HandoffCodex.id(), "handoff-codex");
519        assert_eq!(QuickAction::HandoffCopilot.id(), "handoff-copilot");
520        assert_eq!(QuickAction::Timeline.id(), "timeline");
521        assert_eq!(QuickAction::Ownership.id(), "ownership");
522        assert_eq!(QuickAction::ImpactScore.id(), "impact-score");
523        assert_eq!(QuickAction::ChangeCoupling.id(), "change-coupling");
524        assert_eq!(QuickAction::QualityScore.id(), "quality-score");
525    }
526
527    #[test]
528    fn test_quick_action_title_en() {
529        let lang = Language::En;
530        for action in QuickAction::all() {
531            let title = action.title(lang);
532            assert!(!title.is_empty());
533        }
534    }
535
536    #[test]
537    fn test_quick_action_title_ja() {
538        let lang = Language::Ja;
539        for action in QuickAction::all() {
540            let title = action.title(lang);
541            assert!(!title.is_empty());
542        }
543    }
544
545    #[test]
546    fn test_quick_action_all_ids_are_unique() {
547        let ids: Vec<&str> = QuickAction::all().iter().map(|a| a.id()).collect();
548        let mut deduped = ids.clone();
549        deduped.sort();
550        deduped.dedup();
551        assert_eq!(ids.len(), deduped.len());
552    }
553
554    // ===== SidebarPanel =====
555
556    #[test]
557    fn test_sidebar_panel_default_is_commits() {
558        assert_eq!(SidebarPanel::default(), SidebarPanel::Commits);
559    }
560
561    #[test]
562    fn test_sidebar_panel_from_number() {
563        assert_eq!(SidebarPanel::from_number(1), Some(SidebarPanel::Status));
564        assert_eq!(SidebarPanel::from_number(2), Some(SidebarPanel::Commits));
565        assert_eq!(SidebarPanel::from_number(3), Some(SidebarPanel::Branches));
566        assert_eq!(SidebarPanel::from_number(4), Some(SidebarPanel::Files));
567        assert_eq!(SidebarPanel::from_number(5), Some(SidebarPanel::Stash));
568        assert_eq!(SidebarPanel::from_number(0), None);
569        assert_eq!(SidebarPanel::from_number(6), None);
570    }
571
572    #[test]
573    fn test_sidebar_panel_number_roundtrip() {
574        for panel in SidebarPanel::all() {
575            assert_eq!(SidebarPanel::from_number(panel.number()), Some(*panel));
576        }
577    }
578
579    #[test]
580    fn test_sidebar_panel_next_cycles() {
581        let start = SidebarPanel::Status;
582        let mut current = start;
583        let mut visited = vec![];
584        for _ in 0..5 {
585            visited.push(current);
586            current = current.next();
587        }
588        assert_eq!(visited.len(), 5);
589        assert_eq!(current, start); // full cycle
590    }
591
592    #[test]
593    fn test_sidebar_panel_prev_cycles() {
594        let start = SidebarPanel::Status;
595        let mut current = start;
596        let mut visited = vec![];
597        for _ in 0..5 {
598            visited.push(current);
599            current = current.prev();
600        }
601        assert_eq!(visited.len(), 5);
602        assert_eq!(current, start); // full cycle
603    }
604
605    #[test]
606    fn test_sidebar_panel_next_prev_inverse() {
607        for panel in SidebarPanel::all() {
608            assert_eq!(panel.next().prev(), *panel);
609            assert_eq!(panel.prev().next(), *panel);
610        }
611    }
612
613    #[test]
614    fn test_sidebar_panel_label_not_empty() {
615        for panel in SidebarPanel::all() {
616            assert!(!panel.label(Language::En).is_empty());
617            assert!(!panel.label(Language::Ja).is_empty());
618        }
619    }
620
621    #[test]
622    fn test_sidebar_panel_all_returns_5() {
623        assert_eq!(SidebarPanel::all().len(), 5);
624    }
625
626    // ===== CommitType =====
627
628    #[test]
629    fn test_commit_type_all_returns_8() {
630        assert_eq!(CommitType::all().len(), 8);
631    }
632
633    #[test]
634    fn test_commit_type_prefix() {
635        assert_eq!(CommitType::Feat.prefix(), "feat: ");
636        assert_eq!(CommitType::Fix.prefix(), "fix: ");
637        assert_eq!(CommitType::Docs.prefix(), "docs: ");
638        assert_eq!(CommitType::Style.prefix(), "style: ");
639        assert_eq!(CommitType::Refactor.prefix(), "refactor: ");
640        assert_eq!(CommitType::Test.prefix(), "test: ");
641        assert_eq!(CommitType::Chore.prefix(), "chore: ");
642        assert_eq!(CommitType::Perf.prefix(), "perf: ");
643    }
644
645    #[test]
646    fn test_commit_type_key() {
647        assert_eq!(CommitType::Feat.key(), 'f');
648        assert_eq!(CommitType::Fix.key(), 'x');
649        assert_eq!(CommitType::Docs.key(), 'd');
650        assert_eq!(CommitType::Style.key(), 's');
651        assert_eq!(CommitType::Refactor.key(), 'r');
652        assert_eq!(CommitType::Test.key(), 't');
653        assert_eq!(CommitType::Chore.key(), 'c');
654        assert_eq!(CommitType::Perf.key(), 'p');
655    }
656
657    #[test]
658    fn test_commit_type_name() {
659        for ct in CommitType::all() {
660            let name = ct.name();
661            assert!(!name.is_empty());
662            // prefix should start with name
663            assert!(ct.prefix().starts_with(name));
664        }
665    }
666
667    #[test]
668    fn test_commit_type_keys_are_unique() {
669        let keys: Vec<char> = CommitType::all().iter().map(|c| c.key()).collect();
670        let mut deduped = keys.clone();
671        deduped.sort();
672        deduped.dedup();
673        assert_eq!(keys.len(), deduped.len());
674    }
675
676    // ===== InputMode =====
677
678    #[test]
679    fn test_input_mode_default_is_normal() {
680        assert_eq!(InputMode::default(), InputMode::Normal);
681    }
682}