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 #[arg(long)]
1072 pub fixes: Vec<String>,
1073
1074 #[arg(long)]
1076 pub closes: Vec<String>,
1077
1078 #[arg(long)]
1080 pub resolves: Vec<String>,
1081
1082 #[arg(long)]
1084 pub refs: Vec<String>,
1085
1086 #[arg(long)]
1088 pub breaking: bool,
1089
1090 #[arg(long, short = 'S')]
1092 pub sign: bool,
1093
1094 #[arg(long, short = 's')]
1096 pub signoff: bool,
1097
1098 #[arg(long)]
1100 pub amend: bool,
1101
1102 #[arg(long, short = 'n')]
1105 pub skip_hooks: bool,
1106
1107 #[arg(long)]
1109 pub config: Option<PathBuf>,
1110
1111 #[arg(long, value_enum, value_name = "SHELL")]
1114 pub completions: Option<clap_complete::Shell>,
1115
1116 #[arg(trailing_var_arg = true)]
1119 pub context: Vec<String>,
1120
1121 #[arg(long, short = 'f', conflicts_with_all = ["compose", "rewrite", "test"])]
1124 pub fast: bool,
1125
1126 #[arg(long, conflicts_with_all = ["target", "copy", "dry_run"])]
1129 pub rewrite: bool,
1130
1131 #[arg(long, requires = "rewrite")]
1133 pub rewrite_preview: Option<usize>,
1134
1135 #[arg(long, requires = "rewrite")]
1137 pub rewrite_start: Option<String>,
1138
1139 #[arg(long, default_value = "10", requires = "rewrite")]
1141 pub rewrite_parallel: usize,
1142
1143 #[arg(long, requires = "rewrite")]
1145 pub rewrite_dry_run: bool,
1146
1147 #[arg(long, requires = "rewrite")]
1149 pub rewrite_hide_old_types: bool,
1150
1151 #[arg(long)]
1154 pub exclude_old_message: bool,
1155
1156 #[arg(long, conflicts_with_all = ["target", "rewrite"])]
1159 pub compose: bool,
1160
1161 #[arg(long, requires = "compose")]
1163 pub compose_preview: bool,
1164
1165 #[arg(long, requires = "compose")]
1167 pub compose_max_commits: Option<usize>,
1168
1169 #[arg(long, requires = "compose")]
1171 pub compose_test_after_each: bool,
1172
1173 #[arg(long)]
1176 pub no_changelog: bool,
1177
1178 #[arg(long)]
1182 pub debug_output: Option<PathBuf>,
1183
1184 #[arg(long, value_name = "FILE")]
1186 pub trace_output: Option<PathBuf>,
1187
1188 #[arg(long, conflicts_with_all = ["target", "rewrite", "compose"])]
1191 pub test: bool,
1192
1193 #[arg(long, requires = "test")]
1195 pub test_update: bool,
1196
1197 #[arg(long, requires = "test")]
1199 pub test_add: Option<String>,
1200
1201 #[arg(long, requires = "test_add")]
1203 pub test_name: Option<String>,
1204
1205 #[arg(long, requires = "test")]
1207 pub test_filter: Option<String>,
1208
1209 #[arg(long, requires = "test")]
1211 pub test_list: bool,
1212
1213 #[arg(long, requires = "test")]
1215 pub fixtures_dir: Option<PathBuf>,
1216
1217 #[arg(long, requires = "test")]
1219 pub test_report: Option<PathBuf>,
1220}
1221
1222impl Default for Args {
1223 fn default() -> Self {
1224 Self {
1225 mode: Mode::Staged,
1226 target: None,
1227 copy: false,
1228 dry_run: false,
1229 push: false,
1230 dir: ".".to_string(),
1231 model: None,
1232 fixes: vec![],
1233 closes: vec![],
1234 resolves: vec![],
1235 refs: vec![],
1236 breaking: false,
1237 sign: false,
1238 signoff: false,
1239 amend: false,
1240 skip_hooks: false,
1241 config: None,
1242 context: vec![],
1243 completions: None,
1244 rewrite: false,
1245 rewrite_preview: None,
1246 rewrite_start: None,
1247 rewrite_parallel: 10,
1248 rewrite_dry_run: false,
1249 rewrite_hide_old_types: false,
1250 exclude_old_message: false,
1251 fast: false,
1252 compose: false,
1253 compose_preview: false,
1254 compose_max_commits: None,
1255 compose_test_after_each: false,
1256 no_changelog: false,
1257 debug_output: None,
1258 trace_output: None,
1259 test: false,
1260 test_update: false,
1261 test_add: None,
1262 test_name: None,
1263 test_filter: None,
1264 test_list: false,
1265 fixtures_dir: None,
1266 test_report: None,
1267 }
1268 }
1269}
1270fn deserialize_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
1271where
1272 D: serde::Deserializer<'de>,
1273{
1274 let value = Value::deserialize(deserializer)?;
1275 Ok(value_to_string_vec(value))
1276}
1277
1278fn deserialize_analysis_details<'de, D>(
1280 deserializer: D,
1281) -> std::result::Result<Vec<AnalysisDetail>, D::Error>
1282where
1283 D: serde::Deserializer<'de>,
1284{
1285 let value = Value::deserialize(deserializer)?;
1286 match value {
1287 Value::Array(arr) => {
1288 let mut details = Vec::with_capacity(arr.len());
1289 for item in arr {
1290 let detail = match item {
1291 Value::Object(obj) => {
1293 let text = obj
1294 .get("text")
1295 .and_then(Value::as_str)
1296 .map(String::from)
1297 .unwrap_or_default();
1298 let changelog_category = obj
1299 .get("changelog_category")
1300 .and_then(Value::as_str)
1301 .map(ChangelogCategory::from_name);
1302 let user_visible = obj
1303 .get("user_visible")
1304 .and_then(Value::as_bool)
1305 .unwrap_or(false);
1306 AnalysisDetail { text, changelog_category, user_visible }
1307 },
1308 Value::String(s) => AnalysisDetail::simple(s),
1310 _ => continue,
1311 };
1312 if !detail.text.is_empty() {
1313 details.push(detail);
1314 }
1315 }
1316 Ok(details)
1317 },
1318 Value::String(s) => {
1319 if s.is_empty() {
1321 Ok(Vec::new())
1322 } else {
1323 Ok(vec![AnalysisDetail::simple(s)])
1324 }
1325 },
1326 Value::Null => Ok(Vec::new()),
1327 _ => Ok(Vec::new()),
1328 }
1329}
1330
1331fn extract_strings_from_malformed_json(input: &str) -> Vec<String> {
1332 let mut strings = Vec::new();
1333 let mut chars = input.chars();
1334
1335 while let Some(c) = chars.next() {
1336 if c == '"' {
1337 let mut current_string = String::new();
1338 let mut escaped = false;
1339
1340 for inner_c in chars.by_ref() {
1341 if escaped {
1342 current_string.push(inner_c);
1343 escaped = false;
1344 } else if inner_c == '\\' {
1345 current_string.push(inner_c);
1346 escaped = true;
1347 } else if inner_c == '"' {
1348 break;
1349 } else {
1350 current_string.push(inner_c);
1351 }
1352 }
1353
1354 let json_candidate = format!("\"{current_string}\"");
1356 if let Ok(parsed) = serde_json::from_str::<String>(&json_candidate) {
1357 strings.push(parsed);
1358 } else {
1359 let sanitized = current_string.replace(['\n', '\r'], " ");
1361 let json_sanitized = format!("\"{sanitized}\"");
1362 if let Ok(parsed) = serde_json::from_str::<String>(&json_sanitized) {
1363 strings.push(parsed);
1364 } else {
1365 strings.push(sanitized);
1367 }
1368 }
1369 }
1370 }
1371 strings
1372}
1373
1374fn value_to_string_vec(value: Value) -> Vec<String> {
1375 match value {
1376 Value::Null => Vec::new(),
1377 Value::String(s) => {
1378 let trimmed = s.trim();
1379
1380 if trimmed.starts_with('[') {
1382 let mut cleaned = trimmed;
1385 loop {
1386 let before = cleaned;
1387 cleaned = cleaned.trim_end_matches(['.', ',', ';', '"', '\'']);
1388 if cleaned == before {
1389 break;
1390 }
1391 }
1392
1393 if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(cleaned) {
1395 return arr
1396 .into_iter()
1397 .flat_map(|v| value_to_string_vec(v).into_iter())
1398 .collect();
1399 }
1400
1401 let sanitized = cleaned.replace(['\n', '\r'], " ");
1404 if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(&sanitized) {
1405 return arr
1406 .into_iter()
1407 .flat_map(|v| value_to_string_vec(v).into_iter())
1408 .collect();
1409 }
1410
1411 let extracted = extract_strings_from_malformed_json(trimmed);
1414 if !extracted.is_empty() {
1415 return extracted;
1416 }
1417 }
1418
1419 s.lines()
1421 .map(str::trim)
1422 .filter(|s| !s.is_empty())
1423 .map(|s| s.to_string())
1424 .collect()
1425 },
1426 Value::Array(arr) => arr
1427 .into_iter()
1428 .flat_map(|v| value_to_string_vec(v).into_iter())
1429 .collect(),
1430 Value::Object(map) => map
1431 .into_iter()
1432 .flat_map(|(k, v)| {
1433 let values = value_to_string_vec(v);
1434 if values.is_empty() {
1435 vec![k]
1436 } else {
1437 values
1438 .into_iter()
1439 .map(|val| format!("{k}: {val}"))
1440 .collect()
1441 }
1442 })
1443 .collect(),
1444 other => vec![other.to_string()],
1445 }
1446}
1447
1448fn deserialize_optional_scope<'de, D>(
1449 deserializer: D,
1450) -> std::result::Result<Option<Scope>, D::Error>
1451where
1452 D: serde::Deserializer<'de>,
1453{
1454 let value = Option::<String>::deserialize(deserializer)?;
1455 Ok(coerce_optional_scope(value.as_deref()))
1456}
1457
1458pub(crate) fn coerce_optional_scope(raw: Option<&str>) -> Option<Scope> {
1459 match raw {
1460 None => None,
1461 Some(scope_str) => {
1462 let trimmed = scope_str.trim();
1463 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
1464 None
1465 } else {
1466 coerce_scope(trimmed)
1467 }
1468 },
1469 }
1470}
1471
1472fn coerce_scope(raw: &str) -> Option<Scope> {
1473 let normalized = raw.trim().replace('\\', "/").to_lowercase();
1474
1475 let segments: Vec<String> = normalized
1476 .split('/')
1477 .filter_map(sanitize_scope_segment)
1478 .take(2)
1479 .collect();
1480
1481 if segments.is_empty() {
1482 return None;
1483 }
1484
1485 Scope::new(segments.join("/")).ok()
1486}
1487
1488fn sanitize_scope_segment(segment: &str) -> Option<String> {
1489 let mut out = String::new();
1490 let mut last_was_separator = false;
1491
1492 for ch in segment.trim().chars() {
1493 if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
1494 out.push(ch);
1495 last_was_separator = false;
1496 } else if ch == '-' || ch == '_' {
1497 if !out.is_empty() && !last_was_separator {
1498 out.push(ch);
1499 last_was_separator = true;
1500 }
1501 } else if (ch.is_ascii_whitespace() || ch == '.') && !out.is_empty() && !last_was_separator {
1502 out.push('-');
1503 last_was_separator = true;
1504 }
1505 }
1506
1507 let trimmed = out.trim_matches(['-', '_']).to_string();
1508 (!trimmed.is_empty()).then_some(trimmed)
1509}
1510
1511#[cfg(test)]
1512mod tests {
1513 use super::*;
1514
1515 #[test]
1518 fn test_resolve_model_name() {
1519 assert_eq!(resolve_model_name("sonnet"), "claude-sonnet-4.5");
1521 assert_eq!(resolve_model_name("s"), "claude-sonnet-4.5");
1522 assert_eq!(resolve_model_name("opus"), "claude-opus-4.5");
1523 assert_eq!(resolve_model_name("o"), "claude-opus-4.5");
1524 assert_eq!(resolve_model_name("haiku"), "claude-haiku-4-5");
1525 assert_eq!(resolve_model_name("h"), "claude-haiku-4-5");
1526
1527 assert_eq!(resolve_model_name("gpt5"), "gpt-5");
1529 assert_eq!(resolve_model_name("g5"), "gpt-5");
1530
1531 assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
1533 assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
1534
1535 assert_eq!(resolve_model_name("claude-sonnet-4.5"), "claude-sonnet-4.5");
1537 assert_eq!(resolve_model_name("custom-model"), "custom-model");
1538 }
1539
1540 #[test]
1543 fn test_commit_type_valid() {
1544 let valid_types = [
1545 "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
1546 "revert",
1547 ];
1548
1549 for ty in &valid_types {
1550 assert!(CommitType::new(*ty).is_ok(), "Expected '{ty}' to be valid");
1551 }
1552 }
1553
1554 #[test]
1555 fn test_commit_type_case_normalization() {
1556 let ct = CommitType::new("FEAT").expect("FEAT should normalize");
1558 assert_eq!(ct.as_str(), "feat");
1559
1560 let ct = CommitType::new("Fix").expect("Fix should normalize");
1561 assert_eq!(ct.as_str(), "fix");
1562
1563 let ct = CommitType::new("ReFaCtOr").expect("ReFaCtOr should normalize");
1564 assert_eq!(ct.as_str(), "refactor");
1565 }
1566
1567 #[test]
1568 fn test_commit_type_invalid() {
1569 let invalid_types = ["invalid", "bug", "feature", "update", "change", "random", "xyz", "123"];
1570
1571 for ty in &invalid_types {
1572 assert!(CommitType::new(*ty).is_err(), "Expected '{ty}' to be invalid");
1573 }
1574 }
1575
1576 #[test]
1577 fn test_commit_type_empty() {
1578 assert!(CommitType::new("").is_err(), "Empty string should be invalid");
1579 }
1580
1581 #[test]
1582 fn test_commit_type_display() {
1583 let ct = CommitType::new("feat").unwrap();
1584 assert_eq!(format!("{ct}"), "feat");
1585 }
1586
1587 #[test]
1588 fn test_commit_type_len() {
1589 let ct = CommitType::new("feat").unwrap();
1590 assert_eq!(ct.len(), 4);
1591
1592 let ct = CommitType::new("refactor").unwrap();
1593 assert_eq!(ct.len(), 8);
1594 }
1595
1596 #[test]
1599 fn test_scope_valid_single_segment() {
1600 let valid_scopes = ["core", "api", "lib", "client", "server", "ui", "test-123", "foo_bar"];
1601
1602 for scope in &valid_scopes {
1603 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1604 }
1605 }
1606
1607 #[test]
1608 fn test_scope_valid_two_segments() {
1609 let valid_scopes = ["api/client", "lib/core", "ui/components", "test-1/foo_2"];
1610
1611 for scope in &valid_scopes {
1612 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1613 }
1614 }
1615
1616 #[test]
1617 fn test_scope_invalid_three_segments() {
1618 let scope = Scope::new("a/b/c");
1619 assert!(scope.is_err(), "Three segments should be invalid");
1620
1621 if let Err(CommitGenError::InvalidScope(msg)) = scope {
1622 assert!(msg.contains("3 segments"));
1623 } else {
1624 panic!("Expected InvalidScope error");
1625 }
1626 }
1627
1628 #[test]
1629 fn test_scope_invalid_uppercase() {
1630 let invalid_scopes = ["Core", "API", "MyScope", "api/Client"];
1631
1632 for scope in &invalid_scopes {
1633 assert!(Scope::new(*scope).is_err(), "Expected '{scope}' with uppercase to be invalid");
1634 }
1635 }
1636
1637 #[test]
1638 fn test_scope_invalid_empty_segments() {
1639 let invalid_scopes = ["", "a//b", "/foo", "bar/"];
1640
1641 for scope in &invalid_scopes {
1642 assert!(
1643 Scope::new(*scope).is_err(),
1644 "Expected '{scope}' with empty segments to be invalid"
1645 );
1646 }
1647 }
1648
1649 #[test]
1650 fn test_scope_invalid_chars() {
1651 let invalid_scopes = ["a b", "foo bar", "test@scope", "api/client!", "a.b"];
1652
1653 for scope in &invalid_scopes {
1654 assert!(
1655 Scope::new(*scope).is_err(),
1656 "Expected '{scope}' with invalid chars to be invalid"
1657 );
1658 }
1659 }
1660
1661 #[test]
1662 fn test_scope_segments() {
1663 let scope = Scope::new("core").unwrap();
1664 assert_eq!(scope.segments(), vec!["core"]);
1665
1666 let scope = Scope::new("api/client").unwrap();
1667 assert_eq!(scope.segments(), vec!["api", "client"]);
1668 }
1669
1670 #[test]
1671 fn test_scope_display() {
1672 let scope = Scope::new("api/client").unwrap();
1673 assert_eq!(format!("{scope}"), "api/client");
1674 }
1675
1676 #[test]
1679 fn test_commit_summary_valid() {
1680 let summary_72 = "a".repeat(72);
1681 let summary_96 = "a".repeat(96);
1682 let summary_128 = "a".repeat(128);
1683 let valid_summaries = [
1684 "added new feature",
1685 "fixed bug in authentication",
1686 "x", summary_72.as_str(), summary_96.as_str(), summary_128.as_str(), ];
1691
1692 for summary in &valid_summaries {
1693 assert!(
1694 CommitSummary::new(*summary, 128).is_ok(),
1695 "Expected '{}' (len={}) to be valid",
1696 if summary.len() > 50 {
1697 &summary[..50]
1698 } else {
1699 summary
1700 },
1701 summary.len()
1702 );
1703 }
1704 }
1705
1706 #[test]
1707 fn test_commit_summary_too_long() {
1708 let long_summary = "a".repeat(129); let result = CommitSummary::new(long_summary, 128);
1710 assert!(result.is_err(), "129 char summary should be invalid");
1711
1712 if let Err(CommitGenError::SummaryTooLong { len, max }) = result {
1713 assert_eq!(len, 129);
1714 assert_eq!(max, 128);
1715 } else {
1716 panic!("Expected SummaryTooLong error");
1717 }
1718 }
1719
1720 #[test]
1721 fn test_commit_summary_empty() {
1722 let empty_cases = ["", " ", "\t", "\n"];
1723
1724 for empty in &empty_cases {
1725 assert!(
1726 CommitSummary::new(*empty, 128).is_err(),
1727 "Empty/whitespace-only summary should be invalid"
1728 );
1729 }
1730 }
1731
1732 #[test]
1733 fn test_commit_summary_warnings_uppercase_start() {
1734 let result = CommitSummary::new("Added new feature", 128);
1736 assert!(result.is_ok(), "Should succeed despite uppercase start");
1737 }
1738
1739 #[test]
1740 fn test_commit_summary_warnings_with_period() {
1741 let result = CommitSummary::new("added new feature.", 128);
1743 assert!(result.is_ok(), "Should succeed despite having period");
1744 }
1745
1746 #[test]
1747 fn test_commit_summary_new_unchecked() {
1748 let result = CommitSummary::new_unchecked("Added feature", 128);
1750 assert!(result.is_ok(), "new_unchecked should succeed");
1751 }
1752
1753 #[test]
1754 fn test_commit_summary_len() {
1755 let summary = CommitSummary::new("hello world", 128).unwrap();
1756 assert_eq!(summary.len(), 11);
1757 }
1758
1759 #[test]
1760 fn test_commit_summary_display() {
1761 let summary = CommitSummary::new("fixed bug", 128).unwrap();
1762 assert_eq!(format!("{summary}"), "fixed bug");
1763 }
1764
1765 #[test]
1768 fn test_commit_type_serialize() {
1769 let ct = CommitType::new("feat").unwrap();
1770 let json = serde_json::to_string(&ct).unwrap();
1771 assert_eq!(json, "\"feat\"");
1772 }
1773
1774 #[test]
1775 fn test_commit_type_deserialize() {
1776 let ct: CommitType = serde_json::from_str("\"fix\"").unwrap();
1777 assert_eq!(ct.as_str(), "fix");
1778
1779 let result: serde_json::Result<CommitType> = serde_json::from_str("\"invalid\"");
1781 assert!(result.is_err());
1782 }
1783
1784 #[test]
1785 fn test_scope_serialize() {
1786 let scope = Scope::new("api/client").unwrap();
1787 let json = serde_json::to_string(&scope).unwrap();
1788 assert_eq!(json, "\"api/client\"");
1789 }
1790
1791 #[test]
1792 fn test_scope_deserialize() {
1793 let scope: Scope = serde_json::from_str("\"core\"").unwrap();
1794 assert_eq!(scope.as_str(), "core");
1795
1796 let result: serde_json::Result<Scope> = serde_json::from_str("\"INVALID\"");
1798 assert!(result.is_err());
1799 }
1800
1801 #[test]
1802 fn test_commit_summary_serialize() {
1803 let summary = CommitSummary::new("fixed bug", 128).unwrap();
1804 let json = serde_json::to_string(&summary).unwrap();
1805 assert_eq!(json, "\"fixed bug\"");
1806 }
1807
1808 #[test]
1809 fn test_details_array_parsing() {
1810 let test_cases = [
1812 r#"{"type":"feat","details":[{"text":"item1"},{"text":"item2"}],"issue_refs":[]}"#,
1814 r#"{"type":"feat","details":["item1","item2"],"issue_refs":[]}"#,
1816 ];
1817
1818 for (idx, json) in test_cases.iter().enumerate() {
1819 let result: serde_json::Result<ConventionalAnalysis> = serde_json::from_str(json);
1820 match result {
1821 Ok(analysis) => {
1822 let body_texts = analysis.body_texts();
1823 assert_eq!(
1824 body_texts.len(),
1825 2,
1826 "Case {idx}: Expected 2 body items, got {}",
1827 body_texts.len()
1828 );
1829 assert_eq!(body_texts[0], "item1", "Case {idx}: First item mismatch");
1830 assert_eq!(body_texts[1], "item2", "Case {idx}: Second item mismatch");
1831 },
1832 Err(e) => {
1833 panic!("Case {idx}: Failed to parse: {e}");
1834 },
1835 }
1836 }
1837 }
1838
1839 #[test]
1840 fn test_conventional_analysis_summary_roundtrip() {
1841 let json = r##"{
1842 "type": "feat",
1843 "scope": "api",
1844 "summary": "added holistic commit titles",
1845 "details": [{"text": "Added summary generation to holistic analysis.", "user_visible": false}],
1846 "issue_refs": ["#123"]
1847 }"##;
1848
1849 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1850 assert_eq!(analysis.summary.as_deref(), Some("added holistic commit titles"));
1851
1852 let serialized = serde_json::to_value(&analysis).unwrap();
1853 assert_eq!(serialized["summary"], "added holistic commit titles");
1854 }
1855
1856 #[test]
1857 fn test_analysis_detail_with_changelog() {
1858 let json = r#"{
1860 "type": "feat",
1861 "details": [
1862 {"text": "Added new API endpoint", "changelog_category": "Added", "user_visible": true},
1863 {"text": "Refactored internal code", "user_visible": false}
1864 ],
1865 "issue_refs": []
1866 }"#;
1867
1868 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1869 assert_eq!(analysis.details.len(), 2);
1870 assert_eq!(analysis.details[0].text, "Added new API endpoint");
1871 assert_eq!(analysis.details[0].changelog_category, Some(ChangelogCategory::Added));
1872 assert!(analysis.details[0].user_visible);
1873 assert!(!analysis.details[1].user_visible);
1874
1875 let entries = analysis.changelog_entries();
1877 assert_eq!(entries.len(), 1);
1878 assert!(entries.contains_key(&ChangelogCategory::Added));
1879 }
1880
1881 #[test]
1882 fn test_commit_summary_deserialize() {
1883 let summary: CommitSummary = serde_json::from_str("\"added feature\"").unwrap();
1884 assert_eq!(summary.as_str(), "added feature");
1885
1886 let long = format!("\"{}\"", "a".repeat(129));
1888 let result: serde_json::Result<CommitSummary> = serde_json::from_str(&long);
1889 assert!(result.is_err());
1890
1891 let result: serde_json::Result<CommitSummary> = serde_json::from_str("\"\"");
1893 assert!(result.is_err());
1894 }
1895
1896 #[test]
1897 fn test_conventional_commit_roundtrip() {
1898 let commit = ConventionalCommit {
1899 commit_type: CommitType::new("feat").unwrap(),
1900 scope: Some(Scope::new("api").unwrap()),
1901 summary: CommitSummary::new_unchecked("added endpoint", 128).unwrap(),
1902 body: vec!["detail 1.".to_string(), "detail 2.".to_string()],
1903 footers: vec!["Fixes: #123".to_string()],
1904 };
1905
1906 let json = serde_json::to_string(&commit).unwrap();
1907 let deserialized: ConventionalCommit = serde_json::from_str(&json).unwrap();
1908
1909 assert_eq!(deserialized.commit_type.as_str(), "feat");
1910 assert_eq!(deserialized.scope.unwrap().as_str(), "api");
1911 assert_eq!(deserialized.summary.as_str(), "added endpoint");
1912 assert_eq!(deserialized.body.len(), 2);
1913 assert_eq!(deserialized.footers.len(), 1);
1914 }
1915
1916 #[test]
1917 fn test_scope_null_string_deserializes_to_none() {
1918 let test_cases = [
1920 r#"{"type":"feat","scope":"null","body":[],"issue_refs":[]}"#,
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 ];
1925
1926 for (idx, json) in test_cases.iter().enumerate() {
1927 let analysis: ConventionalAnalysis = serde_json::from_str(json)
1928 .unwrap_or_else(|e| panic!("Case {idx} failed to deserialize: {e}"));
1929 assert!(
1930 analysis.scope.is_none(),
1931 "Case {idx}: Expected scope to be None, got {:?}",
1932 analysis.scope
1933 );
1934 }
1935 }
1936
1937 #[test]
1938 fn test_scope_invalid_model_output_is_coerced() {
1939 let json = r#"{"type":"chore","scope":".github","details":[],"issue_refs":[]}"#;
1940 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1941 assert_eq!(analysis.scope.as_ref().map(Scope::as_str), Some("github"));
1942 }
1943
1944 #[test]
1945 fn test_scope_path_like_model_output_is_coerced() {
1946 let json = r#"{"type":"chore","scope":"docs//Release Notes","details":[],"issue_refs":[]}"#;
1947 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1948 assert_eq!(analysis.scope.as_ref().map(Scope::as_str), Some("docs/release-notes"));
1949 }
1950
1951 #[test]
1954 fn test_body_array_with_newline_in_string() {
1955 let raw_str = "[\"Item 1\", \"Item\n2\"]";
1959 let value = serde_json::Value::String(raw_str.to_string());
1960
1961 let result = value_to_string_vec(value);
1963
1964 assert_eq!(result.len(), 2);
1966 assert_eq!(result[0], "Item 1");
1967 assert_eq!(result[1], "Item 2");
1970 }
1971
1972 #[test]
1973 fn test_body_array_malformed_truncated() {
1974 let raw_str = "[\"Refactored finance...\", \"Added automatic detection...\".";
1977 let value = serde_json::Value::String(raw_str.to_string());
1978
1979 let result = value_to_string_vec(value);
1980
1981 assert_eq!(result.len(), 2);
1983 assert_eq!(result[0], "Refactored finance...");
1984 assert_eq!(result[1], "Added automatic detection...");
1985 }
1986
1987 #[test]
1988 fn test_hunk_selector_deserialize_all() {
1989 let json = r#""ALL""#;
1990 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1991 assert!(matches!(selector, HunkSelector::All));
1992 }
1993
1994 #[test]
1995 fn test_hunk_selector_deserialize_lines_object() {
1996 let json = r#"{"start": 10, "end": 20}"#;
1997 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1998 match selector {
1999 HunkSelector::Lines { start, end } => {
2000 assert_eq!(start, 10);
2001 assert_eq!(end, 20);
2002 },
2003 _ => panic!("Expected Lines variant"),
2004 }
2005 }
2006
2007 #[test]
2008 fn test_hunk_selector_deserialize_lines_string() {
2009 let json = r#""10-20""#;
2010 let selector: HunkSelector = serde_json::from_str(json).unwrap();
2011 match selector {
2012 HunkSelector::Lines { start, end } => {
2013 assert_eq!(start, 10);
2014 assert_eq!(end, 20);
2015 },
2016 _ => panic!("Expected Lines variant"),
2017 }
2018 }
2019
2020 #[test]
2021 fn test_hunk_selector_deserialize_search_pattern() {
2022 let json = r#"{"pattern": "fn main"}"#;
2023 let selector: HunkSelector = serde_json::from_str(json).unwrap();
2024 match selector {
2025 HunkSelector::Search { pattern } => {
2026 assert_eq!(pattern, "fn main");
2027 },
2028 _ => panic!("Expected Search variant"),
2029 }
2030 }
2031
2032 #[test]
2033 fn test_hunk_selector_deserialize_old_format_hunk_header() {
2034 let json = r#""@@ -10,5 +10,7 @@""#;
2036 let selector: HunkSelector = serde_json::from_str(json).unwrap();
2037 match selector {
2038 HunkSelector::Search { pattern } => {
2039 assert_eq!(pattern, "@@ -10,5 +10,7 @@");
2040 },
2041 _ => panic!("Expected Search variant for old hunk header format"),
2042 }
2043 }
2044
2045 #[test]
2046 fn test_hunk_selector_serialize_all() {
2047 let selector = HunkSelector::All;
2048 let json = serde_json::to_string(&selector).unwrap();
2049 assert_eq!(json, r#""ALL""#);
2050 }
2051
2052 #[test]
2053 fn test_hunk_selector_serialize_lines() {
2054 let selector = HunkSelector::Lines { start: 10, end: 20 };
2055 let json = serde_json::to_value(&selector).unwrap();
2056 assert_eq!(json["start"], 10);
2057 assert_eq!(json["end"], 20);
2058 }
2059
2060 #[test]
2061 fn test_file_change_deserialize_with_all() {
2062 let json = r#"{"path": "src/main.rs", "hunks": ["ALL"]}"#;
2063 let change: FileChange = serde_json::from_str(json).unwrap();
2064 assert_eq!(change.path, "src/main.rs");
2065 assert_eq!(change.hunks.len(), 1);
2066 assert!(matches!(change.hunks[0], HunkSelector::All));
2067 }
2068
2069 #[test]
2070 fn test_file_change_deserialize_with_line_ranges() {
2071 let json = r#"{"path": "src/main.rs", "hunks": [{"start": 10, "end": 20}, {"start": 50, "end": 60}]}"#;
2072 let change: FileChange = serde_json::from_str(json).unwrap();
2073 assert_eq!(change.path, "src/main.rs");
2074 assert_eq!(change.hunks.len(), 2);
2075
2076 match &change.hunks[0] {
2077 HunkSelector::Lines { start, end } => {
2078 assert_eq!(*start, 10);
2079 assert_eq!(*end, 20);
2080 },
2081 _ => panic!("Expected Lines variant"),
2082 }
2083
2084 match &change.hunks[1] {
2085 HunkSelector::Lines { start, end } => {
2086 assert_eq!(*start, 50);
2087 assert_eq!(*end, 60);
2088 },
2089 _ => panic!("Expected Lines variant"),
2090 }
2091 }
2092
2093 #[test]
2094 fn test_file_change_deserialize_mixed_formats() {
2095 let json = r#"{"path": "src/main.rs", "hunks": ["10-20", {"start": 50, "end": 60}]}"#;
2097 let change: FileChange = serde_json::from_str(json).unwrap();
2098 assert_eq!(change.hunks.len(), 2);
2099
2100 match &change.hunks[0] {
2101 HunkSelector::Lines { start, end } => {
2102 assert_eq!(*start, 10);
2103 assert_eq!(*end, 20);
2104 },
2105 _ => panic!("Expected Lines variant"),
2106 }
2107
2108 match &change.hunks[1] {
2109 HunkSelector::Lines { start, end } => {
2110 assert_eq!(*start, 50);
2111 assert_eq!(*end, 60);
2112 },
2113 _ => panic!("Expected Lines variant"),
2114 }
2115 }
2116}