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