1use std::{collections::HashMap, fmt, path::PathBuf};
2
3use clap::{Parser, ValueEnum};
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::error::{CommitGenError, Result};
9
10#[derive(Debug, Clone, Default, Deserialize, Serialize)]
14pub struct TypeConfig {
15 pub description: String,
17
18 #[serde(default)]
20 pub diff_indicators: Vec<String>,
21
22 #[serde(default)]
24 pub file_patterns: Vec<String>,
25
26 #[serde(default)]
28 pub examples: Vec<String>,
29
30 #[serde(default)]
32 pub hint: String,
33}
34
35#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37pub struct CategoryMatch {
38 #[serde(default)]
40 pub types: Vec<String>,
41 #[serde(default)]
43 pub body_contains: Vec<String>,
44}
45
46#[derive(Debug, Clone, Deserialize, Serialize)]
48pub struct CategoryConfig {
49 pub name: String,
51 #[serde(default)]
53 pub header: Option<String>,
54 #[serde(default)]
56 pub r#match: CategoryMatch,
57 #[serde(default)]
59 pub default: bool,
60}
61
62impl CategoryConfig {
63 pub fn header(&self) -> &str {
65 self.header.as_deref().unwrap_or(&self.name)
66 }
67}
68
69pub fn default_types() -> IndexMap<String, TypeConfig> {
72 IndexMap::from([
73 ("feat".to_string(), TypeConfig {
74 description: "New public API surface OR user-observable capability/behavior change"
75 .to_string(),
76 diff_indicators: vec![
77 "pub fn".to_string(),
78 "pub struct".to_string(),
79 "pub enum".to_string(),
80 "export function".to_string(),
81 "#[arg]".to_string(),
82 ],
83 file_patterns: vec![],
84 examples: vec![
85 "Added pub fn process_batch() → feat (new API)".to_string(),
86 "Migrated HTTP client to async → feat (behavior change)".to_string(),
87 ],
88 ..Default::default()
89 }),
90 ("fix".to_string(), TypeConfig {
91 description: "Fixes incorrect behavior (bugs, crashes, wrong outputs, race conditions)"
92 .to_string(),
93 diff_indicators: vec![
94 "unwrap() → ?".to_string(),
95 "bounds check".to_string(),
96 "off-by-one".to_string(),
97 "error handling".to_string(),
98 ],
99 ..Default::default()
100 }),
101 ("refactor".to_string(), TypeConfig {
102 description: "Internal restructuring with provably unchanged behavior".to_string(),
103 diff_indicators: vec![
104 "rename".to_string(),
105 "extract".to_string(),
106 "consolidate".to_string(),
107 "reorganize".to_string(),
108 ],
109 examples: vec!["Renamed internal module structure → refactor (no API change)".to_string()],
110 hint: "Requires proof: same tests pass, same API. If behavior changes, use feat."
111 .to_string(),
112 ..Default::default()
113 }),
114 ("docs".to_string(), TypeConfig {
115 description: "Documentation only changes".to_string(),
116 file_patterns: vec!["*.md".to_string(), "doc comments".to_string()],
117 ..Default::default()
118 }),
119 ("test".to_string(), TypeConfig {
120 description: "Adding or modifying tests".to_string(),
121 file_patterns: vec![
122 "*_test.rs".to_string(),
123 "tests/".to_string(),
124 "*.test.ts".to_string(),
125 ],
126 ..Default::default()
127 }),
128 ("chore".to_string(), TypeConfig {
129 description: "Maintenance tasks, dependencies, tooling".to_string(),
130 file_patterns: vec![
131 ".gitignore".to_string(),
132 "*.lock".to_string(),
133 "config files".to_string(),
134 ],
135 ..Default::default()
136 }),
137 ("style".to_string(), TypeConfig {
138 description: "Formatting, whitespace changes (no logic change)".to_string(),
139 diff_indicators: vec!["whitespace".to_string(), "formatting".to_string()],
140 hint: "Variable/function renames are refactor, not style.".to_string(),
141 ..Default::default()
142 }),
143 ("perf".to_string(), TypeConfig {
144 description: "Performance improvements (proven faster)".to_string(),
145 diff_indicators: vec![
146 "optimization".to_string(),
147 "cache".to_string(),
148 "batch".to_string(),
149 ],
150 ..Default::default()
151 }),
152 ("build".to_string(), TypeConfig {
153 description: "Build system, dependency changes".to_string(),
154 file_patterns: vec![
155 "Cargo.toml".to_string(),
156 "package.json".to_string(),
157 "Makefile".to_string(),
158 ],
159 ..Default::default()
160 }),
161 ("ci".to_string(), TypeConfig {
162 description: "CI/CD configuration".to_string(),
163 file_patterns: vec![".github/workflows/".to_string(), ".gitlab-ci.yml".to_string()],
164 ..Default::default()
165 }),
166 ("revert".to_string(), TypeConfig {
167 description: "Reverts a previous commit".to_string(),
168 diff_indicators: vec!["Revert".to_string()],
169 ..Default::default()
170 }),
171 ])
172}
173
174pub fn default_classifier_hint() -> String {
176 r"CRITICAL - feat vs refactor:
177- feat: ANY observable behavior change OR new public API
178- refactor: ONLY when provably unchanged (same tests, same API)
179When in doubt, prefer feat over refactor."
180 .to_string()
181}
182
183pub fn default_categories() -> Vec<CategoryConfig> {
186 vec![
187 CategoryConfig {
188 name: "Breaking".to_string(),
189 header: Some("Breaking Changes".to_string()),
190 r#match: CategoryMatch {
191 types: vec![],
192 body_contains: vec!["breaking".to_string(), "incompatible".to_string()],
193 },
194 default: false,
195 },
196 CategoryConfig {
197 name: "Added".to_string(),
198 header: None,
199 r#match: CategoryMatch { types: vec!["feat".to_string()], body_contains: vec![] },
200 default: false,
201 },
202 CategoryConfig {
203 name: "Changed".to_string(),
204 header: None,
205 r#match: CategoryMatch::default(),
206 default: true,
207 },
208 CategoryConfig {
209 name: "Deprecated".to_string(),
210 header: None,
211 r#match: CategoryMatch::default(),
212 default: false,
213 },
214 CategoryConfig {
215 name: "Removed".to_string(),
216 header: None,
217 r#match: CategoryMatch {
218 types: vec!["revert".to_string()],
219 body_contains: vec![],
220 },
221 default: false,
222 },
223 CategoryConfig {
224 name: "Fixed".to_string(),
225 header: None,
226 r#match: CategoryMatch { types: vec!["fix".to_string()], body_contains: vec![] },
227 default: false,
228 },
229 CategoryConfig {
230 name: "Security".to_string(),
231 header: None,
232 r#match: CategoryMatch::default(),
233 default: false,
234 },
235 ]
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
242pub enum ChangelogCategory {
243 Added,
244 Changed,
245 Fixed,
246 Deprecated,
247 Removed,
248 Security,
249 Breaking,
250}
251
252impl ChangelogCategory {
253 pub const fn as_str(&self) -> &'static str {
255 match self {
256 Self::Added => "Added",
257 Self::Changed => "Changed",
258 Self::Fixed => "Fixed",
259 Self::Deprecated => "Deprecated",
260 Self::Removed => "Removed",
261 Self::Security => "Security",
262 Self::Breaking => "Breaking Changes",
263 }
264 }
265
266 #[must_use]
269 pub fn from_name(name: &str) -> Self {
270 match name.to_lowercase().as_str() {
271 "added" => Self::Added,
272 "changed" => Self::Changed,
273 "fixed" => Self::Fixed,
274 "deprecated" => Self::Deprecated,
275 "removed" => Self::Removed,
276 "security" => Self::Security,
277 "breaking" | "breaking changes" => Self::Breaking,
278 _ => Self::Changed,
279 }
280 }
281
282 pub fn from_commit_type(commit_type: &str, body: &[String]) -> Self {
285 let has_breaking = body.iter().any(|s| {
287 let lower = s.to_lowercase();
288 lower.contains("breaking") || lower.contains("incompatible")
289 });
290
291 if has_breaking {
292 return Self::Breaking;
293 }
294
295 match commit_type {
296 "feat" => Self::Added,
297 "fix" => Self::Fixed,
298 "revert" => Self::Removed,
299 _ => Self::Changed,
301 }
302 }
303
304 pub const fn render_order() -> &'static [Self] {
306 &[
307 Self::Breaking,
308 Self::Added,
309 Self::Changed,
310 Self::Deprecated,
311 Self::Removed,
312 Self::Fixed,
313 Self::Security,
314 ]
315 }
316}
317
318#[derive(Debug, Clone)]
320pub struct ChangelogBoundary {
321 pub changelog_path: PathBuf,
323 pub files: Vec<String>,
325 pub diff: String,
327 pub stat: String,
329}
330
331#[derive(Debug, Clone, Default)]
333pub struct UnreleasedSection {
334 pub header_line: usize,
336 pub end_line: usize,
338 pub entries: HashMap<ChangelogCategory, Vec<String>>,
340}
341
342#[derive(Debug, Clone, ValueEnum)]
343pub enum Mode {
344 Staged,
346 Commit,
348 Unstaged,
350 Compose,
352}
353
354pub fn resolve_model_name(name: &str) -> String {
356 match name {
357 "sonnet" | "s" => "claude-sonnet-4.5",
359 "opus" | "o" | "o4.5" => "claude-opus-4.5",
360 "haiku" | "h" => "claude-haiku-4-5",
361 "3.5" | "sonnet-3.5" => "claude-3.5-sonnet",
362 "3.7" | "sonnet-3.7" => "claude-3.7-sonnet",
363
364 "gpt5" | "g5" => "gpt-5",
366 "gpt5-pro" => "gpt-5-pro",
367 "gpt5-mini" => "gpt-5-mini",
368 "gpt5-codex" => "gpt-5-codex",
369
370 "o3" => "o3",
372 "o3-pro" => "o3-pro",
373 "o3-mini" => "o3-mini",
374 "o1" => "o1",
375 "o1-pro" => "o1-pro",
376 "o1-mini" => "o1-mini",
377
378 "gemini" | "g2.5" => "gemini-2.5-pro",
380 "flash" | "g2.5-flash" => "gemini-2.5-flash",
381 "flash-lite" => "gemini-2.5-flash-lite",
382
383 "qwen" | "q480b" => "qwen-3-coder-480b",
385
386 "glm4.6" => "glm-4.6",
388 "glm4.5" => "glm-4.5",
389 "glm-air" => "glm-4.5-air",
390
391 _ => name,
393 }
394 .to_string()
395}
396
397#[derive(Debug, Clone)]
399pub struct ScopeCandidate {
400 pub path: String,
401 pub percentage: f32,
402 pub confidence: f32,
403}
404
405#[derive(Clone, PartialEq, Eq)]
407pub struct CommitType(String);
408
409impl CommitType {
410 const VALID_TYPES: &'static [&'static str] = &[
411 "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert",
412 ];
413
414 pub fn new(s: impl Into<String>) -> Result<Self> {
416 let s = s.into();
417 let normalized = s.to_lowercase();
418
419 if !Self::VALID_TYPES.contains(&normalized.as_str()) {
420 return Err(CommitGenError::InvalidCommitType(format!(
421 "Invalid commit type '{}'. Must be one of: {}",
422 s,
423 Self::VALID_TYPES.join(", ")
424 )));
425 }
426
427 Ok(Self(normalized))
428 }
429
430 pub fn as_str(&self) -> &str {
432 &self.0
433 }
434
435 pub const fn len(&self) -> usize {
437 self.0.len()
438 }
439
440 #[allow(dead_code, reason = "Convenience method for future use")]
442 pub const fn is_empty(&self) -> bool {
443 self.0.is_empty()
444 }
445}
446
447impl fmt::Display for CommitType {
448 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449 write!(f, "{}", self.0)
450 }
451}
452
453impl fmt::Debug for CommitType {
454 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455 f.debug_tuple("CommitType").field(&self.0).finish()
456 }
457}
458
459impl Serialize for CommitType {
460 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
461 where
462 S: serde::Serializer,
463 {
464 self.0.serialize(serializer)
465 }
466}
467
468impl<'de> Deserialize<'de> for CommitType {
469 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
470 where
471 D: serde::Deserializer<'de>,
472 {
473 let s = String::deserialize(deserializer)?;
474 Self::new(s).map_err(serde::de::Error::custom)
475 }
476}
477
478#[derive(Clone)]
480pub struct CommitSummary(String);
481
482impl CommitSummary {
483 pub fn new(s: impl Into<String>, max_len: usize) -> Result<Self> {
486 Self::new_impl(s, max_len, true)
487 }
488
489 pub(crate) fn new_unchecked(s: impl Into<String>, max_len: usize) -> Result<Self> {
492 Self::new_impl(s, max_len, false)
493 }
494
495 fn new_impl(s: impl Into<String>, max_len: usize, emit_warnings: bool) -> Result<Self> {
496 let s = s.into();
497
498 if s.trim().is_empty() {
500 return Err(CommitGenError::ValidationError("commit summary cannot be empty".to_string()));
501 }
502
503 if s.len() > max_len {
505 return Err(CommitGenError::SummaryTooLong { len: s.len(), max: max_len });
506 }
507
508 if emit_warnings {
509 if let Some(first_char) = s.chars().next()
511 && first_char.is_uppercase()
512 {
513 crate::style::warn(&format!("commit summary should start with lowercase: {s}"));
514 }
515
516 if s.trim_end().ends_with('.') {
518 crate::style::warn(&format!(
519 "commit summary should NOT end with period (conventional commits style): {s}"
520 ));
521 }
522 }
523
524 Ok(Self(s))
525 }
526
527 pub fn as_str(&self) -> &str {
529 &self.0
530 }
531
532 pub const fn len(&self) -> usize {
534 self.0.len()
535 }
536
537 #[allow(dead_code, reason = "Convenience method for future use")]
539 pub const fn is_empty(&self) -> bool {
540 self.0.is_empty()
541 }
542}
543
544impl fmt::Display for CommitSummary {
545 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546 write!(f, "{}", self.0)
547 }
548}
549
550impl fmt::Debug for CommitSummary {
551 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
552 f.debug_tuple("CommitSummary").field(&self.0).finish()
553 }
554}
555
556impl Serialize for CommitSummary {
557 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
558 where
559 S: serde::Serializer,
560 {
561 self.0.serialize(serializer)
562 }
563}
564
565impl<'de> Deserialize<'de> for CommitSummary {
566 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
567 where
568 D: serde::Deserializer<'de>,
569 {
570 let s = String::deserialize(deserializer)?;
571 if s.trim().is_empty() {
573 return Err(serde::de::Error::custom("commit summary cannot be empty"));
574 }
575 if s.len() > 128 {
576 return Err(serde::de::Error::custom(format!(
577 "commit summary must be ≤128 characters, got {}",
578 s.len()
579 )));
580 }
581 Ok(Self(s))
582 }
583}
584
585#[derive(Clone, PartialEq, Eq)]
587pub struct Scope(String);
588
589impl Scope {
590 pub fn new(s: impl Into<String>) -> Result<Self> {
597 let s = s.into();
598 let segments: Vec<&str> = s.split('/').collect();
599
600 if segments.len() > 2 {
601 return Err(CommitGenError::InvalidScope(format!(
602 "scope has {} segments, max 2 allowed",
603 segments.len()
604 )));
605 }
606
607 for segment in &segments {
608 if segment.is_empty() {
609 return Err(CommitGenError::InvalidScope("scope contains empty segment".to_string()));
610 }
611 if !segment
612 .chars()
613 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
614 {
615 return Err(CommitGenError::InvalidScope(format!(
616 "invalid characters in scope segment: {segment}"
617 )));
618 }
619 }
620
621 Ok(Self(s))
622 }
623
624 pub fn as_str(&self) -> &str {
626 &self.0
627 }
628
629 #[allow(dead_code, reason = "Public API method for scope manipulation")]
631 pub fn segments(&self) -> Vec<&str> {
632 self.0.split('/').collect()
633 }
634
635 pub const fn is_empty(&self) -> bool {
637 self.0.is_empty()
638 }
639}
640
641impl fmt::Display for Scope {
642 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
643 write!(f, "{}", self.0)
644 }
645}
646
647impl fmt::Debug for Scope {
648 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
649 f.debug_tuple("Scope").field(&self.0).finish()
650 }
651}
652
653impl Serialize for Scope {
654 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
655 where
656 S: serde::Serializer,
657 {
658 serializer.serialize_str(&self.0)
659 }
660}
661
662impl<'de> Deserialize<'de> for Scope {
663 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
664 where
665 D: serde::Deserializer<'de>,
666 {
667 let s = String::deserialize(deserializer)?;
668 Self::new(s).map_err(serde::de::Error::custom)
669 }
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
673pub struct ConventionalCommit {
674 pub commit_type: CommitType,
675 pub scope: Option<Scope>,
676 pub summary: CommitSummary,
677 pub body: Vec<String>,
678 pub footers: Vec<String>,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct AnalysisDetail {
684 pub text: String,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub changelog_category: Option<ChangelogCategory>,
689 #[serde(default)]
691 pub user_visible: bool,
692}
693
694impl AnalysisDetail {
695 pub fn simple(text: impl Into<String>) -> Self {
698 Self { text: text.into(), changelog_category: None, user_visible: false }
699 }
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct ConventionalAnalysis {
704 #[serde(rename = "type")]
705 pub commit_type: CommitType,
706 #[serde(default, deserialize_with = "deserialize_optional_scope")]
707 pub scope: Option<Scope>,
708 #[serde(default, deserialize_with = "deserialize_analysis_details")]
710 pub details: Vec<AnalysisDetail>,
711 #[serde(default, deserialize_with = "deserialize_string_vec")]
712 pub issue_refs: Vec<String>,
713}
714
715impl ConventionalAnalysis {
716 pub fn body_texts(&self) -> Vec<String> {
718 self.details.iter().map(|d| d.text.clone()).collect()
719 }
720
721 pub fn changelog_entries(&self) -> std::collections::HashMap<ChangelogCategory, Vec<String>> {
723 let mut entries = std::collections::HashMap::new();
724 for detail in &self.details {
725 if detail.user_visible
726 && let Some(category) = detail.changelog_category
727 {
728 entries
729 .entry(category)
730 .or_insert_with(Vec::new)
731 .push(detail.text.clone());
732 }
733 }
734 entries
735 }
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
739#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
740pub struct SummaryOutput {
741 pub summary: String,
742}
743
744#[derive(Debug, Clone)]
746pub struct CommitMetadata {
747 pub hash: String,
748 pub author_name: String,
749 pub author_email: String,
750 pub author_date: String,
751 pub committer_name: String,
752 pub committer_email: String,
753 pub committer_date: String,
754 pub message: String,
755 pub parent_hashes: Vec<String>,
756 pub tree_hash: String,
757}
758
759#[derive(Debug, Clone)]
761pub enum HunkSelector {
762 All,
764 Lines { start: usize, end: usize },
766 Search { pattern: String },
768}
769
770impl Serialize for HunkSelector {
771 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
772 where
773 S: serde::Serializer,
774 {
775 match self {
776 Self::All => serializer.serialize_str("ALL"),
777 Self::Lines { start, end } => {
778 use serde::ser::SerializeStruct;
779 let mut state = serializer.serialize_struct("Lines", 2)?;
780 state.serialize_field("start", start)?;
781 state.serialize_field("end", end)?;
782 state.end()
783 },
784 Self::Search { pattern } => {
785 use serde::ser::SerializeStruct;
786 let mut state = serializer.serialize_struct("Search", 1)?;
787 state.serialize_field("pattern", pattern)?;
788 state.end()
789 },
790 }
791 }
792}
793
794impl<'de> Deserialize<'de> for HunkSelector {
795 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
796 where
797 D: serde::Deserializer<'de>,
798 {
799 let value = Value::deserialize(deserializer)?;
800
801 match value {
802 Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(Self::All),
804 Value::String(s) if s.starts_with("@@") => Ok(Self::Search { pattern: s }),
806 Value::String(s) if s.contains('-') => {
808 let parts: Vec<&str> = s.split('-').collect();
809 if parts.len() == 2 {
810 let start = parts[0].trim().parse().map_err(serde::de::Error::custom)?;
811 let end = parts[1].trim().parse().map_err(serde::de::Error::custom)?;
812 Ok(Self::Lines { start, end })
813 } else {
814 Err(serde::de::Error::custom(format!("Invalid line range format: {s}")))
815 }
816 },
817 Value::Object(map) if map.contains_key("start") && map.contains_key("end") => {
819 let start = map
820 .get("start")
821 .and_then(|v| v.as_u64())
822 .ok_or_else(|| serde::de::Error::custom("Invalid start field"))?
823 as usize;
824 let end = map
825 .get("end")
826 .and_then(|v| v.as_u64())
827 .ok_or_else(|| serde::de::Error::custom("Invalid end field"))?
828 as usize;
829 Ok(Self::Lines { start, end })
830 },
831 Value::Object(map) if map.contains_key("pattern") => {
833 let pattern = map
834 .get("pattern")
835 .and_then(|v| v.as_str())
836 .ok_or_else(|| serde::de::Error::custom("Invalid pattern field"))?
837 .to_string();
838 Ok(Self::Search { pattern })
839 },
840 Value::String(s) => Ok(Self::Search { pattern: s }),
842 _ => Err(serde::de::Error::custom("Invalid HunkSelector format")),
843 }
844 }
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize)]
849pub struct FileChange {
850 pub path: String,
851 pub hunks: Vec<HunkSelector>,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize)]
856pub struct ChangeGroup {
857 pub changes: Vec<FileChange>,
858 #[serde(rename = "type")]
859 pub commit_type: CommitType,
860 pub scope: Option<Scope>,
861 pub rationale: String,
862 #[serde(default)]
863 pub dependencies: Vec<usize>,
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize)]
868pub struct ComposeAnalysis {
869 pub groups: Vec<ChangeGroup>,
870 pub dependency_order: Vec<usize>,
871}
872
873#[derive(Debug, Serialize)]
875#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
876pub struct Message {
877 pub role: String,
878 pub content: String,
879}
880
881#[derive(Debug, Clone, Serialize, Deserialize)]
882#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
883pub struct FunctionParameters {
884 #[serde(rename = "type")]
885 pub param_type: String,
886 pub properties: serde_json::Value,
887 pub required: Vec<String>,
888}
889
890#[derive(Debug, Clone, Serialize, Deserialize)]
891#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
892pub struct Function {
893 pub name: String,
894 pub description: String,
895 pub parameters: FunctionParameters,
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize)]
899#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
900pub struct Tool {
901 #[serde(rename = "type")]
902 pub tool_type: String,
903 pub function: Function,
904}
905
906#[derive(Parser, Debug)]
908#[command(author, version, about = "Generate git commit messages using Claude AI", long_about = None)]
909pub struct Args {
910 #[arg(long, value_enum, default_value = "staged")]
912 pub mode: Mode,
913
914 #[arg(long)]
916 pub target: Option<String>,
917
918 #[arg(long)]
920 pub copy: bool,
921
922 #[arg(long)]
924 pub dry_run: bool,
925
926 #[arg(long, short = 'p')]
928 pub push: bool,
929
930 #[arg(long, default_value = ".")]
932 pub dir: String,
933
934 #[arg(long, short = 'm')]
937 pub model: Option<String>,
938
939 #[arg(long, short = 't')]
941 pub temperature: Option<f32>,
942
943 #[arg(long)]
945 pub fixes: Vec<String>,
946
947 #[arg(long)]
949 pub closes: Vec<String>,
950
951 #[arg(long)]
953 pub resolves: Vec<String>,
954
955 #[arg(long)]
957 pub refs: Vec<String>,
958
959 #[arg(long)]
961 pub breaking: bool,
962
963 #[arg(long, short = 'S')]
965 pub sign: bool,
966
967 #[arg(long, short = 's')]
969 pub signoff: bool,
970
971 #[arg(long)]
973 pub amend: bool,
974
975 #[arg(long, short = 'n')]
978 pub skip_hooks: bool,
979
980 #[arg(long)]
982 pub config: Option<PathBuf>,
983
984 #[arg(trailing_var_arg = true)]
987 pub context: Vec<String>,
988
989 #[arg(long, conflicts_with_all = ["target", "copy", "dry_run"])]
992 pub rewrite: bool,
993
994 #[arg(long, requires = "rewrite")]
996 pub rewrite_preview: Option<usize>,
997
998 #[arg(long, requires = "rewrite")]
1000 pub rewrite_start: Option<String>,
1001
1002 #[arg(long, default_value = "10", requires = "rewrite")]
1004 pub rewrite_parallel: usize,
1005
1006 #[arg(long, requires = "rewrite")]
1008 pub rewrite_dry_run: bool,
1009
1010 #[arg(long, requires = "rewrite")]
1012 pub rewrite_hide_old_types: bool,
1013
1014 #[arg(long)]
1017 pub exclude_old_message: bool,
1018
1019 #[arg(long, conflicts_with_all = ["target", "rewrite"])]
1022 pub compose: bool,
1023
1024 #[arg(long, requires = "compose")]
1026 pub compose_preview: bool,
1027
1028 #[arg(long, requires = "compose")]
1030 pub compose_max_commits: Option<usize>,
1031
1032 #[arg(long, requires = "compose")]
1034 pub compose_test_after_each: bool,
1035
1036 #[arg(long)]
1039 pub no_changelog: bool,
1040
1041 #[arg(long)]
1045 pub debug_output: Option<PathBuf>,
1046
1047 #[arg(long, conflicts_with_all = ["target", "rewrite", "compose"])]
1050 pub test: bool,
1051
1052 #[arg(long, requires = "test")]
1054 pub test_update: bool,
1055
1056 #[arg(long, requires = "test")]
1058 pub test_add: Option<String>,
1059
1060 #[arg(long, requires = "test_add")]
1062 pub test_name: Option<String>,
1063
1064 #[arg(long, requires = "test")]
1066 pub test_filter: Option<String>,
1067
1068 #[arg(long, requires = "test")]
1070 pub test_list: bool,
1071
1072 #[arg(long, requires = "test")]
1074 pub fixtures_dir: Option<PathBuf>,
1075
1076 #[arg(long, requires = "test")]
1078 pub test_report: Option<PathBuf>,
1079}
1080
1081impl Default for Args {
1082 fn default() -> Self {
1083 Self {
1084 mode: Mode::Staged,
1085 target: None,
1086 copy: false,
1087 dry_run: false,
1088 push: false,
1089 dir: ".".to_string(),
1090 model: None,
1091 temperature: None,
1092 fixes: vec![],
1093 closes: vec![],
1094 resolves: vec![],
1095 refs: vec![],
1096 breaking: false,
1097 sign: false,
1098 signoff: false,
1099 amend: false,
1100 skip_hooks: false,
1101 config: None,
1102 context: vec![],
1103 rewrite: false,
1104 rewrite_preview: None,
1105 rewrite_start: None,
1106 rewrite_parallel: 10,
1107 rewrite_dry_run: false,
1108 rewrite_hide_old_types: false,
1109 exclude_old_message: false,
1110 compose: false,
1111 compose_preview: false,
1112 compose_max_commits: None,
1113 compose_test_after_each: false,
1114 no_changelog: false,
1115 debug_output: None,
1116 test: false,
1117 test_update: false,
1118 test_add: None,
1119 test_name: None,
1120 test_filter: None,
1121 test_list: false,
1122 fixtures_dir: None,
1123 test_report: None,
1124 }
1125 }
1126}
1127fn deserialize_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
1128where
1129 D: serde::Deserializer<'de>,
1130{
1131 let value = Value::deserialize(deserializer)?;
1132 Ok(value_to_string_vec(value))
1133}
1134
1135fn deserialize_analysis_details<'de, D>(
1137 deserializer: D,
1138) -> std::result::Result<Vec<AnalysisDetail>, D::Error>
1139where
1140 D: serde::Deserializer<'de>,
1141{
1142 let value = Value::deserialize(deserializer)?;
1143 match value {
1144 Value::Array(arr) => {
1145 let mut details = Vec::with_capacity(arr.len());
1146 for item in arr {
1147 let detail = match item {
1148 Value::Object(obj) => {
1150 let text = obj
1151 .get("text")
1152 .and_then(Value::as_str)
1153 .map(String::from)
1154 .unwrap_or_default();
1155 let changelog_category = obj
1156 .get("changelog_category")
1157 .and_then(Value::as_str)
1158 .map(ChangelogCategory::from_name);
1159 let user_visible = obj
1160 .get("user_visible")
1161 .and_then(Value::as_bool)
1162 .unwrap_or(false);
1163 AnalysisDetail { text, changelog_category, user_visible }
1164 },
1165 Value::String(s) => AnalysisDetail::simple(s),
1167 _ => continue,
1168 };
1169 if !detail.text.is_empty() {
1170 details.push(detail);
1171 }
1172 }
1173 Ok(details)
1174 },
1175 Value::String(s) => {
1176 if s.is_empty() {
1178 Ok(Vec::new())
1179 } else {
1180 Ok(vec![AnalysisDetail::simple(s)])
1181 }
1182 },
1183 Value::Null => Ok(Vec::new()),
1184 _ => Ok(Vec::new()),
1185 }
1186}
1187
1188fn extract_strings_from_malformed_json(input: &str) -> Vec<String> {
1189 let mut strings = Vec::new();
1190 let mut chars = input.chars();
1191
1192 while let Some(c) = chars.next() {
1193 if c == '"' {
1194 let mut current_string = String::new();
1195 let mut escaped = false;
1196
1197 for inner_c in chars.by_ref() {
1198 if escaped {
1199 current_string.push(inner_c);
1200 escaped = false;
1201 } else if inner_c == '\\' {
1202 current_string.push(inner_c);
1203 escaped = true;
1204 } else if inner_c == '"' {
1205 break;
1206 } else {
1207 current_string.push(inner_c);
1208 }
1209 }
1210
1211 let json_candidate = format!("\"{current_string}\"");
1213 if let Ok(parsed) = serde_json::from_str::<String>(&json_candidate) {
1214 strings.push(parsed);
1215 } else {
1216 let sanitized = current_string.replace(['\n', '\r'], " ");
1218 let json_sanitized = format!("\"{sanitized}\"");
1219 if let Ok(parsed) = serde_json::from_str::<String>(&json_sanitized) {
1220 strings.push(parsed);
1221 } else {
1222 strings.push(sanitized);
1224 }
1225 }
1226 }
1227 }
1228 strings
1229}
1230
1231fn value_to_string_vec(value: Value) -> Vec<String> {
1232 match value {
1233 Value::Null => Vec::new(),
1234 Value::String(s) => {
1235 let trimmed = s.trim();
1236
1237 if trimmed.starts_with('[') {
1239 let mut cleaned = trimmed;
1242 loop {
1243 let before = cleaned;
1244 cleaned = cleaned.trim_end_matches(['.', ',', ';', '"', '\'']);
1245 if cleaned == before {
1246 break;
1247 }
1248 }
1249
1250 if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(cleaned) {
1252 return arr
1253 .into_iter()
1254 .flat_map(|v| value_to_string_vec(v).into_iter())
1255 .collect();
1256 }
1257
1258 let sanitized = cleaned.replace(['\n', '\r'], " ");
1261 if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(&sanitized) {
1262 return arr
1263 .into_iter()
1264 .flat_map(|v| value_to_string_vec(v).into_iter())
1265 .collect();
1266 }
1267
1268 let extracted = extract_strings_from_malformed_json(trimmed);
1271 if !extracted.is_empty() {
1272 return extracted;
1273 }
1274 }
1275
1276 s.lines()
1278 .map(str::trim)
1279 .filter(|s| !s.is_empty())
1280 .map(|s| s.to_string())
1281 .collect()
1282 },
1283 Value::Array(arr) => arr
1284 .into_iter()
1285 .flat_map(|v| value_to_string_vec(v).into_iter())
1286 .collect(),
1287 Value::Object(map) => map
1288 .into_iter()
1289 .flat_map(|(k, v)| {
1290 let values = value_to_string_vec(v);
1291 if values.is_empty() {
1292 vec![k]
1293 } else {
1294 values
1295 .into_iter()
1296 .map(|val| format!("{k}: {val}"))
1297 .collect()
1298 }
1299 })
1300 .collect(),
1301 other => vec![other.to_string()],
1302 }
1303}
1304
1305fn deserialize_optional_scope<'de, D>(
1306 deserializer: D,
1307) -> std::result::Result<Option<Scope>, D::Error>
1308where
1309 D: serde::Deserializer<'de>,
1310{
1311 let value = Option::<String>::deserialize(deserializer)?;
1312 match value {
1313 None => Ok(None),
1314 Some(scope_str) => {
1315 let trimmed = scope_str.trim();
1316 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
1317 Ok(None)
1318 } else {
1319 Scope::new(trimmed.to_string())
1320 .map(Some)
1321 .map_err(serde::de::Error::custom)
1322 }
1323 },
1324 }
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329 use super::*;
1330
1331 #[test]
1334 fn test_resolve_model_name() {
1335 assert_eq!(resolve_model_name("sonnet"), "claude-sonnet-4.5");
1337 assert_eq!(resolve_model_name("s"), "claude-sonnet-4.5");
1338 assert_eq!(resolve_model_name("opus"), "claude-opus-4.5");
1339 assert_eq!(resolve_model_name("o"), "claude-opus-4.5");
1340 assert_eq!(resolve_model_name("haiku"), "claude-haiku-4-5");
1341 assert_eq!(resolve_model_name("h"), "claude-haiku-4-5");
1342
1343 assert_eq!(resolve_model_name("gpt5"), "gpt-5");
1345 assert_eq!(resolve_model_name("g5"), "gpt-5");
1346
1347 assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
1349 assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
1350
1351 assert_eq!(resolve_model_name("claude-sonnet-4.5"), "claude-sonnet-4.5");
1353 assert_eq!(resolve_model_name("custom-model"), "custom-model");
1354 }
1355
1356 #[test]
1359 fn test_commit_type_valid() {
1360 let valid_types = [
1361 "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
1362 "revert",
1363 ];
1364
1365 for ty in &valid_types {
1366 assert!(CommitType::new(*ty).is_ok(), "Expected '{ty}' to be valid");
1367 }
1368 }
1369
1370 #[test]
1371 fn test_commit_type_case_normalization() {
1372 let ct = CommitType::new("FEAT").expect("FEAT should normalize");
1374 assert_eq!(ct.as_str(), "feat");
1375
1376 let ct = CommitType::new("Fix").expect("Fix should normalize");
1377 assert_eq!(ct.as_str(), "fix");
1378
1379 let ct = CommitType::new("ReFaCtOr").expect("ReFaCtOr should normalize");
1380 assert_eq!(ct.as_str(), "refactor");
1381 }
1382
1383 #[test]
1384 fn test_commit_type_invalid() {
1385 let invalid_types = ["invalid", "bug", "feature", "update", "change", "random", "xyz", "123"];
1386
1387 for ty in &invalid_types {
1388 assert!(CommitType::new(*ty).is_err(), "Expected '{ty}' to be invalid");
1389 }
1390 }
1391
1392 #[test]
1393 fn test_commit_type_empty() {
1394 assert!(CommitType::new("").is_err(), "Empty string should be invalid");
1395 }
1396
1397 #[test]
1398 fn test_commit_type_display() {
1399 let ct = CommitType::new("feat").unwrap();
1400 assert_eq!(format!("{ct}"), "feat");
1401 }
1402
1403 #[test]
1404 fn test_commit_type_len() {
1405 let ct = CommitType::new("feat").unwrap();
1406 assert_eq!(ct.len(), 4);
1407
1408 let ct = CommitType::new("refactor").unwrap();
1409 assert_eq!(ct.len(), 8);
1410 }
1411
1412 #[test]
1415 fn test_scope_valid_single_segment() {
1416 let valid_scopes = ["core", "api", "lib", "client", "server", "ui", "test-123", "foo_bar"];
1417
1418 for scope in &valid_scopes {
1419 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1420 }
1421 }
1422
1423 #[test]
1424 fn test_scope_valid_two_segments() {
1425 let valid_scopes = ["api/client", "lib/core", "ui/components", "test-1/foo_2"];
1426
1427 for scope in &valid_scopes {
1428 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1429 }
1430 }
1431
1432 #[test]
1433 fn test_scope_invalid_three_segments() {
1434 let scope = Scope::new("a/b/c");
1435 assert!(scope.is_err(), "Three segments should be invalid");
1436
1437 if let Err(CommitGenError::InvalidScope(msg)) = scope {
1438 assert!(msg.contains("3 segments"));
1439 } else {
1440 panic!("Expected InvalidScope error");
1441 }
1442 }
1443
1444 #[test]
1445 fn test_scope_invalid_uppercase() {
1446 let invalid_scopes = ["Core", "API", "MyScope", "api/Client"];
1447
1448 for scope in &invalid_scopes {
1449 assert!(Scope::new(*scope).is_err(), "Expected '{scope}' with uppercase to be invalid");
1450 }
1451 }
1452
1453 #[test]
1454 fn test_scope_invalid_empty_segments() {
1455 let invalid_scopes = ["", "a//b", "/foo", "bar/"];
1456
1457 for scope in &invalid_scopes {
1458 assert!(
1459 Scope::new(*scope).is_err(),
1460 "Expected '{scope}' with empty segments to be invalid"
1461 );
1462 }
1463 }
1464
1465 #[test]
1466 fn test_scope_invalid_chars() {
1467 let invalid_scopes = ["a b", "foo bar", "test@scope", "api/client!", "a.b"];
1468
1469 for scope in &invalid_scopes {
1470 assert!(
1471 Scope::new(*scope).is_err(),
1472 "Expected '{scope}' with invalid chars to be invalid"
1473 );
1474 }
1475 }
1476
1477 #[test]
1478 fn test_scope_segments() {
1479 let scope = Scope::new("core").unwrap();
1480 assert_eq!(scope.segments(), vec!["core"]);
1481
1482 let scope = Scope::new("api/client").unwrap();
1483 assert_eq!(scope.segments(), vec!["api", "client"]);
1484 }
1485
1486 #[test]
1487 fn test_scope_display() {
1488 let scope = Scope::new("api/client").unwrap();
1489 assert_eq!(format!("{scope}"), "api/client");
1490 }
1491
1492 #[test]
1495 fn test_commit_summary_valid() {
1496 let summary_72 = "a".repeat(72);
1497 let summary_96 = "a".repeat(96);
1498 let summary_128 = "a".repeat(128);
1499 let valid_summaries = [
1500 "added new feature",
1501 "fixed bug in authentication",
1502 "x", summary_72.as_str(), summary_96.as_str(), summary_128.as_str(), ];
1507
1508 for summary in &valid_summaries {
1509 assert!(
1510 CommitSummary::new(*summary, 128).is_ok(),
1511 "Expected '{}' (len={}) to be valid",
1512 if summary.len() > 50 {
1513 &summary[..50]
1514 } else {
1515 summary
1516 },
1517 summary.len()
1518 );
1519 }
1520 }
1521
1522 #[test]
1523 fn test_commit_summary_too_long() {
1524 let long_summary = "a".repeat(129); let result = CommitSummary::new(long_summary, 128);
1526 assert!(result.is_err(), "129 char summary should be invalid");
1527
1528 if let Err(CommitGenError::SummaryTooLong { len, max }) = result {
1529 assert_eq!(len, 129);
1530 assert_eq!(max, 128);
1531 } else {
1532 panic!("Expected SummaryTooLong error");
1533 }
1534 }
1535
1536 #[test]
1537 fn test_commit_summary_empty() {
1538 let empty_cases = ["", " ", "\t", "\n"];
1539
1540 for empty in &empty_cases {
1541 assert!(
1542 CommitSummary::new(*empty, 128).is_err(),
1543 "Empty/whitespace-only summary should be invalid"
1544 );
1545 }
1546 }
1547
1548 #[test]
1549 fn test_commit_summary_warnings_uppercase_start() {
1550 let result = CommitSummary::new("Added new feature", 128);
1552 assert!(result.is_ok(), "Should succeed despite uppercase start");
1553 }
1554
1555 #[test]
1556 fn test_commit_summary_warnings_with_period() {
1557 let result = CommitSummary::new("added new feature.", 128);
1559 assert!(result.is_ok(), "Should succeed despite having period");
1560 }
1561
1562 #[test]
1563 fn test_commit_summary_new_unchecked() {
1564 let result = CommitSummary::new_unchecked("Added feature", 128);
1566 assert!(result.is_ok(), "new_unchecked should succeed");
1567 }
1568
1569 #[test]
1570 fn test_commit_summary_len() {
1571 let summary = CommitSummary::new("hello world", 128).unwrap();
1572 assert_eq!(summary.len(), 11);
1573 }
1574
1575 #[test]
1576 fn test_commit_summary_display() {
1577 let summary = CommitSummary::new("fixed bug", 128).unwrap();
1578 assert_eq!(format!("{summary}"), "fixed bug");
1579 }
1580
1581 #[test]
1584 fn test_commit_type_serialize() {
1585 let ct = CommitType::new("feat").unwrap();
1586 let json = serde_json::to_string(&ct).unwrap();
1587 assert_eq!(json, "\"feat\"");
1588 }
1589
1590 #[test]
1591 fn test_commit_type_deserialize() {
1592 let ct: CommitType = serde_json::from_str("\"fix\"").unwrap();
1593 assert_eq!(ct.as_str(), "fix");
1594
1595 let result: serde_json::Result<CommitType> = serde_json::from_str("\"invalid\"");
1597 assert!(result.is_err());
1598 }
1599
1600 #[test]
1601 fn test_scope_serialize() {
1602 let scope = Scope::new("api/client").unwrap();
1603 let json = serde_json::to_string(&scope).unwrap();
1604 assert_eq!(json, "\"api/client\"");
1605 }
1606
1607 #[test]
1608 fn test_scope_deserialize() {
1609 let scope: Scope = serde_json::from_str("\"core\"").unwrap();
1610 assert_eq!(scope.as_str(), "core");
1611
1612 let result: serde_json::Result<Scope> = serde_json::from_str("\"INVALID\"");
1614 assert!(result.is_err());
1615 }
1616
1617 #[test]
1618 fn test_commit_summary_serialize() {
1619 let summary = CommitSummary::new("fixed bug", 128).unwrap();
1620 let json = serde_json::to_string(&summary).unwrap();
1621 assert_eq!(json, "\"fixed bug\"");
1622 }
1623
1624 #[test]
1625 fn test_details_array_parsing() {
1626 let test_cases = [
1628 r#"{"type":"feat","details":[{"text":"item1"},{"text":"item2"}],"issue_refs":[]}"#,
1630 r#"{"type":"feat","details":["item1","item2"],"issue_refs":[]}"#,
1632 ];
1633
1634 for (idx, json) in test_cases.iter().enumerate() {
1635 let result: serde_json::Result<ConventionalAnalysis> = serde_json::from_str(json);
1636 match result {
1637 Ok(analysis) => {
1638 let body_texts = analysis.body_texts();
1639 assert_eq!(
1640 body_texts.len(),
1641 2,
1642 "Case {idx}: Expected 2 body items, got {}",
1643 body_texts.len()
1644 );
1645 assert_eq!(body_texts[0], "item1", "Case {idx}: First item mismatch");
1646 assert_eq!(body_texts[1], "item2", "Case {idx}: Second item mismatch");
1647 },
1648 Err(e) => {
1649 panic!("Case {idx}: Failed to parse: {e}");
1650 },
1651 }
1652 }
1653 }
1654
1655 #[test]
1656 fn test_analysis_detail_with_changelog() {
1657 let json = r#"{
1659 "type": "feat",
1660 "details": [
1661 {"text": "Added new API endpoint", "changelog_category": "Added", "user_visible": true},
1662 {"text": "Refactored internal code", "user_visible": false}
1663 ],
1664 "issue_refs": []
1665 }"#;
1666
1667 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1668 assert_eq!(analysis.details.len(), 2);
1669 assert_eq!(analysis.details[0].text, "Added new API endpoint");
1670 assert_eq!(analysis.details[0].changelog_category, Some(ChangelogCategory::Added));
1671 assert!(analysis.details[0].user_visible);
1672 assert!(!analysis.details[1].user_visible);
1673
1674 let entries = analysis.changelog_entries();
1676 assert_eq!(entries.len(), 1);
1677 assert!(entries.contains_key(&ChangelogCategory::Added));
1678 }
1679
1680 #[test]
1681 fn test_commit_summary_deserialize() {
1682 let summary: CommitSummary = serde_json::from_str("\"added feature\"").unwrap();
1683 assert_eq!(summary.as_str(), "added feature");
1684
1685 let long = format!("\"{}\"", "a".repeat(129));
1687 let result: serde_json::Result<CommitSummary> = serde_json::from_str(&long);
1688 assert!(result.is_err());
1689
1690 let result: serde_json::Result<CommitSummary> = serde_json::from_str("\"\"");
1692 assert!(result.is_err());
1693 }
1694
1695 #[test]
1696 fn test_conventional_commit_roundtrip() {
1697 let commit = ConventionalCommit {
1698 commit_type: CommitType::new("feat").unwrap(),
1699 scope: Some(Scope::new("api").unwrap()),
1700 summary: CommitSummary::new_unchecked("added endpoint", 128).unwrap(),
1701 body: vec!["detail 1.".to_string(), "detail 2.".to_string()],
1702 footers: vec!["Fixes: #123".to_string()],
1703 };
1704
1705 let json = serde_json::to_string(&commit).unwrap();
1706 let deserialized: ConventionalCommit = serde_json::from_str(&json).unwrap();
1707
1708 assert_eq!(deserialized.commit_type.as_str(), "feat");
1709 assert_eq!(deserialized.scope.unwrap().as_str(), "api");
1710 assert_eq!(deserialized.summary.as_str(), "added endpoint");
1711 assert_eq!(deserialized.body.len(), 2);
1712 assert_eq!(deserialized.footers.len(), 1);
1713 }
1714
1715 #[test]
1716 fn test_scope_null_string_deserializes_to_none() {
1717 let test_cases = [
1719 r#"{"type":"feat","scope":"null","body":[],"issue_refs":[]}"#,
1720 r#"{"type":"feat","scope":"Null","body":[],"issue_refs":[]}"#,
1721 r#"{"type":"feat","scope":"NULL","body":[],"issue_refs":[]}"#,
1722 r#"{"type":"feat","scope":" null ","body":[],"issue_refs":[]}"#,
1723 ];
1724
1725 for (idx, json) in test_cases.iter().enumerate() {
1726 let analysis: ConventionalAnalysis = serde_json::from_str(json)
1727 .unwrap_or_else(|e| panic!("Case {idx} failed to deserialize: {e}"));
1728 assert!(
1729 analysis.scope.is_none(),
1730 "Case {idx}: Expected scope to be None, got {:?}",
1731 analysis.scope
1732 );
1733 }
1734 }
1735
1736 #[test]
1739 fn test_body_array_with_newline_in_string() {
1740 let raw_str = "[\"Item 1\", \"Item\n2\"]";
1744 let value = serde_json::Value::String(raw_str.to_string());
1745
1746 let result = value_to_string_vec(value);
1748
1749 assert_eq!(result.len(), 2);
1751 assert_eq!(result[0], "Item 1");
1752 assert_eq!(result[1], "Item 2");
1755 }
1756
1757 #[test]
1758 fn test_body_array_malformed_truncated() {
1759 let raw_str = "[\"Refactored finance...\", \"Added automatic detection...\".";
1762 let value = serde_json::Value::String(raw_str.to_string());
1763
1764 let result = value_to_string_vec(value);
1765
1766 assert_eq!(result.len(), 2);
1768 assert_eq!(result[0], "Refactored finance...");
1769 assert_eq!(result[1], "Added automatic detection...");
1770 }
1771
1772 #[test]
1773 fn test_hunk_selector_deserialize_all() {
1774 let json = r#""ALL""#;
1775 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1776 assert!(matches!(selector, HunkSelector::All));
1777 }
1778
1779 #[test]
1780 fn test_hunk_selector_deserialize_lines_object() {
1781 let json = r#"{"start": 10, "end": 20}"#;
1782 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1783 match selector {
1784 HunkSelector::Lines { start, end } => {
1785 assert_eq!(start, 10);
1786 assert_eq!(end, 20);
1787 },
1788 _ => panic!("Expected Lines variant"),
1789 }
1790 }
1791
1792 #[test]
1793 fn test_hunk_selector_deserialize_lines_string() {
1794 let json = r#""10-20""#;
1795 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1796 match selector {
1797 HunkSelector::Lines { start, end } => {
1798 assert_eq!(start, 10);
1799 assert_eq!(end, 20);
1800 },
1801 _ => panic!("Expected Lines variant"),
1802 }
1803 }
1804
1805 #[test]
1806 fn test_hunk_selector_deserialize_search_pattern() {
1807 let json = r#"{"pattern": "fn main"}"#;
1808 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1809 match selector {
1810 HunkSelector::Search { pattern } => {
1811 assert_eq!(pattern, "fn main");
1812 },
1813 _ => panic!("Expected Search variant"),
1814 }
1815 }
1816
1817 #[test]
1818 fn test_hunk_selector_deserialize_old_format_hunk_header() {
1819 let json = r#""@@ -10,5 +10,7 @@""#;
1821 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1822 match selector {
1823 HunkSelector::Search { pattern } => {
1824 assert_eq!(pattern, "@@ -10,5 +10,7 @@");
1825 },
1826 _ => panic!("Expected Search variant for old hunk header format"),
1827 }
1828 }
1829
1830 #[test]
1831 fn test_hunk_selector_serialize_all() {
1832 let selector = HunkSelector::All;
1833 let json = serde_json::to_string(&selector).unwrap();
1834 assert_eq!(json, r#""ALL""#);
1835 }
1836
1837 #[test]
1838 fn test_hunk_selector_serialize_lines() {
1839 let selector = HunkSelector::Lines { start: 10, end: 20 };
1840 let json = serde_json::to_value(&selector).unwrap();
1841 assert_eq!(json["start"], 10);
1842 assert_eq!(json["end"], 20);
1843 }
1844
1845 #[test]
1846 fn test_file_change_deserialize_with_all() {
1847 let json = r#"{"path": "src/main.rs", "hunks": ["ALL"]}"#;
1848 let change: FileChange = serde_json::from_str(json).unwrap();
1849 assert_eq!(change.path, "src/main.rs");
1850 assert_eq!(change.hunks.len(), 1);
1851 assert!(matches!(change.hunks[0], HunkSelector::All));
1852 }
1853
1854 #[test]
1855 fn test_file_change_deserialize_with_line_ranges() {
1856 let json = r#"{"path": "src/main.rs", "hunks": [{"start": 10, "end": 20}, {"start": 50, "end": 60}]}"#;
1857 let change: FileChange = serde_json::from_str(json).unwrap();
1858 assert_eq!(change.path, "src/main.rs");
1859 assert_eq!(change.hunks.len(), 2);
1860
1861 match &change.hunks[0] {
1862 HunkSelector::Lines { start, end } => {
1863 assert_eq!(*start, 10);
1864 assert_eq!(*end, 20);
1865 },
1866 _ => panic!("Expected Lines variant"),
1867 }
1868
1869 match &change.hunks[1] {
1870 HunkSelector::Lines { start, end } => {
1871 assert_eq!(*start, 50);
1872 assert_eq!(*end, 60);
1873 },
1874 _ => panic!("Expected Lines variant"),
1875 }
1876 }
1877
1878 #[test]
1879 fn test_file_change_deserialize_mixed_formats() {
1880 let json = r#"{"path": "src/main.rs", "hunks": ["10-20", {"start": 50, "end": 60}]}"#;
1882 let change: FileChange = serde_json::from_str(json).unwrap();
1883 assert_eq!(change.hunks.len(), 2);
1884
1885 match &change.hunks[0] {
1886 HunkSelector::Lines { start, end } => {
1887 assert_eq!(*start, 10);
1888 assert_eq!(*end, 20);
1889 },
1890 _ => panic!("Expected Lines variant"),
1891 }
1892
1893 match &change.hunks[1] {
1894 HunkSelector::Lines { start, end } => {
1895 assert_eq!(*start, 50);
1896 assert_eq!(*end, 60);
1897 },
1898 _ => panic!("Expected Lines variant"),
1899 }
1900 }
1901}