Skip to main content

omni_dev/data/
context.rs

1//! Context data structures for enhanced commit message analysis.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8
9/// Complete context information for intelligent commit message improvement.
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct CommitContext {
12    /// Project-level context and conventions.
13    pub project: ProjectContext,
14    /// Branch analysis and work pattern detection.
15    pub branch: BranchContext,
16    /// Multi-commit analysis and work patterns.
17    pub range: CommitRangeContext,
18    /// File-specific context and architectural understanding.
19    pub files: Vec<FileContext>,
20    /// User-provided context information.
21    pub user_provided: Option<String>,
22}
23
24/// Project-level context discovered from configuration files.
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct ProjectContext {
27    /// Project-specific commit guidelines from .omni-dev/commit-guidelines.md.
28    pub commit_guidelines: Option<String>,
29    /// Project-specific PR guidelines from .omni-dev/pr-guidelines.md.
30    pub pr_guidelines: Option<String>,
31    /// Valid scopes and their descriptions from .omni-dev/scopes.yaml.
32    pub valid_scopes: Vec<ScopeDefinition>,
33    /// Feature-specific context from .omni-dev/context/.
34    pub feature_contexts: HashMap<String, FeatureContext>,
35    /// Parsed conventions from CONTRIBUTING.md.
36    pub project_conventions: ProjectConventions,
37    /// Detected ecosystem (rust, node, python, etc.).
38    pub ecosystem: Ecosystem,
39}
40
41/// Definition of a valid scope in the project.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ScopeDefinition {
44    /// Name of the scope.
45    pub name: String,
46    /// Human-readable description of the scope.
47    pub description: String,
48    /// Example commit messages using this scope.
49    pub examples: Vec<String>,
50    /// File patterns that match this scope.
51    pub file_patterns: Vec<String>,
52}
53
54/// The ten conventional-commit types accepted by default when no
55/// `.omni-dev/commit-rules.yaml` overrides them; mirrors the `## Types`
56/// table in `.omni-dev/commit-guidelines.md`.
57pub const DEFAULT_COMMIT_TYPES: &[&str] = &[
58    "feat", "fix", "docs", "refactor", "chore", "test", "ci", "build", "perf", "style",
59];
60
61/// Footer prefixes rejected by default (case-insensitive, `<prefix>:` match).
62pub const DEFAULT_FORBIDDEN_FOOTERS: &[&str] = &["Co-Authored-By"];
63
64/// Default subject-line length limit (characters) used when no
65/// `.omni-dev/commit-rules.yaml` overrides it.
66pub const DEFAULT_SUBJECT_MAX_LEN: usize = 80;
67
68/// Deterministic commit-message lint configuration.
69///
70/// Loaded from an optional `.omni-dev/commit-rules.yaml`. Every field falls
71/// back to its own default when the file is absent or a field is omitted
72/// (`#[serde(default)]` at the container level fills missing fields from
73/// [`Default::default`]).
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(default)]
76pub struct CommitRules {
77    /// Maximum subject-line length in characters. Commits over this are a
78    /// `Subject Line` error.
79    pub subject_max_len: usize,
80    /// Accepted conventional-commit types. An unrecognized type is a
81    /// `Types` error.
82    pub types: Vec<String>,
83    /// When `true`, a subject with no `(scope)` segment is a `Scopes`
84    /// error. Off by default — some historical/legitimate commits
85    /// legitimately omit a scope.
86    pub require_scope: bool,
87    /// Footer line prefixes that are not permitted (e.g. AI attribution
88    /// lines). A match is a `Body Guidelines` warning.
89    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/// Context for a specific feature or work area.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FeatureContext {
112    /// Name of the feature context.
113    pub name: String,
114    /// Description of the feature or work area.
115    pub description: String,
116    /// Associated scope for this feature.
117    pub scope: String,
118    /// Specific conventions for this feature.
119    pub conventions: Vec<String>,
120}
121
122/// Project conventions parsed from documentation.
123#[derive(Debug, Clone, Serialize, Deserialize, Default)]
124pub struct ProjectConventions {
125    /// Expected commit message format.
126    pub commit_format: Option<String>,
127    /// Required trailers like Signed-off-by.
128    pub required_trailers: Vec<String>,
129    /// Preferred commit types for this project.
130    pub preferred_types: Vec<String>,
131    /// Scope usage requirements and definitions.
132    pub scope_requirements: ScopeRequirements,
133}
134
135/// Requirements and validation rules for commit scopes.
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct ScopeRequirements {
138    /// Whether a scope is required in commit messages.
139    pub required: bool,
140    /// List of valid scope names.
141    pub valid_scopes: Vec<String>,
142    /// Mapping from file patterns to suggested scopes.
143    pub scope_mapping: HashMap<String, Vec<String>>, // file patterns -> scope
144}
145
146/// Detected project ecosystem.
147#[derive(Debug, Clone, Serialize, Deserialize, Default)]
148pub enum Ecosystem {
149    #[default]
150    /// Unknown or undetected ecosystem.
151    Unknown,
152    /// Rust ecosystem with Cargo.
153    Rust,
154    /// Node.js ecosystem with npm/yarn.
155    Node,
156    /// Python ecosystem with pip/poetry.
157    Python,
158    /// Go ecosystem with go modules.
159    Go,
160    /// Java ecosystem with Maven/Gradle.
161    Java,
162    /// Generic project without specific ecosystem.
163    Generic,
164}
165
166/// Branch analysis and work pattern detection.
167#[derive(Debug, Clone, Serialize, Deserialize, Default)]
168pub struct BranchContext {
169    /// Type of work being performed on this branch.
170    pub work_type: WorkType,
171    /// Extracted scope from branch name.
172    pub scope: Option<String>,
173    /// Associated ticket or issue ID.
174    pub ticket_id: Option<String>,
175    /// Human-readable description of the branch purpose.
176    pub description: String,
177    /// Whether this is a feature branch (vs main/master).
178    pub is_feature_branch: bool,
179    /// Base branch this was created from.
180    pub base_branch: Option<String>,
181}
182
183/// Type of work being performed.
184#[derive(Debug, Clone, Serialize, Deserialize, Default)]
185pub enum WorkType {
186    #[default]
187    /// Unknown or unspecified work type.
188    Unknown,
189    /// New feature development.
190    Feature,
191    /// Bug fix.
192    Fix,
193    /// Documentation changes.
194    Docs,
195    /// Code refactoring.
196    Refactor,
197    /// Maintenance tasks.
198    Chore,
199    /// Test-related changes.
200    Test,
201    /// CI/CD pipeline changes.
202    Ci,
203    /// Build system changes.
204    Build,
205    /// Performance improvements.
206    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/// Multi-commit analysis and work patterns.
246#[derive(Debug, Clone, Serialize, Deserialize, Default)]
247pub struct CommitRangeContext {
248    /// Related commit hashes in this analysis.
249    pub related_commits: Vec<String>, // commit hashes
250    /// Files that appear in multiple commits.
251    pub common_files: Vec<PathBuf>,
252    /// Detected work pattern across commits.
253    pub work_pattern: WorkPattern,
254    /// Analysis of scope consistency.
255    pub scope_consistency: ScopeAnalysis,
256    /// Overall architectural impact assessment.
257    pub architectural_impact: ArchitecturalImpact,
258    /// Significance of changes for commit message detail.
259    pub change_significance: ChangeSignificance,
260}
261
262/// Detected work pattern across commits.
263#[derive(Debug, Clone, Serialize, Deserialize, Default)]
264pub enum WorkPattern {
265    #[default]
266    /// Unknown or undetected pattern.
267    Unknown,
268    /// Building feature step by step.
269    Sequential,
270    /// Multiple small cleanup commits.
271    Refactoring,
272    /// Investigation and fixes.
273    BugHunt,
274    /// Documentation updates.
275    Documentation,
276    /// Config and setup changes.
277    Configuration,
278}
279
280/// Analysis of scope consistency across commits.
281#[derive(Debug, Clone, Serialize, Deserialize, Default)]
282pub struct ScopeAnalysis {
283    /// Most consistent scope across commits if any.
284    pub consistent_scope: Option<String>,
285    /// All scope changes detected.
286    pub scope_changes: Vec<String>,
287    /// Confidence level in scope consistency (0.0-1.0).
288    pub confidence: f32,
289}
290
291/// Impact on system architecture.
292#[derive(Debug, Clone, Serialize, Deserialize, Default)]
293pub enum ArchitecturalImpact {
294    #[default]
295    /// Small changes, no architecture impact.
296    Minimal,
297    /// Some architectural changes.
298    Moderate,
299    /// Major architectural changes.
300    Significant,
301    /// Breaking changes.
302    Breaking,
303}
304
305/// Significance of changes for commit message detail level.
306#[derive(Debug, Clone, Serialize, Deserialize, Default)]
307pub enum ChangeSignificance {
308    #[default]
309    /// Simple fix or small addition.
310    Minor,
311    /// Notable feature or improvement.
312    Moderate,
313    /// Significant enhancement or new capability.
314    Major,
315    /// Major system changes or breaking changes.
316    Critical,
317}
318
319/// File-based context and architectural understanding.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct FileContext {
322    /// Path to the file.
323    pub path: PathBuf,
324    /// Purpose of this file in the project.
325    pub file_purpose: FilePurpose,
326    /// Architectural layer this file belongs to.
327    pub architectural_layer: ArchitecturalLayer,
328    /// Impact of changes to this file.
329    pub change_impact: ChangeImpact,
330    /// Significance of this file in the project.
331    pub project_significance: ProjectSignificance,
332}
333
334/// Purpose of the file in the project.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub enum FilePurpose {
337    /// Configuration files.
338    Config,
339    /// Test files.
340    Test,
341    /// Docs and README files.
342    Documentation,
343    /// Main application logic.
344    CoreLogic,
345    /// API definitions, public interfaces.
346    Interface,
347    /// Build and deployment files.
348    Build,
349    /// Development tools and scripts.
350    Tooling,
351}
352
353/// Architectural layer of the file.
354#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
355pub enum ArchitecturalLayer {
356    /// UI, CLI, web interfaces.
357    Presentation,
358    /// Core business logic.
359    Business,
360    /// Data access, models, storage.
361    Data,
362    /// System, network, deployment.
363    Infrastructure,
364    /// Cross-cutting concerns (logging, auth, etc.).
365    Cross,
366}
367
368/// Impact of changes to this file.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub enum ChangeImpact {
371    /// Formatting, comments, style changes.
372    Style,
373    /// New functionality, non-breaking.
374    Additive,
375    /// Changing existing functionality.
376    Modification,
377    /// Breaking existing functionality.
378    Breaking,
379    /// Security, safety, or critical fixes.
380    Critical,
381}
382
383/// Significance of file in project.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub enum ProjectSignificance {
386    /// Common, everyday files.
387    Routine,
388    /// Key files but not critical.
389    Important,
390    /// Core files that define the project.
391    Critical,
392}
393
394impl CommitContext {
395    /// Creates a new empty context.
396    pub fn new() -> Self {
397        Self::default()
398    }
399
400    /// Checks if this context suggests a significant change needing detailed commit message.
401    #[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    /// Returns the suggested commit message verbosity level.
419    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            // Be more generous with Detailed level for twiddle system:
432            // - Moderate changes
433            // - Multiple files
434            // - UI/CLI or business logic changes
435            VerbosityLevel::Detailed
436        } else {
437            VerbosityLevel::Concise
438        }
439    }
440}
441
442/// Suggested level of detail for commit messages.
443#[derive(Debug, Clone, Copy)]
444pub enum VerbosityLevel {
445    /// Single line, basic conventional commit.
446    Concise,
447    /// Subject + brief body paragraph.
448    Detailed,
449    /// Subject + detailed multi-paragraph body + lists.
450    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    // ── WorkType::from_str ───────────────────────────────────────────
460
461    #[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    // ── CommitContext::is_significant_change ──────────────────────────
521
522    #[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    // ── CommitContext::suggested_verbosity ────────────────────────────
556
557    #[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}