1use std::{collections::HashMap, fmt, path::PathBuf};
2
3use clap::{Parser, ValueEnum};
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::error::{CommitGenError, Result};
9
10#[derive(Debug, Clone, Default, Deserialize, Serialize)]
14pub struct TypeConfig {
15 pub description: String,
17
18 #[serde(default)]
20 pub diff_indicators: Vec<String>,
21
22 #[serde(default)]
24 pub file_patterns: Vec<String>,
25
26 #[serde(default)]
28 pub examples: Vec<String>,
29
30 #[serde(default)]
32 pub hint: String,
33}
34
35#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37pub struct CategoryMatch {
38 #[serde(default)]
40 pub types: Vec<String>,
41 #[serde(default)]
43 pub body_contains: Vec<String>,
44}
45
46#[derive(Debug, Clone, Deserialize, Serialize)]
48pub struct CategoryConfig {
49 pub name: String,
51 #[serde(default)]
53 pub header: Option<String>,
54 #[serde(default)]
56 pub r#match: CategoryMatch,
57 #[serde(default)]
59 pub default: bool,
60}
61
62impl CategoryConfig {
63 pub fn header(&self) -> &str {
65 self.header.as_deref().unwrap_or(&self.name)
66 }
67}
68
69pub fn default_types() -> IndexMap<String, TypeConfig> {
72 IndexMap::from([
73 ("feat".to_string(), TypeConfig {
74 description: "New public API surface OR user-observable capability/behavior change"
75 .to_string(),
76 diff_indicators: vec![
77 "pub fn".to_string(),
78 "pub struct".to_string(),
79 "pub enum".to_string(),
80 "export function".to_string(),
81 "#[arg]".to_string(),
82 ],
83 file_patterns: vec![],
84 examples: vec![
85 "Added pub fn process_batch() → feat (new API)".to_string(),
86 "Migrated HTTP client to async → feat (behavior change)".to_string(),
87 ],
88 ..Default::default()
89 }),
90 ("fix".to_string(), TypeConfig {
91 description: "Fixes incorrect behavior (bugs, crashes, wrong outputs, race conditions)"
92 .to_string(),
93 diff_indicators: vec![
94 "unwrap() → ?".to_string(),
95 "bounds check".to_string(),
96 "off-by-one".to_string(),
97 "error handling".to_string(),
98 ],
99 ..Default::default()
100 }),
101 ("refactor".to_string(), TypeConfig {
102 description: "Internal restructuring with provably unchanged behavior".to_string(),
103 diff_indicators: vec![
104 "rename".to_string(),
105 "extract".to_string(),
106 "consolidate".to_string(),
107 "reorganize".to_string(),
108 ],
109 examples: vec!["Renamed internal module structure → refactor (no API change)".to_string()],
110 hint: "Requires proof: same tests pass, same API. If behavior changes, use feat."
111 .to_string(),
112 ..Default::default()
113 }),
114 ("docs".to_string(), TypeConfig {
115 description: "Documentation only changes".to_string(),
116 file_patterns: vec!["*.md".to_string(), "doc comments".to_string()],
117 hint: "Excludes prompt template files (prompts/*.md). Prompt changes are functional — \
118 use feat/fix/refactor."
119 .to_string(),
120 ..Default::default()
121 }),
122 ("test".to_string(), TypeConfig {
123 description: "Adding or modifying tests".to_string(),
124 file_patterns: vec![
125 "*_test.rs".to_string(),
126 "tests/".to_string(),
127 "*.test.ts".to_string(),
128 ],
129 ..Default::default()
130 }),
131 ("chore".to_string(), TypeConfig {
132 description: "Housekeeping: tooling scripts, editor config, miscellaneous maintenance \
133 not covered by other types"
134 .to_string(),
135 file_patterns: vec![".gitignore".to_string(), "*.lock".to_string()],
136 hint: "Use deps for version bumps, config for app/env config, build for build scripts."
137 .to_string(),
138 ..Default::default()
139 }),
140 ("style".to_string(), TypeConfig {
141 description: "Formatting, whitespace changes (no logic change)".to_string(),
142 diff_indicators: vec!["whitespace".to_string(), "formatting".to_string()],
143 hint: "Variable/function renames are refactor, not style.".to_string(),
144 ..Default::default()
145 }),
146 ("perf".to_string(), TypeConfig {
147 description: "Performance improvements (proven faster)".to_string(),
148 diff_indicators: vec![
149 "optimization".to_string(),
150 "cache".to_string(),
151 "batch".to_string(),
152 ],
153 ..Default::default()
154 }),
155 ("build".to_string(), TypeConfig {
156 description: "Build system, dependency changes".to_string(),
157 file_patterns: vec![
158 "Cargo.toml".to_string(),
159 "package.json".to_string(),
160 "Makefile".to_string(),
161 ],
162 ..Default::default()
163 }),
164 ("ci".to_string(), TypeConfig {
165 description: "CI/CD configuration".to_string(),
166 file_patterns: vec![".github/workflows/".to_string(), ".gitlab-ci.yml".to_string()],
167 ..Default::default()
168 }),
169 ("revert".to_string(), TypeConfig {
170 description: "Reverts a previous commit".to_string(),
171 diff_indicators: vec!["Revert".to_string()],
172 ..Default::default()
173 }),
174 ("deps".to_string(), TypeConfig {
176 description: "Dependency version bumps (Cargo.toml, package.json, go.mod, \
177 requirements.txt, etc.)"
178 .to_string(),
179 file_patterns: vec![
180 "Cargo.toml".to_string(),
181 "package.json".to_string(),
182 "go.mod".to_string(),
183 "requirements.txt".to_string(),
184 "pyproject.toml".to_string(),
185 ],
186 hint: "Version bumps only. Build system changes belong in build; lockfile-only changes \
187 can be deps."
188 .to_string(),
189 ..Default::default()
190 }),
191 ("security".to_string(), TypeConfig {
192 description: "Security hardening, CVE patches, auth improvements, input sanitization, \
193 rate limiting"
194 .to_string(),
195 diff_indicators: vec![
196 "sanitize".to_string(),
197 "auth".to_string(),
198 "CVE".to_string(),
199 "rate limit".to_string(),
200 "HMAC".to_string(),
201 ],
202 hint: "Use for proactive hardening too, not just bug fixes. Security-motivated fix → \
203 security, not fix."
204 .to_string(),
205 ..Default::default()
206 }),
207 ("config".to_string(), TypeConfig {
208 description: "Application or environment configuration changes (.env, settings, feature \
209 flags, runtime config)"
210 .to_string(),
211 file_patterns: vec![
212 ".env".to_string(),
213 "settings.toml".to_string(),
214 "config.yaml".to_string(),
215 ],
216 hint: "App/runtime config. Build system config → build; CI config → ci; dev tooling → \
217 chore."
218 .to_string(),
219 ..Default::default()
220 }),
221 ("ux".to_string(), TypeConfig {
222 description: "Usability and ergonomics improvements to existing interfaces (CLI flags, \
223 error messages, output formatting)"
224 .to_string(),
225 hint: "Existing feature made easier/clearer → ux. New capability → feat.".to_string(),
226 ..Default::default()
227 }),
228 ("release".to_string(), TypeConfig {
229 description: "Version bump and release preparation (CHANGELOG.md updates, version files, \
230 release tags)"
231 .to_string(),
232 file_patterns: vec![
233 "CHANGELOG.md".to_string(),
234 "CHANGELOG".to_string(),
235 "VERSION".to_string(),
236 ],
237 hint: "Only for the release commit itself. Code changes alongside a release use their \
238 own type."
239 .to_string(),
240 ..Default::default()
241 }),
242 ("hotfix".to_string(), TypeConfig {
243 description: "Critical production fix requiring immediate patch, often on a dedicated \
244 hotfix branch"
245 .to_string(),
246 hint: "Reserve for genuine production emergencies. Normal bugs → fix.".to_string(),
247 ..Default::default()
248 }),
249 ("infra".to_string(), TypeConfig {
250 description: "Infrastructure-as-code changes (Terraform, Kubernetes manifests, Ansible, \
251 cloud config)"
252 .to_string(),
253 file_patterns: vec![
254 "*.tf".to_string(),
255 "helm/".to_string(),
256 "terraform/".to_string(),
257 "k8s/".to_string(),
258 ],
259 ..Default::default()
260 }),
261 ("init".to_string(), TypeConfig {
262 description: "Initial commit bootstrapping a project, module, or major subsystem"
263 .to_string(),
264 hint: "Use once per project/module bootstrap. Subsequent setup → chore or build."
265 .to_string(),
266 ..Default::default()
267 }),
268 ("merge".to_string(), TypeConfig {
269 description: "Merge or sync commit with no standalone logic change (merge branches, sync \
270 forks)"
271 .to_string(),
272 hint: "Only when the commit is purely a merge. Squashed logic changes → use the \
273 appropriate type."
274 .to_string(),
275 ..Default::default()
276 }),
277 ("hack".to_string(), TypeConfig {
278 description: "Deliberate temporary workaround or shortcut with known technical debt"
279 .to_string(),
280 hint: "Must signal intent to revisit in the body (e.g., TODO: replace once X lands)."
281 .to_string(),
282 ..Default::default()
283 }),
284 ("wip".to_string(), TypeConfig {
285 description: "Incomplete in-progress work not ready for review or release".to_string(),
286 hint: "Prefer a real type for finished commits. Use wip only for explicit save-points."
287 .to_string(),
288 ..Default::default()
289 }),
290 ])
291}
292
293pub fn default_classifier_hint() -> String {
295 r"CRITICAL disambiguation rules:
296- feat vs refactor: feat=ANY observable behavior change OR new public API; refactor=provably unchanged (same tests, same API). When in doubt, prefer feat.
297- fix vs hotfix: hotfix=critical production emergency; fix=normal bug.
298- fix vs security: security=proactive hardening, CVE patches, auth hardening; fix=non-security bugs.
299- deps vs chore: deps=dependency version bumps only; chore=other maintenance (tooling, scripts).
300- deps vs build: build=build system scripts/config; deps=bumping library versions in manifests.
301- config vs chore: config=application/runtime config; chore=dev tooling and housekeeping.
302- ux vs feat: ux=existing feature made easier/clearer; feat=new capability.
303- init=bootstrap commit for a project or major subsystem; use once.
304- wip=in-progress save-point; prefer a real type for finished commits.
305- hack=deliberate temporary workaround; body must note intent to revisit.
306- merge=merge/sync commits with no standalone logic change."
307 .to_string()
308}
309
310pub fn default_categories() -> Vec<CategoryConfig> {
313 vec![
314 CategoryConfig {
315 name: "Breaking".to_string(),
316 header: Some("Breaking Changes".to_string()),
317 r#match: CategoryMatch {
318 types: vec![],
319 body_contains: vec!["breaking".to_string(), "incompatible".to_string()],
320 },
321 default: false,
322 },
323 CategoryConfig {
324 name: "Added".to_string(),
325 header: None,
326 r#match: CategoryMatch { types: vec!["feat".to_string()], body_contains: vec![] },
327 default: false,
328 },
329 CategoryConfig {
330 name: "Changed".to_string(),
331 header: None,
332 r#match: CategoryMatch::default(),
333 default: true,
334 },
335 CategoryConfig {
336 name: "Deprecated".to_string(),
337 header: None,
338 r#match: CategoryMatch::default(),
339 default: false,
340 },
341 CategoryConfig {
342 name: "Removed".to_string(),
343 header: None,
344 r#match: CategoryMatch {
345 types: vec!["revert".to_string()],
346 body_contains: vec![],
347 },
348 default: false,
349 },
350 CategoryConfig {
351 name: "Fixed".to_string(),
352 header: None,
353 r#match: CategoryMatch { types: vec!["fix".to_string()], body_contains: vec![] },
354 default: false,
355 },
356 CategoryConfig {
357 name: "Security".to_string(),
358 header: None,
359 r#match: CategoryMatch::default(),
360 default: false,
361 },
362 ]
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
369pub enum ChangelogCategory {
370 Added,
371 Changed,
372 Fixed,
373 Deprecated,
374 Removed,
375 Security,
376 Breaking,
377}
378
379impl ChangelogCategory {
380 pub const fn as_str(&self) -> &'static str {
382 match self {
383 Self::Added => "Added",
384 Self::Changed => "Changed",
385 Self::Fixed => "Fixed",
386 Self::Deprecated => "Deprecated",
387 Self::Removed => "Removed",
388 Self::Security => "Security",
389 Self::Breaking => "Breaking Changes",
390 }
391 }
392
393 #[must_use]
396 pub fn from_name(name: &str) -> Self {
397 match name.to_lowercase().as_str() {
398 "added" => Self::Added,
399 "changed" => Self::Changed,
400 "fixed" => Self::Fixed,
401 "deprecated" => Self::Deprecated,
402 "removed" => Self::Removed,
403 "security" => Self::Security,
404 "breaking" | "breaking changes" => Self::Breaking,
405 _ => Self::Changed,
406 }
407 }
408
409 pub fn from_commit_type(commit_type: &str, body: &[String]) -> Self {
412 let has_breaking = body.iter().any(|s| {
414 let lower = s.to_lowercase();
415 lower.contains("breaking") || lower.contains("incompatible")
416 });
417
418 if has_breaking {
419 return Self::Breaking;
420 }
421
422 match commit_type {
423 "feat" => Self::Added,
424 "fix" => Self::Fixed,
425 "revert" => Self::Removed,
426 _ => Self::Changed,
428 }
429 }
430
431 pub const fn render_order() -> &'static [Self] {
433 &[
434 Self::Breaking,
435 Self::Added,
436 Self::Changed,
437 Self::Deprecated,
438 Self::Removed,
439 Self::Fixed,
440 Self::Security,
441 ]
442 }
443}
444
445#[derive(Debug, Clone)]
447pub struct ChangelogBoundary {
448 pub changelog_path: PathBuf,
450 pub files: Vec<String>,
452 pub diff: String,
454 pub stat: String,
456}
457
458#[derive(Debug, Clone, Default)]
460pub struct UnreleasedSection {
461 pub header_line: usize,
463 pub end_line: usize,
465 pub entries: HashMap<ChangelogCategory, Vec<String>>,
467}
468
469#[derive(Debug, Clone, ValueEnum)]
470pub enum Mode {
471 Staged,
473 Commit,
475 Unstaged,
477 Compose,
479}
480
481pub fn resolve_model_name(name: &str) -> String {
483 match name {
484 "sonnet" | "s" => "claude-sonnet-4.5",
486 "opus" | "o" | "o4.5" => "claude-opus-4.5",
487 "haiku" | "h" => "claude-haiku-4-5",
488 "3.5" | "sonnet-3.5" => "claude-3.5-sonnet",
489 "3.7" | "sonnet-3.7" => "claude-3.7-sonnet",
490
491 "gpt5" | "g5" => "gpt-5",
493 "gpt5-pro" => "gpt-5-pro",
494 "gpt5-mini" => "gpt-5-mini",
495 "gpt5-codex" => "gpt-5-codex",
496
497 "o3" => "o3",
499 "o3-pro" => "o3-pro",
500 "o3-mini" => "o3-mini",
501 "o1" => "o1",
502 "o1-pro" => "o1-pro",
503 "o1-mini" => "o1-mini",
504
505 "gemini" | "g2.5" => "gemini-2.5-pro",
507 "flash" | "g2.5-flash" => "gemini-2.5-flash",
508 "flash-lite" => "gemini-2.5-flash-lite",
509
510 "qwen" | "q480b" => "qwen-3-coder-480b",
512
513 "glm4.6" => "glm-4.6",
515 "glm4.5" => "glm-4.5",
516 "glm-air" => "glm-4.5-air",
517
518 _ => name,
520 }
521 .to_string()
522}
523
524#[derive(Debug, Clone)]
526pub struct ScopeCandidate {
527 pub path: String,
528 pub percentage: f32,
529 pub confidence: f32,
530}
531
532#[derive(Clone, PartialEq, Eq)]
534pub struct CommitType(String);
535
536impl CommitType {
537 const VALID_TYPES: &'static [&'static str] = &[
538 "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert",
539 "deps", "security", "config", "ux", "release", "hotfix", "infra", "init", "merge", "hack",
540 "wip",
541 ];
542
543 pub fn new(s: impl Into<String>) -> Result<Self> {
545 let s = s.into();
546 let normalized = s.to_lowercase();
547
548 if !Self::VALID_TYPES.contains(&normalized.as_str()) {
549 return Err(CommitGenError::InvalidCommitType(format!(
550 "Invalid commit type '{}'. Must be one of: {}",
551 s,
552 Self::VALID_TYPES.join(", ")
553 )));
554 }
555
556 Ok(Self(normalized))
557 }
558
559 pub fn as_str(&self) -> &str {
561 &self.0
562 }
563
564 pub const fn len(&self) -> usize {
566 self.0.len()
567 }
568
569 #[allow(dead_code, reason = "Convenience method for future use")]
571 pub const fn is_empty(&self) -> bool {
572 self.0.is_empty()
573 }
574}
575
576impl fmt::Display for CommitType {
577 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
578 write!(f, "{}", self.0)
579 }
580}
581
582impl fmt::Debug for CommitType {
583 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
584 f.debug_tuple("CommitType").field(&self.0).finish()
585 }
586}
587
588impl Serialize for CommitType {
589 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
590 where
591 S: serde::Serializer,
592 {
593 self.0.serialize(serializer)
594 }
595}
596
597impl<'de> Deserialize<'de> for CommitType {
598 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
599 where
600 D: serde::Deserializer<'de>,
601 {
602 let s = String::deserialize(deserializer)?;
603 Self::new(s).map_err(serde::de::Error::custom)
604 }
605}
606
607#[derive(Clone)]
609pub struct CommitSummary(String);
610
611impl CommitSummary {
612 pub fn new(s: impl Into<String>, max_len: usize) -> Result<Self> {
615 Self::new_impl(s, max_len, true)
616 }
617
618 pub(crate) fn new_unchecked(s: impl Into<String>, max_len: usize) -> Result<Self> {
621 Self::new_impl(s, max_len, false)
622 }
623
624 fn new_impl(s: impl Into<String>, max_len: usize, emit_warnings: bool) -> Result<Self> {
625 let s = s.into();
626
627 if s.trim().is_empty() {
629 return Err(CommitGenError::ValidationError("commit summary cannot be empty".to_string()));
630 }
631
632 if s.len() > max_len {
634 return Err(CommitGenError::SummaryTooLong { len: s.len(), max: max_len });
635 }
636
637 if emit_warnings {
638 if let Some(first_char) = s.chars().next()
640 && first_char.is_uppercase()
641 {
642 crate::style::warn(&format!("commit summary should start with lowercase: {s}"));
643 }
644
645 if s.trim_end().ends_with('.') {
647 crate::style::warn(&format!(
648 "commit summary should NOT end with period (conventional commits style): {s}"
649 ));
650 }
651 }
652
653 Ok(Self(s))
654 }
655
656 pub fn as_str(&self) -> &str {
658 &self.0
659 }
660
661 pub const fn len(&self) -> usize {
663 self.0.len()
664 }
665
666 #[allow(dead_code, reason = "Convenience method for future use")]
668 pub const fn is_empty(&self) -> bool {
669 self.0.is_empty()
670 }
671}
672
673impl fmt::Display for CommitSummary {
674 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
675 write!(f, "{}", self.0)
676 }
677}
678
679impl fmt::Debug for CommitSummary {
680 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
681 f.debug_tuple("CommitSummary").field(&self.0).finish()
682 }
683}
684
685impl Serialize for CommitSummary {
686 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
687 where
688 S: serde::Serializer,
689 {
690 self.0.serialize(serializer)
691 }
692}
693
694impl<'de> Deserialize<'de> for CommitSummary {
695 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
696 where
697 D: serde::Deserializer<'de>,
698 {
699 let s = String::deserialize(deserializer)?;
700 if s.trim().is_empty() {
702 return Err(serde::de::Error::custom("commit summary cannot be empty"));
703 }
704 if s.len() > 128 {
705 return Err(serde::de::Error::custom(format!(
706 "commit summary must be ≤128 characters, got {}",
707 s.len()
708 )));
709 }
710 Ok(Self(s))
711 }
712}
713
714#[derive(Clone, PartialEq, Eq)]
716pub struct Scope(String);
717
718impl Scope {
719 pub fn new(s: impl Into<String>) -> Result<Self> {
726 let s = s.into();
727 let segments: Vec<&str> = s.split('/').collect();
728
729 if segments.len() > 2 {
730 return Err(CommitGenError::InvalidScope(format!(
731 "scope has {} segments, max 2 allowed",
732 segments.len()
733 )));
734 }
735
736 for segment in &segments {
737 if segment.is_empty() {
738 return Err(CommitGenError::InvalidScope("scope contains empty segment".to_string()));
739 }
740 if !segment
741 .chars()
742 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
743 {
744 return Err(CommitGenError::InvalidScope(format!(
745 "invalid characters in scope segment: {segment}"
746 )));
747 }
748 }
749
750 Ok(Self(s))
751 }
752
753 pub fn as_str(&self) -> &str {
755 &self.0
756 }
757
758 #[allow(dead_code, reason = "Public API method for scope manipulation")]
760 pub fn segments(&self) -> Vec<&str> {
761 self.0.split('/').collect()
762 }
763
764 pub const fn is_empty(&self) -> bool {
766 self.0.is_empty()
767 }
768}
769
770impl fmt::Display for Scope {
771 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
772 write!(f, "{}", self.0)
773 }
774}
775
776impl fmt::Debug for Scope {
777 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
778 f.debug_tuple("Scope").field(&self.0).finish()
779 }
780}
781
782impl Serialize for Scope {
783 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
784 where
785 S: serde::Serializer,
786 {
787 serializer.serialize_str(&self.0)
788 }
789}
790
791impl<'de> Deserialize<'de> for Scope {
792 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
793 where
794 D: serde::Deserializer<'de>,
795 {
796 let s = String::deserialize(deserializer)?;
797 Self::new(s).map_err(serde::de::Error::custom)
798 }
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct ConventionalCommit {
803 pub commit_type: CommitType,
804 pub scope: Option<Scope>,
805 pub summary: CommitSummary,
806 pub body: Vec<String>,
807 pub footers: Vec<String>,
808}
809
810#[derive(Debug, Clone, Serialize, Deserialize)]
812pub struct AnalysisDetail {
813 pub text: String,
815 #[serde(default, skip_serializing_if = "Option::is_none")]
817 pub changelog_category: Option<ChangelogCategory>,
818 #[serde(default)]
820 pub user_visible: bool,
821}
822
823impl AnalysisDetail {
824 pub fn simple(text: impl Into<String>) -> Self {
827 Self { text: text.into(), changelog_category: None, user_visible: false }
828 }
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize)]
832pub struct ConventionalAnalysis {
833 #[serde(rename = "type")]
834 pub commit_type: CommitType,
835 #[serde(default, deserialize_with = "deserialize_optional_scope")]
836 pub scope: Option<Scope>,
837 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub summary: Option<String>,
839 #[serde(default, deserialize_with = "deserialize_analysis_details")]
841 pub details: Vec<AnalysisDetail>,
842 #[serde(default, deserialize_with = "deserialize_string_vec")]
843 pub issue_refs: Vec<String>,
844}
845
846impl ConventionalAnalysis {
847 pub fn body_texts(&self) -> Vec<String> {
849 self.details.iter().map(|d| d.text.clone()).collect()
850 }
851
852 pub fn changelog_entries(&self) -> std::collections::HashMap<ChangelogCategory, Vec<String>> {
854 let mut entries = std::collections::HashMap::new();
855 for detail in &self.details {
856 if detail.user_visible
857 && let Some(category) = detail.changelog_category
858 {
859 entries
860 .entry(category)
861 .or_insert_with(Vec::new)
862 .push(detail.text.clone());
863 }
864 }
865 entries
866 }
867}
868
869#[derive(Debug, Clone, Serialize, Deserialize)]
870#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
871pub struct SummaryOutput {
872 pub summary: String,
873}
874
875#[derive(Debug, Clone)]
877pub struct CommitMetadata {
878 pub hash: String,
879 pub author_name: String,
880 pub author_email: String,
881 pub author_date: String,
882 pub committer_name: String,
883 pub committer_email: String,
884 pub committer_date: String,
885 pub message: String,
886 pub parent_hashes: Vec<String>,
887 pub tree_hash: String,
888}
889
890#[derive(Debug, Clone)]
892pub enum HunkSelector {
893 All,
895 Lines { start: usize, end: usize },
897 Search { pattern: String },
899}
900
901impl Serialize for HunkSelector {
902 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
903 where
904 S: serde::Serializer,
905 {
906 match self {
907 Self::All => serializer.serialize_str("ALL"),
908 Self::Lines { start, end } => {
909 use serde::ser::SerializeStruct;
910 let mut state = serializer.serialize_struct("Lines", 2)?;
911 state.serialize_field("start", start)?;
912 state.serialize_field("end", end)?;
913 state.end()
914 },
915 Self::Search { pattern } => {
916 use serde::ser::SerializeStruct;
917 let mut state = serializer.serialize_struct("Search", 1)?;
918 state.serialize_field("pattern", pattern)?;
919 state.end()
920 },
921 }
922 }
923}
924
925impl<'de> Deserialize<'de> for HunkSelector {
926 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
927 where
928 D: serde::Deserializer<'de>,
929 {
930 let value = Value::deserialize(deserializer)?;
931
932 match value {
933 Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(Self::All),
935 Value::String(s) if s.starts_with("@@") => Ok(Self::Search { pattern: s }),
937 Value::String(s) if s.contains('-') => {
939 let parts: Vec<&str> = s.split('-').collect();
940 if parts.len() == 2 {
941 let start = parts[0].trim().parse().map_err(serde::de::Error::custom)?;
942 let end = parts[1].trim().parse().map_err(serde::de::Error::custom)?;
943 Ok(Self::Lines { start, end })
944 } else {
945 Err(serde::de::Error::custom(format!("Invalid line range format: {s}")))
946 }
947 },
948 Value::Object(map) if map.contains_key("start") && map.contains_key("end") => {
950 let start = map
951 .get("start")
952 .and_then(|v| v.as_u64())
953 .ok_or_else(|| serde::de::Error::custom("Invalid start field"))?
954 as usize;
955 let end = map
956 .get("end")
957 .and_then(|v| v.as_u64())
958 .ok_or_else(|| serde::de::Error::custom("Invalid end field"))?
959 as usize;
960 Ok(Self::Lines { start, end })
961 },
962 Value::Object(map) if map.contains_key("pattern") => {
964 let pattern = map
965 .get("pattern")
966 .and_then(|v| v.as_str())
967 .ok_or_else(|| serde::de::Error::custom("Invalid pattern field"))?
968 .to_string();
969 Ok(Self::Search { pattern })
970 },
971 Value::String(s) => Ok(Self::Search { pattern: s }),
973 _ => Err(serde::de::Error::custom("Invalid HunkSelector format")),
974 }
975 }
976}
977
978#[derive(Debug, Clone, Serialize, Deserialize)]
980pub struct FileChange {
981 pub path: String,
982 pub hunks: Vec<HunkSelector>,
983}
984
985#[derive(Debug, Clone, Serialize, Deserialize)]
987pub struct ChangeGroup {
988 pub changes: Vec<FileChange>,
989 #[serde(rename = "type")]
990 pub commit_type: CommitType,
991 pub scope: Option<Scope>,
992 pub rationale: String,
993 #[serde(default)]
994 pub dependencies: Vec<usize>,
995}
996
997#[derive(Debug, Clone, Serialize, Deserialize)]
999pub struct ComposeAnalysis {
1000 pub groups: Vec<ChangeGroup>,
1001 pub dependency_order: Vec<usize>,
1002}
1003
1004#[derive(Debug, Serialize)]
1006#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
1007pub struct Message {
1008 pub role: String,
1009 pub content: String,
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize)]
1013#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
1014pub struct FunctionParameters {
1015 #[serde(rename = "type")]
1016 pub param_type: String,
1017 pub properties: serde_json::Value,
1018 pub required: Vec<String>,
1019}
1020
1021#[derive(Debug, Clone, Serialize, Deserialize)]
1022#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
1023pub struct Function {
1024 pub name: String,
1025 pub description: String,
1026 pub parameters: FunctionParameters,
1027}
1028
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1030#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
1031pub struct Tool {
1032 #[serde(rename = "type")]
1033 pub tool_type: String,
1034 pub function: Function,
1035}
1036
1037#[derive(Parser, Debug)]
1039#[command(author, version, about = "Generate git commit messages using Claude AI", long_about = None)]
1040pub struct Args {
1041 #[arg(long, value_enum, default_value = "staged")]
1043 pub mode: Mode,
1044
1045 #[arg(long)]
1047 pub target: Option<String>,
1048
1049 #[arg(long)]
1051 pub copy: bool,
1052
1053 #[arg(long)]
1055 pub dry_run: bool,
1056
1057 #[arg(long, short = 'p')]
1059 pub push: bool,
1060
1061 #[arg(long, default_value = ".")]
1063 pub dir: String,
1064
1065 #[arg(long, short = 'm')]
1068 pub model: Option<String>,
1069
1070
1071 #[arg(long)]
1073 pub fixes: Vec<String>,
1074
1075 #[arg(long)]
1077 pub closes: Vec<String>,
1078
1079 #[arg(long)]
1081 pub resolves: Vec<String>,
1082
1083 #[arg(long)]
1085 pub refs: Vec<String>,
1086
1087 #[arg(long)]
1089 pub breaking: bool,
1090
1091 #[arg(long, short = 'S')]
1093 pub sign: bool,
1094
1095 #[arg(long, short = 's')]
1097 pub signoff: bool,
1098
1099 #[arg(long)]
1101 pub amend: bool,
1102
1103 #[arg(long, short = 'n')]
1106 pub skip_hooks: bool,
1107
1108 #[arg(long)]
1110 pub config: Option<PathBuf>,
1111
1112 #[arg(long, value_enum, value_name = "SHELL")]
1115 pub completions: Option<clap_complete::Shell>,
1116
1117 #[arg(trailing_var_arg = true)]
1120 pub context: Vec<String>,
1121
1122 #[arg(long, short = 'f', conflicts_with_all = ["compose", "rewrite", "test"])]
1125 pub fast: bool,
1126
1127 #[arg(long, conflicts_with_all = ["target", "copy", "dry_run"])]
1130 pub rewrite: bool,
1131
1132 #[arg(long, requires = "rewrite")]
1134 pub rewrite_preview: Option<usize>,
1135
1136 #[arg(long, requires = "rewrite")]
1138 pub rewrite_start: Option<String>,
1139
1140 #[arg(long, default_value = "10", requires = "rewrite")]
1142 pub rewrite_parallel: usize,
1143
1144 #[arg(long, requires = "rewrite")]
1146 pub rewrite_dry_run: bool,
1147
1148 #[arg(long, requires = "rewrite")]
1150 pub rewrite_hide_old_types: bool,
1151
1152 #[arg(long)]
1155 pub exclude_old_message: bool,
1156
1157 #[arg(long, conflicts_with_all = ["target", "rewrite"])]
1160 pub compose: bool,
1161
1162 #[arg(long, requires = "compose")]
1164 pub compose_preview: bool,
1165
1166 #[arg(long, requires = "compose")]
1168 pub compose_max_commits: Option<usize>,
1169
1170 #[arg(long, requires = "compose")]
1172 pub compose_test_after_each: bool,
1173
1174 #[arg(long)]
1177 pub no_changelog: bool,
1178
1179 #[arg(long)]
1183 pub debug_output: Option<PathBuf>,
1184
1185 #[arg(long, value_name = "FILE")]
1187 pub trace_output: Option<PathBuf>,
1188
1189 #[arg(long, conflicts_with_all = ["target", "rewrite", "compose"])]
1192 pub test: bool,
1193
1194 #[arg(long, requires = "test")]
1196 pub test_update: bool,
1197
1198 #[arg(long, requires = "test")]
1200 pub test_add: Option<String>,
1201
1202 #[arg(long, requires = "test_add")]
1204 pub test_name: Option<String>,
1205
1206 #[arg(long, requires = "test")]
1208 pub test_filter: Option<String>,
1209
1210 #[arg(long, requires = "test")]
1212 pub test_list: bool,
1213
1214 #[arg(long, requires = "test")]
1216 pub fixtures_dir: Option<PathBuf>,
1217
1218 #[arg(long, requires = "test")]
1220 pub test_report: Option<PathBuf>,
1221}
1222
1223impl Default for Args {
1224 fn default() -> Self {
1225 Self {
1226 mode: Mode::Staged,
1227 target: None,
1228 copy: false,
1229 dry_run: false,
1230 push: false,
1231 dir: ".".to_string(),
1232 model: None,
1233 fixes: vec![],
1234 closes: vec![],
1235 resolves: vec![],
1236 refs: vec![],
1237 breaking: false,
1238 sign: false,
1239 signoff: false,
1240 amend: false,
1241 skip_hooks: false,
1242 config: None,
1243 context: vec![],
1244 completions: None,
1245 rewrite: false,
1246 rewrite_preview: None,
1247 rewrite_start: None,
1248 rewrite_parallel: 10,
1249 rewrite_dry_run: false,
1250 rewrite_hide_old_types: false,
1251 exclude_old_message: false,
1252 fast: false,
1253 compose: false,
1254 compose_preview: false,
1255 compose_max_commits: None,
1256 compose_test_after_each: false,
1257 no_changelog: false,
1258 debug_output: None,
1259 trace_output: None,
1260 test: false,
1261 test_update: false,
1262 test_add: None,
1263 test_name: None,
1264 test_filter: None,
1265 test_list: false,
1266 fixtures_dir: None,
1267 test_report: None,
1268 }
1269 }
1270}
1271fn deserialize_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
1272where
1273 D: serde::Deserializer<'de>,
1274{
1275 let value = Value::deserialize(deserializer)?;
1276 Ok(value_to_string_vec(value))
1277}
1278
1279fn deserialize_analysis_details<'de, D>(
1281 deserializer: D,
1282) -> std::result::Result<Vec<AnalysisDetail>, D::Error>
1283where
1284 D: serde::Deserializer<'de>,
1285{
1286 let value = Value::deserialize(deserializer)?;
1287 match value {
1288 Value::Array(arr) => {
1289 let mut details = Vec::with_capacity(arr.len());
1290 for item in arr {
1291 let detail = match item {
1292 Value::Object(obj) => {
1294 let text = obj
1295 .get("text")
1296 .and_then(Value::as_str)
1297 .map(String::from)
1298 .unwrap_or_default();
1299 let changelog_category = obj
1300 .get("changelog_category")
1301 .and_then(Value::as_str)
1302 .map(ChangelogCategory::from_name);
1303 let user_visible = obj
1304 .get("user_visible")
1305 .and_then(Value::as_bool)
1306 .unwrap_or(false);
1307 AnalysisDetail { text, changelog_category, user_visible }
1308 },
1309 Value::String(s) => AnalysisDetail::simple(s),
1311 _ => continue,
1312 };
1313 if !detail.text.is_empty() {
1314 details.push(detail);
1315 }
1316 }
1317 Ok(details)
1318 },
1319 Value::String(s) => {
1320 if s.is_empty() {
1322 Ok(Vec::new())
1323 } else {
1324 Ok(vec![AnalysisDetail::simple(s)])
1325 }
1326 },
1327 Value::Null => Ok(Vec::new()),
1328 _ => Ok(Vec::new()),
1329 }
1330}
1331
1332fn extract_strings_from_malformed_json(input: &str) -> Vec<String> {
1333 let mut strings = Vec::new();
1334 let mut chars = input.chars();
1335
1336 while let Some(c) = chars.next() {
1337 if c == '"' {
1338 let mut current_string = String::new();
1339 let mut escaped = false;
1340
1341 for inner_c in chars.by_ref() {
1342 if escaped {
1343 current_string.push(inner_c);
1344 escaped = false;
1345 } else if inner_c == '\\' {
1346 current_string.push(inner_c);
1347 escaped = true;
1348 } else if inner_c == '"' {
1349 break;
1350 } else {
1351 current_string.push(inner_c);
1352 }
1353 }
1354
1355 let json_candidate = format!("\"{current_string}\"");
1357 if let Ok(parsed) = serde_json::from_str::<String>(&json_candidate) {
1358 strings.push(parsed);
1359 } else {
1360 let sanitized = current_string.replace(['\n', '\r'], " ");
1362 let json_sanitized = format!("\"{sanitized}\"");
1363 if let Ok(parsed) = serde_json::from_str::<String>(&json_sanitized) {
1364 strings.push(parsed);
1365 } else {
1366 strings.push(sanitized);
1368 }
1369 }
1370 }
1371 }
1372 strings
1373}
1374
1375fn value_to_string_vec(value: Value) -> Vec<String> {
1376 match value {
1377 Value::Null => Vec::new(),
1378 Value::String(s) => {
1379 let trimmed = s.trim();
1380
1381 if trimmed.starts_with('[') {
1383 let mut cleaned = trimmed;
1386 loop {
1387 let before = cleaned;
1388 cleaned = cleaned.trim_end_matches(['.', ',', ';', '"', '\'']);
1389 if cleaned == before {
1390 break;
1391 }
1392 }
1393
1394 if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(cleaned) {
1396 return arr
1397 .into_iter()
1398 .flat_map(|v| value_to_string_vec(v).into_iter())
1399 .collect();
1400 }
1401
1402 let sanitized = cleaned.replace(['\n', '\r'], " ");
1405 if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(&sanitized) {
1406 return arr
1407 .into_iter()
1408 .flat_map(|v| value_to_string_vec(v).into_iter())
1409 .collect();
1410 }
1411
1412 let extracted = extract_strings_from_malformed_json(trimmed);
1415 if !extracted.is_empty() {
1416 return extracted;
1417 }
1418 }
1419
1420 s.lines()
1422 .map(str::trim)
1423 .filter(|s| !s.is_empty())
1424 .map(|s| s.to_string())
1425 .collect()
1426 },
1427 Value::Array(arr) => arr
1428 .into_iter()
1429 .flat_map(|v| value_to_string_vec(v).into_iter())
1430 .collect(),
1431 Value::Object(map) => map
1432 .into_iter()
1433 .flat_map(|(k, v)| {
1434 let values = value_to_string_vec(v);
1435 if values.is_empty() {
1436 vec![k]
1437 } else {
1438 values
1439 .into_iter()
1440 .map(|val| format!("{k}: {val}"))
1441 .collect()
1442 }
1443 })
1444 .collect(),
1445 other => vec![other.to_string()],
1446 }
1447}
1448
1449fn deserialize_optional_scope<'de, D>(
1450 deserializer: D,
1451) -> std::result::Result<Option<Scope>, D::Error>
1452where
1453 D: serde::Deserializer<'de>,
1454{
1455 let value = Option::<String>::deserialize(deserializer)?;
1456 Ok(coerce_optional_scope(value.as_deref()))
1457}
1458
1459pub(crate) fn coerce_optional_scope(raw: Option<&str>) -> Option<Scope> {
1460 match raw {
1461 None => None,
1462 Some(scope_str) => {
1463 let trimmed = scope_str.trim();
1464 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
1465 None
1466 } else {
1467 coerce_scope(trimmed)
1468 }
1469 },
1470 }
1471}
1472
1473fn coerce_scope(raw: &str) -> Option<Scope> {
1474 let normalized = raw.trim().replace('\\', "/").to_lowercase();
1475
1476 let segments: Vec<String> = normalized
1477 .split('/')
1478 .filter_map(sanitize_scope_segment)
1479 .take(2)
1480 .collect();
1481
1482 if segments.is_empty() {
1483 return None;
1484 }
1485
1486 Scope::new(segments.join("/")).ok()
1487}
1488
1489fn sanitize_scope_segment(segment: &str) -> Option<String> {
1490 let mut out = String::new();
1491 let mut last_was_separator = false;
1492
1493 for ch in segment.trim().chars() {
1494 if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
1495 out.push(ch);
1496 last_was_separator = false;
1497 } else if ch == '-' || ch == '_' {
1498 if !out.is_empty() && !last_was_separator {
1499 out.push(ch);
1500 last_was_separator = true;
1501 }
1502 } else if (ch.is_ascii_whitespace() || ch == '.') && !out.is_empty() && !last_was_separator {
1503 out.push('-');
1504 last_was_separator = true;
1505 }
1506 }
1507
1508 let trimmed = out.trim_matches(['-', '_']).to_string();
1509 (!trimmed.is_empty()).then_some(trimmed)
1510}
1511
1512#[cfg(test)]
1513mod tests {
1514 use super::*;
1515
1516 #[test]
1519 fn test_resolve_model_name() {
1520 assert_eq!(resolve_model_name("sonnet"), "claude-sonnet-4.5");
1522 assert_eq!(resolve_model_name("s"), "claude-sonnet-4.5");
1523 assert_eq!(resolve_model_name("opus"), "claude-opus-4.5");
1524 assert_eq!(resolve_model_name("o"), "claude-opus-4.5");
1525 assert_eq!(resolve_model_name("haiku"), "claude-haiku-4-5");
1526 assert_eq!(resolve_model_name("h"), "claude-haiku-4-5");
1527
1528 assert_eq!(resolve_model_name("gpt5"), "gpt-5");
1530 assert_eq!(resolve_model_name("g5"), "gpt-5");
1531
1532 assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
1534 assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
1535
1536 assert_eq!(resolve_model_name("claude-sonnet-4.5"), "claude-sonnet-4.5");
1538 assert_eq!(resolve_model_name("custom-model"), "custom-model");
1539 }
1540
1541 #[test]
1544 fn test_commit_type_valid() {
1545 let valid_types = [
1546 "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
1547 "revert",
1548 ];
1549
1550 for ty in &valid_types {
1551 assert!(CommitType::new(*ty).is_ok(), "Expected '{ty}' to be valid");
1552 }
1553 }
1554
1555 #[test]
1556 fn test_commit_type_case_normalization() {
1557 let ct = CommitType::new("FEAT").expect("FEAT should normalize");
1559 assert_eq!(ct.as_str(), "feat");
1560
1561 let ct = CommitType::new("Fix").expect("Fix should normalize");
1562 assert_eq!(ct.as_str(), "fix");
1563
1564 let ct = CommitType::new("ReFaCtOr").expect("ReFaCtOr should normalize");
1565 assert_eq!(ct.as_str(), "refactor");
1566 }
1567
1568 #[test]
1569 fn test_commit_type_invalid() {
1570 let invalid_types = ["invalid", "bug", "feature", "update", "change", "random", "xyz", "123"];
1571
1572 for ty in &invalid_types {
1573 assert!(CommitType::new(*ty).is_err(), "Expected '{ty}' to be invalid");
1574 }
1575 }
1576
1577 #[test]
1578 fn test_commit_type_empty() {
1579 assert!(CommitType::new("").is_err(), "Empty string should be invalid");
1580 }
1581
1582 #[test]
1583 fn test_commit_type_display() {
1584 let ct = CommitType::new("feat").unwrap();
1585 assert_eq!(format!("{ct}"), "feat");
1586 }
1587
1588 #[test]
1589 fn test_commit_type_len() {
1590 let ct = CommitType::new("feat").unwrap();
1591 assert_eq!(ct.len(), 4);
1592
1593 let ct = CommitType::new("refactor").unwrap();
1594 assert_eq!(ct.len(), 8);
1595 }
1596
1597 #[test]
1600 fn test_scope_valid_single_segment() {
1601 let valid_scopes = ["core", "api", "lib", "client", "server", "ui", "test-123", "foo_bar"];
1602
1603 for scope in &valid_scopes {
1604 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1605 }
1606 }
1607
1608 #[test]
1609 fn test_scope_valid_two_segments() {
1610 let valid_scopes = ["api/client", "lib/core", "ui/components", "test-1/foo_2"];
1611
1612 for scope in &valid_scopes {
1613 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1614 }
1615 }
1616
1617 #[test]
1618 fn test_scope_invalid_three_segments() {
1619 let scope = Scope::new("a/b/c");
1620 assert!(scope.is_err(), "Three segments should be invalid");
1621
1622 if let Err(CommitGenError::InvalidScope(msg)) = scope {
1623 assert!(msg.contains("3 segments"));
1624 } else {
1625 panic!("Expected InvalidScope error");
1626 }
1627 }
1628
1629 #[test]
1630 fn test_scope_invalid_uppercase() {
1631 let invalid_scopes = ["Core", "API", "MyScope", "api/Client"];
1632
1633 for scope in &invalid_scopes {
1634 assert!(Scope::new(*scope).is_err(), "Expected '{scope}' with uppercase to be invalid");
1635 }
1636 }
1637
1638 #[test]
1639 fn test_scope_invalid_empty_segments() {
1640 let invalid_scopes = ["", "a//b", "/foo", "bar/"];
1641
1642 for scope in &invalid_scopes {
1643 assert!(
1644 Scope::new(*scope).is_err(),
1645 "Expected '{scope}' with empty segments to be invalid"
1646 );
1647 }
1648 }
1649
1650 #[test]
1651 fn test_scope_invalid_chars() {
1652 let invalid_scopes = ["a b", "foo bar", "test@scope", "api/client!", "a.b"];
1653
1654 for scope in &invalid_scopes {
1655 assert!(
1656 Scope::new(*scope).is_err(),
1657 "Expected '{scope}' with invalid chars to be invalid"
1658 );
1659 }
1660 }
1661
1662 #[test]
1663 fn test_scope_segments() {
1664 let scope = Scope::new("core").unwrap();
1665 assert_eq!(scope.segments(), vec!["core"]);
1666
1667 let scope = Scope::new("api/client").unwrap();
1668 assert_eq!(scope.segments(), vec!["api", "client"]);
1669 }
1670
1671 #[test]
1672 fn test_scope_display() {
1673 let scope = Scope::new("api/client").unwrap();
1674 assert_eq!(format!("{scope}"), "api/client");
1675 }
1676
1677 #[test]
1680 fn test_commit_summary_valid() {
1681 let summary_72 = "a".repeat(72);
1682 let summary_96 = "a".repeat(96);
1683 let summary_128 = "a".repeat(128);
1684 let valid_summaries = [
1685 "added new feature",
1686 "fixed bug in authentication",
1687 "x", summary_72.as_str(), summary_96.as_str(), summary_128.as_str(), ];
1692
1693 for summary in &valid_summaries {
1694 assert!(
1695 CommitSummary::new(*summary, 128).is_ok(),
1696 "Expected '{}' (len={}) to be valid",
1697 if summary.len() > 50 {
1698 &summary[..50]
1699 } else {
1700 summary
1701 },
1702 summary.len()
1703 );
1704 }
1705 }
1706
1707 #[test]
1708 fn test_commit_summary_too_long() {
1709 let long_summary = "a".repeat(129); let result = CommitSummary::new(long_summary, 128);
1711 assert!(result.is_err(), "129 char summary should be invalid");
1712
1713 if let Err(CommitGenError::SummaryTooLong { len, max }) = result {
1714 assert_eq!(len, 129);
1715 assert_eq!(max, 128);
1716 } else {
1717 panic!("Expected SummaryTooLong error");
1718 }
1719 }
1720
1721 #[test]
1722 fn test_commit_summary_empty() {
1723 let empty_cases = ["", " ", "\t", "\n"];
1724
1725 for empty in &empty_cases {
1726 assert!(
1727 CommitSummary::new(*empty, 128).is_err(),
1728 "Empty/whitespace-only summary should be invalid"
1729 );
1730 }
1731 }
1732
1733 #[test]
1734 fn test_commit_summary_warnings_uppercase_start() {
1735 let result = CommitSummary::new("Added new feature", 128);
1737 assert!(result.is_ok(), "Should succeed despite uppercase start");
1738 }
1739
1740 #[test]
1741 fn test_commit_summary_warnings_with_period() {
1742 let result = CommitSummary::new("added new feature.", 128);
1744 assert!(result.is_ok(), "Should succeed despite having period");
1745 }
1746
1747 #[test]
1748 fn test_commit_summary_new_unchecked() {
1749 let result = CommitSummary::new_unchecked("Added feature", 128);
1751 assert!(result.is_ok(), "new_unchecked should succeed");
1752 }
1753
1754 #[test]
1755 fn test_commit_summary_len() {
1756 let summary = CommitSummary::new("hello world", 128).unwrap();
1757 assert_eq!(summary.len(), 11);
1758 }
1759
1760 #[test]
1761 fn test_commit_summary_display() {
1762 let summary = CommitSummary::new("fixed bug", 128).unwrap();
1763 assert_eq!(format!("{summary}"), "fixed bug");
1764 }
1765
1766 #[test]
1769 fn test_commit_type_serialize() {
1770 let ct = CommitType::new("feat").unwrap();
1771 let json = serde_json::to_string(&ct).unwrap();
1772 assert_eq!(json, "\"feat\"");
1773 }
1774
1775 #[test]
1776 fn test_commit_type_deserialize() {
1777 let ct: CommitType = serde_json::from_str("\"fix\"").unwrap();
1778 assert_eq!(ct.as_str(), "fix");
1779
1780 let result: serde_json::Result<CommitType> = serde_json::from_str("\"invalid\"");
1782 assert!(result.is_err());
1783 }
1784
1785 #[test]
1786 fn test_scope_serialize() {
1787 let scope = Scope::new("api/client").unwrap();
1788 let json = serde_json::to_string(&scope).unwrap();
1789 assert_eq!(json, "\"api/client\"");
1790 }
1791
1792 #[test]
1793 fn test_scope_deserialize() {
1794 let scope: Scope = serde_json::from_str("\"core\"").unwrap();
1795 assert_eq!(scope.as_str(), "core");
1796
1797 let result: serde_json::Result<Scope> = serde_json::from_str("\"INVALID\"");
1799 assert!(result.is_err());
1800 }
1801
1802 #[test]
1803 fn test_commit_summary_serialize() {
1804 let summary = CommitSummary::new("fixed bug", 128).unwrap();
1805 let json = serde_json::to_string(&summary).unwrap();
1806 assert_eq!(json, "\"fixed bug\"");
1807 }
1808
1809 #[test]
1810 fn test_details_array_parsing() {
1811 let test_cases = [
1813 r#"{"type":"feat","details":[{"text":"item1"},{"text":"item2"}],"issue_refs":[]}"#,
1815 r#"{"type":"feat","details":["item1","item2"],"issue_refs":[]}"#,
1817 ];
1818
1819 for (idx, json) in test_cases.iter().enumerate() {
1820 let result: serde_json::Result<ConventionalAnalysis> = serde_json::from_str(json);
1821 match result {
1822 Ok(analysis) => {
1823 let body_texts = analysis.body_texts();
1824 assert_eq!(
1825 body_texts.len(),
1826 2,
1827 "Case {idx}: Expected 2 body items, got {}",
1828 body_texts.len()
1829 );
1830 assert_eq!(body_texts[0], "item1", "Case {idx}: First item mismatch");
1831 assert_eq!(body_texts[1], "item2", "Case {idx}: Second item mismatch");
1832 },
1833 Err(e) => {
1834 panic!("Case {idx}: Failed to parse: {e}");
1835 },
1836 }
1837 }
1838 }
1839
1840 #[test]
1841 fn test_conventional_analysis_summary_roundtrip() {
1842 let json = r##"{
1843 "type": "feat",
1844 "scope": "api",
1845 "summary": "added holistic commit titles",
1846 "details": [{"text": "Added summary generation to holistic analysis.", "user_visible": false}],
1847 "issue_refs": ["#123"]
1848 }"##;
1849
1850 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1851 assert_eq!(analysis.summary.as_deref(), Some("added holistic commit titles"));
1852
1853 let serialized = serde_json::to_value(&analysis).unwrap();
1854 assert_eq!(serialized["summary"], "added holistic commit titles");
1855 }
1856
1857 #[test]
1858 fn test_analysis_detail_with_changelog() {
1859 let json = r#"{
1861 "type": "feat",
1862 "details": [
1863 {"text": "Added new API endpoint", "changelog_category": "Added", "user_visible": true},
1864 {"text": "Refactored internal code", "user_visible": false}
1865 ],
1866 "issue_refs": []
1867 }"#;
1868
1869 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1870 assert_eq!(analysis.details.len(), 2);
1871 assert_eq!(analysis.details[0].text, "Added new API endpoint");
1872 assert_eq!(analysis.details[0].changelog_category, Some(ChangelogCategory::Added));
1873 assert!(analysis.details[0].user_visible);
1874 assert!(!analysis.details[1].user_visible);
1875
1876 let entries = analysis.changelog_entries();
1878 assert_eq!(entries.len(), 1);
1879 assert!(entries.contains_key(&ChangelogCategory::Added));
1880 }
1881
1882 #[test]
1883 fn test_commit_summary_deserialize() {
1884 let summary: CommitSummary = serde_json::from_str("\"added feature\"").unwrap();
1885 assert_eq!(summary.as_str(), "added feature");
1886
1887 let long = format!("\"{}\"", "a".repeat(129));
1889 let result: serde_json::Result<CommitSummary> = serde_json::from_str(&long);
1890 assert!(result.is_err());
1891
1892 let result: serde_json::Result<CommitSummary> = serde_json::from_str("\"\"");
1894 assert!(result.is_err());
1895 }
1896
1897 #[test]
1898 fn test_conventional_commit_roundtrip() {
1899 let commit = ConventionalCommit {
1900 commit_type: CommitType::new("feat").unwrap(),
1901 scope: Some(Scope::new("api").unwrap()),
1902 summary: CommitSummary::new_unchecked("added endpoint", 128).unwrap(),
1903 body: vec!["detail 1.".to_string(), "detail 2.".to_string()],
1904 footers: vec!["Fixes: #123".to_string()],
1905 };
1906
1907 let json = serde_json::to_string(&commit).unwrap();
1908 let deserialized: ConventionalCommit = serde_json::from_str(&json).unwrap();
1909
1910 assert_eq!(deserialized.commit_type.as_str(), "feat");
1911 assert_eq!(deserialized.scope.unwrap().as_str(), "api");
1912 assert_eq!(deserialized.summary.as_str(), "added endpoint");
1913 assert_eq!(deserialized.body.len(), 2);
1914 assert_eq!(deserialized.footers.len(), 1);
1915 }
1916
1917 #[test]
1918 fn test_scope_null_string_deserializes_to_none() {
1919 let test_cases = [
1921 r#"{"type":"feat","scope":"null","body":[],"issue_refs":[]}"#,
1922 r#"{"type":"feat","scope":"Null","body":[],"issue_refs":[]}"#,
1923 r#"{"type":"feat","scope":"NULL","body":[],"issue_refs":[]}"#,
1924 r#"{"type":"feat","scope":" null ","body":[],"issue_refs":[]}"#,
1925 ];
1926
1927 for (idx, json) in test_cases.iter().enumerate() {
1928 let analysis: ConventionalAnalysis = serde_json::from_str(json)
1929 .unwrap_or_else(|e| panic!("Case {idx} failed to deserialize: {e}"));
1930 assert!(
1931 analysis.scope.is_none(),
1932 "Case {idx}: Expected scope to be None, got {:?}",
1933 analysis.scope
1934 );
1935 }
1936 }
1937
1938 #[test]
1939 fn test_scope_invalid_model_output_is_coerced() {
1940 let json = r#"{"type":"chore","scope":".github","details":[],"issue_refs":[]}"#;
1941 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1942 assert_eq!(analysis.scope.as_ref().map(Scope::as_str), Some("github"));
1943 }
1944
1945 #[test]
1946 fn test_scope_path_like_model_output_is_coerced() {
1947 let json = r#"{"type":"chore","scope":"docs//Release Notes","details":[],"issue_refs":[]}"#;
1948 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1949 assert_eq!(analysis.scope.as_ref().map(Scope::as_str), Some("docs/release-notes"));
1950 }
1951
1952 #[test]
1955 fn test_body_array_with_newline_in_string() {
1956 let raw_str = "[\"Item 1\", \"Item\n2\"]";
1960 let value = serde_json::Value::String(raw_str.to_string());
1961
1962 let result = value_to_string_vec(value);
1964
1965 assert_eq!(result.len(), 2);
1967 assert_eq!(result[0], "Item 1");
1968 assert_eq!(result[1], "Item 2");
1971 }
1972
1973 #[test]
1974 fn test_body_array_malformed_truncated() {
1975 let raw_str = "[\"Refactored finance...\", \"Added automatic detection...\".";
1978 let value = serde_json::Value::String(raw_str.to_string());
1979
1980 let result = value_to_string_vec(value);
1981
1982 assert_eq!(result.len(), 2);
1984 assert_eq!(result[0], "Refactored finance...");
1985 assert_eq!(result[1], "Added automatic detection...");
1986 }
1987
1988 #[test]
1989 fn test_hunk_selector_deserialize_all() {
1990 let json = r#""ALL""#;
1991 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1992 assert!(matches!(selector, HunkSelector::All));
1993 }
1994
1995 #[test]
1996 fn test_hunk_selector_deserialize_lines_object() {
1997 let json = r#"{"start": 10, "end": 20}"#;
1998 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1999 match selector {
2000 HunkSelector::Lines { start, end } => {
2001 assert_eq!(start, 10);
2002 assert_eq!(end, 20);
2003 },
2004 _ => panic!("Expected Lines variant"),
2005 }
2006 }
2007
2008 #[test]
2009 fn test_hunk_selector_deserialize_lines_string() {
2010 let json = r#""10-20""#;
2011 let selector: HunkSelector = serde_json::from_str(json).unwrap();
2012 match selector {
2013 HunkSelector::Lines { start, end } => {
2014 assert_eq!(start, 10);
2015 assert_eq!(end, 20);
2016 },
2017 _ => panic!("Expected Lines variant"),
2018 }
2019 }
2020
2021 #[test]
2022 fn test_hunk_selector_deserialize_search_pattern() {
2023 let json = r#"{"pattern": "fn main"}"#;
2024 let selector: HunkSelector = serde_json::from_str(json).unwrap();
2025 match selector {
2026 HunkSelector::Search { pattern } => {
2027 assert_eq!(pattern, "fn main");
2028 },
2029 _ => panic!("Expected Search variant"),
2030 }
2031 }
2032
2033 #[test]
2034 fn test_hunk_selector_deserialize_old_format_hunk_header() {
2035 let json = r#""@@ -10,5 +10,7 @@""#;
2037 let selector: HunkSelector = serde_json::from_str(json).unwrap();
2038 match selector {
2039 HunkSelector::Search { pattern } => {
2040 assert_eq!(pattern, "@@ -10,5 +10,7 @@");
2041 },
2042 _ => panic!("Expected Search variant for old hunk header format"),
2043 }
2044 }
2045
2046 #[test]
2047 fn test_hunk_selector_serialize_all() {
2048 let selector = HunkSelector::All;
2049 let json = serde_json::to_string(&selector).unwrap();
2050 assert_eq!(json, r#""ALL""#);
2051 }
2052
2053 #[test]
2054 fn test_hunk_selector_serialize_lines() {
2055 let selector = HunkSelector::Lines { start: 10, end: 20 };
2056 let json = serde_json::to_value(&selector).unwrap();
2057 assert_eq!(json["start"], 10);
2058 assert_eq!(json["end"], 20);
2059 }
2060
2061 #[test]
2062 fn test_file_change_deserialize_with_all() {
2063 let json = r#"{"path": "src/main.rs", "hunks": ["ALL"]}"#;
2064 let change: FileChange = serde_json::from_str(json).unwrap();
2065 assert_eq!(change.path, "src/main.rs");
2066 assert_eq!(change.hunks.len(), 1);
2067 assert!(matches!(change.hunks[0], HunkSelector::All));
2068 }
2069
2070 #[test]
2071 fn test_file_change_deserialize_with_line_ranges() {
2072 let json = r#"{"path": "src/main.rs", "hunks": [{"start": 10, "end": 20}, {"start": 50, "end": 60}]}"#;
2073 let change: FileChange = serde_json::from_str(json).unwrap();
2074 assert_eq!(change.path, "src/main.rs");
2075 assert_eq!(change.hunks.len(), 2);
2076
2077 match &change.hunks[0] {
2078 HunkSelector::Lines { start, end } => {
2079 assert_eq!(*start, 10);
2080 assert_eq!(*end, 20);
2081 },
2082 _ => panic!("Expected Lines variant"),
2083 }
2084
2085 match &change.hunks[1] {
2086 HunkSelector::Lines { start, end } => {
2087 assert_eq!(*start, 50);
2088 assert_eq!(*end, 60);
2089 },
2090 _ => panic!("Expected Lines variant"),
2091 }
2092 }
2093
2094 #[test]
2095 fn test_file_change_deserialize_mixed_formats() {
2096 let json = r#"{"path": "src/main.rs", "hunks": ["10-20", {"start": 50, "end": 60}]}"#;
2098 let change: FileChange = serde_json::from_str(json).unwrap();
2099 assert_eq!(change.hunks.len(), 2);
2100
2101 match &change.hunks[0] {
2102 HunkSelector::Lines { start, end } => {
2103 assert_eq!(*start, 10);
2104 assert_eq!(*end, 20);
2105 },
2106 _ => panic!("Expected Lines variant"),
2107 }
2108
2109 match &change.hunks[1] {
2110 HunkSelector::Lines { start, end } => {
2111 assert_eq!(*start, 50);
2112 assert_eq!(*end, 60);
2113 },
2114 _ => panic!("Expected Lines variant"),
2115 }
2116 }
2117}