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