1use std::collections::HashMap;
4use std::path::PathBuf;
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct CommitContext {
12 pub project: ProjectContext,
14 pub branch: BranchContext,
16 pub range: CommitRangeContext,
18 pub files: Vec<FileContext>,
20 pub user_provided: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct ProjectContext {
27 pub commit_guidelines: Option<String>,
29 pub pr_guidelines: Option<String>,
31 pub valid_scopes: Vec<ScopeDefinition>,
33 pub feature_contexts: HashMap<String, FeatureContext>,
35 pub project_conventions: ProjectConventions,
37 pub ecosystem: Ecosystem,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ScopeDefinition {
44 pub name: String,
46 pub description: String,
48 pub examples: Vec<String>,
50 pub file_patterns: Vec<String>,
52}
53
54pub const DEFAULT_COMMIT_TYPES: &[&str] = &[
58 "feat", "fix", "docs", "refactor", "chore", "test", "ci", "build", "perf", "style",
59];
60
61pub const DEFAULT_FORBIDDEN_FOOTERS: &[&str] = &["Co-Authored-By"];
63
64pub const DEFAULT_SUBJECT_MAX_LEN: usize = 80;
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(default)]
76pub struct CommitRules {
77 pub subject_max_len: usize,
80 pub types: Vec<String>,
83 pub require_scope: bool,
87 pub forbidden_footers: Vec<String>,
90}
91
92impl Default for CommitRules {
93 fn default() -> Self {
94 Self {
95 subject_max_len: DEFAULT_SUBJECT_MAX_LEN,
96 types: DEFAULT_COMMIT_TYPES
97 .iter()
98 .map(|s| (*s).to_string())
99 .collect(),
100 require_scope: false,
101 forbidden_footers: DEFAULT_FORBIDDEN_FOOTERS
102 .iter()
103 .map(|s| (*s).to_string())
104 .collect(),
105 }
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FeatureContext {
112 pub name: String,
114 pub description: String,
116 pub scope: String,
118 pub conventions: Vec<String>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
124pub struct ProjectConventions {
125 pub commit_format: Option<String>,
127 pub required_trailers: Vec<String>,
129 pub preferred_types: Vec<String>,
131 pub scope_requirements: ScopeRequirements,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct ScopeRequirements {
138 pub required: bool,
140 pub valid_scopes: Vec<String>,
142 pub scope_mapping: HashMap<String, Vec<String>>, }
145
146#[derive(Debug, Clone, Serialize, Deserialize, Default)]
148pub enum Ecosystem {
149 #[default]
150 Unknown,
152 Rust,
154 Node,
156 Python,
158 Go,
160 Java,
162 Generic,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, Default)]
168pub struct BranchContext {
169 pub work_type: WorkType,
171 pub scope: Option<String>,
173 pub ticket_id: Option<String>,
175 pub description: String,
177 pub is_feature_branch: bool,
179 pub base_branch: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, Default)]
185pub enum WorkType {
186 #[default]
187 Unknown,
189 Feature,
191 Fix,
193 Docs,
195 Refactor,
197 Chore,
199 Test,
201 Ci,
203 Build,
205 Perf,
207}
208
209impl std::str::FromStr for WorkType {
210 type Err = anyhow::Error;
211
212 fn from_str(s: &str) -> Result<Self> {
213 match s.to_lowercase().as_str() {
214 "feature" | "feat" => Ok(Self::Feature),
215 "fix" | "bugfix" => Ok(Self::Fix),
216 "docs" | "doc" => Ok(Self::Docs),
217 "refactor" | "refact" => Ok(Self::Refactor),
218 "chore" => Ok(Self::Chore),
219 "test" | "tests" => Ok(Self::Test),
220 "ci" => Ok(Self::Ci),
221 "build" => Ok(Self::Build),
222 "perf" | "performance" => Ok(Self::Perf),
223 _ => Ok(Self::Unknown),
224 }
225 }
226}
227
228impl std::fmt::Display for WorkType {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 match self {
231 Self::Unknown => write!(f, "unknown work"),
232 Self::Feature => write!(f, "feature development"),
233 Self::Fix => write!(f, "bug fix"),
234 Self::Docs => write!(f, "documentation update"),
235 Self::Refactor => write!(f, "refactoring"),
236 Self::Chore => write!(f, "maintenance"),
237 Self::Test => write!(f, "testing"),
238 Self::Ci => write!(f, "CI/CD"),
239 Self::Build => write!(f, "build system"),
240 Self::Perf => write!(f, "performance improvement"),
241 }
242 }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, Default)]
247pub struct CommitRangeContext {
248 pub related_commits: Vec<String>, pub common_files: Vec<PathBuf>,
252 pub work_pattern: WorkPattern,
254 pub scope_consistency: ScopeAnalysis,
256 pub architectural_impact: ArchitecturalImpact,
258 pub change_significance: ChangeSignificance,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, Default)]
264pub enum WorkPattern {
265 #[default]
266 Unknown,
268 Sequential,
270 Refactoring,
272 BugHunt,
274 Documentation,
276 Configuration,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, Default)]
282pub struct ScopeAnalysis {
283 pub consistent_scope: Option<String>,
285 pub scope_changes: Vec<String>,
287 pub confidence: f32,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, Default)]
293pub enum ArchitecturalImpact {
294 #[default]
295 Minimal,
297 Moderate,
299 Significant,
301 Breaking,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, Default)]
307pub enum ChangeSignificance {
308 #[default]
309 Minor,
311 Moderate,
313 Major,
315 Critical,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct FileContext {
322 pub path: PathBuf,
324 pub file_purpose: FilePurpose,
326 pub architectural_layer: ArchitecturalLayer,
328 pub change_impact: ChangeImpact,
330 pub project_significance: ProjectSignificance,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336pub enum FilePurpose {
337 Config,
339 Test,
341 Documentation,
343 CoreLogic,
345 Interface,
347 Build,
349 Tooling,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
355pub enum ArchitecturalLayer {
356 Presentation,
358 Business,
360 Data,
362 Infrastructure,
364 Cross,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub enum ChangeImpact {
371 Style,
373 Additive,
375 Modification,
377 Breaking,
379 Critical,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub enum ProjectSignificance {
386 Routine,
388 Important,
390 Critical,
392}
393
394impl CommitContext {
395 pub fn new() -> Self {
397 Self::default()
398 }
399
400 #[must_use]
402 pub fn is_significant_change(&self) -> bool {
403 matches!(
404 self.range.change_significance,
405 ChangeSignificance::Major | ChangeSignificance::Critical
406 ) || matches!(
407 self.range.architectural_impact,
408 ArchitecturalImpact::Significant | ArchitecturalImpact::Breaking
409 ) || self.files.iter().any(|f| {
410 matches!(f.project_significance, ProjectSignificance::Critical)
411 || matches!(
412 f.change_impact,
413 ChangeImpact::Breaking | ChangeImpact::Critical
414 )
415 })
416 }
417
418 pub fn suggested_verbosity(&self) -> VerbosityLevel {
420 if self.is_significant_change() {
421 VerbosityLevel::Comprehensive
422 } else if matches!(self.range.change_significance, ChangeSignificance::Moderate)
423 || self.files.len() > 1
424 || self.files.iter().any(|f| {
425 matches!(
426 f.architectural_layer,
427 ArchitecturalLayer::Presentation | ArchitecturalLayer::Business
428 )
429 })
430 {
431 VerbosityLevel::Detailed
436 } else {
437 VerbosityLevel::Concise
438 }
439 }
440}
441
442#[derive(Debug, Clone, Copy)]
444pub enum VerbosityLevel {
445 Concise,
447 Detailed,
449 Comprehensive,
451}
452
453#[cfg(test)]
454#[allow(clippy::unwrap_used, clippy::expect_used)]
455mod tests {
456 use super::*;
457 use std::str::FromStr;
458
459 #[test]
462 fn work_type_known_variants() {
463 assert!(matches!(
464 WorkType::from_str("feature").unwrap(),
465 WorkType::Feature
466 ));
467 assert!(matches!(
468 WorkType::from_str("feat").unwrap(),
469 WorkType::Feature
470 ));
471 assert!(matches!(WorkType::from_str("fix").unwrap(), WorkType::Fix));
472 assert!(matches!(
473 WorkType::from_str("bugfix").unwrap(),
474 WorkType::Fix
475 ));
476 assert!(matches!(
477 WorkType::from_str("docs").unwrap(),
478 WorkType::Docs
479 ));
480 assert!(matches!(WorkType::from_str("doc").unwrap(), WorkType::Docs));
481 assert!(matches!(
482 WorkType::from_str("refactor").unwrap(),
483 WorkType::Refactor
484 ));
485 assert!(matches!(
486 WorkType::from_str("chore").unwrap(),
487 WorkType::Chore
488 ));
489 assert!(matches!(
490 WorkType::from_str("test").unwrap(),
491 WorkType::Test
492 ));
493 assert!(matches!(WorkType::from_str("ci").unwrap(), WorkType::Ci));
494 assert!(matches!(
495 WorkType::from_str("build").unwrap(),
496 WorkType::Build
497 ));
498 assert!(matches!(
499 WorkType::from_str("perf").unwrap(),
500 WorkType::Perf
501 ));
502 }
503
504 #[test]
505 fn work_type_unknown() {
506 assert!(matches!(
507 WorkType::from_str("random").unwrap(),
508 WorkType::Unknown
509 ));
510 assert!(matches!(WorkType::from_str("").unwrap(), WorkType::Unknown));
511 }
512
513 #[test]
514 fn work_type_display() {
515 assert_eq!(WorkType::Feature.to_string(), "feature development");
516 assert_eq!(WorkType::Fix.to_string(), "bug fix");
517 assert_eq!(WorkType::Unknown.to_string(), "unknown work");
518 }
519
520 #[test]
523 fn significant_when_breaking_impact() {
524 let mut ctx = CommitContext::new();
525 ctx.range.architectural_impact = ArchitecturalImpact::Breaking;
526 assert!(ctx.is_significant_change());
527 }
528
529 #[test]
530 fn significant_when_critical_change() {
531 let mut ctx = CommitContext::new();
532 ctx.range.change_significance = ChangeSignificance::Critical;
533 assert!(ctx.is_significant_change());
534 }
535
536 #[test]
537 fn significant_when_critical_file() {
538 let mut ctx = CommitContext::new();
539 ctx.files.push(FileContext {
540 path: "src/main.rs".into(),
541 file_purpose: FilePurpose::CoreLogic,
542 architectural_layer: ArchitecturalLayer::Business,
543 change_impact: ChangeImpact::Breaking,
544 project_significance: ProjectSignificance::Critical,
545 });
546 assert!(ctx.is_significant_change());
547 }
548
549 #[test]
550 fn not_significant_when_minor() {
551 let ctx = CommitContext::new();
552 assert!(!ctx.is_significant_change());
553 }
554
555 #[test]
558 fn comprehensive_for_significant() {
559 let mut ctx = CommitContext::new();
560 ctx.range.architectural_impact = ArchitecturalImpact::Breaking;
561 assert!(matches!(
562 ctx.suggested_verbosity(),
563 VerbosityLevel::Comprehensive
564 ));
565 }
566
567 #[test]
568 fn detailed_for_moderate() {
569 let mut ctx = CommitContext::new();
570 ctx.range.change_significance = ChangeSignificance::Moderate;
571 assert!(matches!(
572 ctx.suggested_verbosity(),
573 VerbosityLevel::Detailed
574 ));
575 }
576
577 #[test]
578 fn concise_for_minor() {
579 let ctx = CommitContext::new();
580 assert!(matches!(ctx.suggested_verbosity(), VerbosityLevel::Concise));
581 }
582}