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, short = 'f', conflicts_with_all = ["compose", "rewrite", "test"])]
992 pub fast: bool,
993
994 #[arg(long, conflicts_with_all = ["target", "copy", "dry_run"])]
997 pub rewrite: bool,
998
999 #[arg(long, requires = "rewrite")]
1001 pub rewrite_preview: Option<usize>,
1002
1003 #[arg(long, requires = "rewrite")]
1005 pub rewrite_start: Option<String>,
1006
1007 #[arg(long, default_value = "10", requires = "rewrite")]
1009 pub rewrite_parallel: usize,
1010
1011 #[arg(long, requires = "rewrite")]
1013 pub rewrite_dry_run: bool,
1014
1015 #[arg(long, requires = "rewrite")]
1017 pub rewrite_hide_old_types: bool,
1018
1019 #[arg(long)]
1022 pub exclude_old_message: bool,
1023
1024 #[arg(long, conflicts_with_all = ["target", "rewrite"])]
1027 pub compose: bool,
1028
1029 #[arg(long, requires = "compose")]
1031 pub compose_preview: bool,
1032
1033 #[arg(long, requires = "compose")]
1035 pub compose_max_commits: Option<usize>,
1036
1037 #[arg(long, requires = "compose")]
1039 pub compose_test_after_each: bool,
1040
1041 #[arg(long)]
1044 pub no_changelog: bool,
1045
1046 #[arg(long)]
1050 pub debug_output: Option<PathBuf>,
1051
1052 #[arg(long, conflicts_with_all = ["target", "rewrite", "compose"])]
1055 pub test: bool,
1056
1057 #[arg(long, requires = "test")]
1059 pub test_update: bool,
1060
1061 #[arg(long, requires = "test")]
1063 pub test_add: Option<String>,
1064
1065 #[arg(long, requires = "test_add")]
1067 pub test_name: Option<String>,
1068
1069 #[arg(long, requires = "test")]
1071 pub test_filter: Option<String>,
1072
1073 #[arg(long, requires = "test")]
1075 pub test_list: bool,
1076
1077 #[arg(long, requires = "test")]
1079 pub fixtures_dir: Option<PathBuf>,
1080
1081 #[arg(long, requires = "test")]
1083 pub test_report: Option<PathBuf>,
1084}
1085
1086impl Default for Args {
1087 fn default() -> Self {
1088 Self {
1089 mode: Mode::Staged,
1090 target: None,
1091 copy: false,
1092 dry_run: false,
1093 push: false,
1094 dir: ".".to_string(),
1095 model: None,
1096 temperature: None,
1097 fixes: vec![],
1098 closes: vec![],
1099 resolves: vec![],
1100 refs: vec![],
1101 breaking: false,
1102 sign: false,
1103 signoff: false,
1104 amend: false,
1105 skip_hooks: false,
1106 config: None,
1107 context: vec![],
1108 rewrite: false,
1109 rewrite_preview: None,
1110 rewrite_start: None,
1111 rewrite_parallel: 10,
1112 rewrite_dry_run: false,
1113 rewrite_hide_old_types: false,
1114 exclude_old_message: false,
1115 fast: false,
1116 compose: false,
1117 compose_preview: false,
1118 compose_max_commits: None,
1119 compose_test_after_each: false,
1120 no_changelog: false,
1121 debug_output: None,
1122 test: false,
1123 test_update: false,
1124 test_add: None,
1125 test_name: None,
1126 test_filter: None,
1127 test_list: false,
1128 fixtures_dir: None,
1129 test_report: None,
1130 }
1131 }
1132}
1133fn deserialize_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
1134where
1135 D: serde::Deserializer<'de>,
1136{
1137 let value = Value::deserialize(deserializer)?;
1138 Ok(value_to_string_vec(value))
1139}
1140
1141fn deserialize_analysis_details<'de, D>(
1143 deserializer: D,
1144) -> std::result::Result<Vec<AnalysisDetail>, D::Error>
1145where
1146 D: serde::Deserializer<'de>,
1147{
1148 let value = Value::deserialize(deserializer)?;
1149 match value {
1150 Value::Array(arr) => {
1151 let mut details = Vec::with_capacity(arr.len());
1152 for item in arr {
1153 let detail = match item {
1154 Value::Object(obj) => {
1156 let text = obj
1157 .get("text")
1158 .and_then(Value::as_str)
1159 .map(String::from)
1160 .unwrap_or_default();
1161 let changelog_category = obj
1162 .get("changelog_category")
1163 .and_then(Value::as_str)
1164 .map(ChangelogCategory::from_name);
1165 let user_visible = obj
1166 .get("user_visible")
1167 .and_then(Value::as_bool)
1168 .unwrap_or(false);
1169 AnalysisDetail { text, changelog_category, user_visible }
1170 },
1171 Value::String(s) => AnalysisDetail::simple(s),
1173 _ => continue,
1174 };
1175 if !detail.text.is_empty() {
1176 details.push(detail);
1177 }
1178 }
1179 Ok(details)
1180 },
1181 Value::String(s) => {
1182 if s.is_empty() {
1184 Ok(Vec::new())
1185 } else {
1186 Ok(vec![AnalysisDetail::simple(s)])
1187 }
1188 },
1189 Value::Null => Ok(Vec::new()),
1190 _ => Ok(Vec::new()),
1191 }
1192}
1193
1194fn extract_strings_from_malformed_json(input: &str) -> Vec<String> {
1195 let mut strings = Vec::new();
1196 let mut chars = input.chars();
1197
1198 while let Some(c) = chars.next() {
1199 if c == '"' {
1200 let mut current_string = String::new();
1201 let mut escaped = false;
1202
1203 for inner_c in chars.by_ref() {
1204 if escaped {
1205 current_string.push(inner_c);
1206 escaped = false;
1207 } else if inner_c == '\\' {
1208 current_string.push(inner_c);
1209 escaped = true;
1210 } else if inner_c == '"' {
1211 break;
1212 } else {
1213 current_string.push(inner_c);
1214 }
1215 }
1216
1217 let json_candidate = format!("\"{current_string}\"");
1219 if let Ok(parsed) = serde_json::from_str::<String>(&json_candidate) {
1220 strings.push(parsed);
1221 } else {
1222 let sanitized = current_string.replace(['\n', '\r'], " ");
1224 let json_sanitized = format!("\"{sanitized}\"");
1225 if let Ok(parsed) = serde_json::from_str::<String>(&json_sanitized) {
1226 strings.push(parsed);
1227 } else {
1228 strings.push(sanitized);
1230 }
1231 }
1232 }
1233 }
1234 strings
1235}
1236
1237fn value_to_string_vec(value: Value) -> Vec<String> {
1238 match value {
1239 Value::Null => Vec::new(),
1240 Value::String(s) => {
1241 let trimmed = s.trim();
1242
1243 if trimmed.starts_with('[') {
1245 let mut cleaned = trimmed;
1248 loop {
1249 let before = cleaned;
1250 cleaned = cleaned.trim_end_matches(['.', ',', ';', '"', '\'']);
1251 if cleaned == before {
1252 break;
1253 }
1254 }
1255
1256 if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(cleaned) {
1258 return arr
1259 .into_iter()
1260 .flat_map(|v| value_to_string_vec(v).into_iter())
1261 .collect();
1262 }
1263
1264 let sanitized = cleaned.replace(['\n', '\r'], " ");
1267 if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(&sanitized) {
1268 return arr
1269 .into_iter()
1270 .flat_map(|v| value_to_string_vec(v).into_iter())
1271 .collect();
1272 }
1273
1274 let extracted = extract_strings_from_malformed_json(trimmed);
1277 if !extracted.is_empty() {
1278 return extracted;
1279 }
1280 }
1281
1282 s.lines()
1284 .map(str::trim)
1285 .filter(|s| !s.is_empty())
1286 .map(|s| s.to_string())
1287 .collect()
1288 },
1289 Value::Array(arr) => arr
1290 .into_iter()
1291 .flat_map(|v| value_to_string_vec(v).into_iter())
1292 .collect(),
1293 Value::Object(map) => map
1294 .into_iter()
1295 .flat_map(|(k, v)| {
1296 let values = value_to_string_vec(v);
1297 if values.is_empty() {
1298 vec![k]
1299 } else {
1300 values
1301 .into_iter()
1302 .map(|val| format!("{k}: {val}"))
1303 .collect()
1304 }
1305 })
1306 .collect(),
1307 other => vec![other.to_string()],
1308 }
1309}
1310
1311fn deserialize_optional_scope<'de, D>(
1312 deserializer: D,
1313) -> std::result::Result<Option<Scope>, D::Error>
1314where
1315 D: serde::Deserializer<'de>,
1316{
1317 let value = Option::<String>::deserialize(deserializer)?;
1318 match value {
1319 None => Ok(None),
1320 Some(scope_str) => {
1321 let trimmed = scope_str.trim();
1322 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
1323 Ok(None)
1324 } else {
1325 Scope::new(trimmed.to_string())
1326 .map(Some)
1327 .map_err(serde::de::Error::custom)
1328 }
1329 },
1330 }
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335 use super::*;
1336
1337 #[test]
1340 fn test_resolve_model_name() {
1341 assert_eq!(resolve_model_name("sonnet"), "claude-sonnet-4.5");
1343 assert_eq!(resolve_model_name("s"), "claude-sonnet-4.5");
1344 assert_eq!(resolve_model_name("opus"), "claude-opus-4.5");
1345 assert_eq!(resolve_model_name("o"), "claude-opus-4.5");
1346 assert_eq!(resolve_model_name("haiku"), "claude-haiku-4-5");
1347 assert_eq!(resolve_model_name("h"), "claude-haiku-4-5");
1348
1349 assert_eq!(resolve_model_name("gpt5"), "gpt-5");
1351 assert_eq!(resolve_model_name("g5"), "gpt-5");
1352
1353 assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
1355 assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
1356
1357 assert_eq!(resolve_model_name("claude-sonnet-4.5"), "claude-sonnet-4.5");
1359 assert_eq!(resolve_model_name("custom-model"), "custom-model");
1360 }
1361
1362 #[test]
1365 fn test_commit_type_valid() {
1366 let valid_types = [
1367 "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
1368 "revert",
1369 ];
1370
1371 for ty in &valid_types {
1372 assert!(CommitType::new(*ty).is_ok(), "Expected '{ty}' to be valid");
1373 }
1374 }
1375
1376 #[test]
1377 fn test_commit_type_case_normalization() {
1378 let ct = CommitType::new("FEAT").expect("FEAT should normalize");
1380 assert_eq!(ct.as_str(), "feat");
1381
1382 let ct = CommitType::new("Fix").expect("Fix should normalize");
1383 assert_eq!(ct.as_str(), "fix");
1384
1385 let ct = CommitType::new("ReFaCtOr").expect("ReFaCtOr should normalize");
1386 assert_eq!(ct.as_str(), "refactor");
1387 }
1388
1389 #[test]
1390 fn test_commit_type_invalid() {
1391 let invalid_types = ["invalid", "bug", "feature", "update", "change", "random", "xyz", "123"];
1392
1393 for ty in &invalid_types {
1394 assert!(CommitType::new(*ty).is_err(), "Expected '{ty}' to be invalid");
1395 }
1396 }
1397
1398 #[test]
1399 fn test_commit_type_empty() {
1400 assert!(CommitType::new("").is_err(), "Empty string should be invalid");
1401 }
1402
1403 #[test]
1404 fn test_commit_type_display() {
1405 let ct = CommitType::new("feat").unwrap();
1406 assert_eq!(format!("{ct}"), "feat");
1407 }
1408
1409 #[test]
1410 fn test_commit_type_len() {
1411 let ct = CommitType::new("feat").unwrap();
1412 assert_eq!(ct.len(), 4);
1413
1414 let ct = CommitType::new("refactor").unwrap();
1415 assert_eq!(ct.len(), 8);
1416 }
1417
1418 #[test]
1421 fn test_scope_valid_single_segment() {
1422 let valid_scopes = ["core", "api", "lib", "client", "server", "ui", "test-123", "foo_bar"];
1423
1424 for scope in &valid_scopes {
1425 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1426 }
1427 }
1428
1429 #[test]
1430 fn test_scope_valid_two_segments() {
1431 let valid_scopes = ["api/client", "lib/core", "ui/components", "test-1/foo_2"];
1432
1433 for scope in &valid_scopes {
1434 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1435 }
1436 }
1437
1438 #[test]
1439 fn test_scope_invalid_three_segments() {
1440 let scope = Scope::new("a/b/c");
1441 assert!(scope.is_err(), "Three segments should be invalid");
1442
1443 if let Err(CommitGenError::InvalidScope(msg)) = scope {
1444 assert!(msg.contains("3 segments"));
1445 } else {
1446 panic!("Expected InvalidScope error");
1447 }
1448 }
1449
1450 #[test]
1451 fn test_scope_invalid_uppercase() {
1452 let invalid_scopes = ["Core", "API", "MyScope", "api/Client"];
1453
1454 for scope in &invalid_scopes {
1455 assert!(Scope::new(*scope).is_err(), "Expected '{scope}' with uppercase to be invalid");
1456 }
1457 }
1458
1459 #[test]
1460 fn test_scope_invalid_empty_segments() {
1461 let invalid_scopes = ["", "a//b", "/foo", "bar/"];
1462
1463 for scope in &invalid_scopes {
1464 assert!(
1465 Scope::new(*scope).is_err(),
1466 "Expected '{scope}' with empty segments to be invalid"
1467 );
1468 }
1469 }
1470
1471 #[test]
1472 fn test_scope_invalid_chars() {
1473 let invalid_scopes = ["a b", "foo bar", "test@scope", "api/client!", "a.b"];
1474
1475 for scope in &invalid_scopes {
1476 assert!(
1477 Scope::new(*scope).is_err(),
1478 "Expected '{scope}' with invalid chars to be invalid"
1479 );
1480 }
1481 }
1482
1483 #[test]
1484 fn test_scope_segments() {
1485 let scope = Scope::new("core").unwrap();
1486 assert_eq!(scope.segments(), vec!["core"]);
1487
1488 let scope = Scope::new("api/client").unwrap();
1489 assert_eq!(scope.segments(), vec!["api", "client"]);
1490 }
1491
1492 #[test]
1493 fn test_scope_display() {
1494 let scope = Scope::new("api/client").unwrap();
1495 assert_eq!(format!("{scope}"), "api/client");
1496 }
1497
1498 #[test]
1501 fn test_commit_summary_valid() {
1502 let summary_72 = "a".repeat(72);
1503 let summary_96 = "a".repeat(96);
1504 let summary_128 = "a".repeat(128);
1505 let valid_summaries = [
1506 "added new feature",
1507 "fixed bug in authentication",
1508 "x", summary_72.as_str(), summary_96.as_str(), summary_128.as_str(), ];
1513
1514 for summary in &valid_summaries {
1515 assert!(
1516 CommitSummary::new(*summary, 128).is_ok(),
1517 "Expected '{}' (len={}) to be valid",
1518 if summary.len() > 50 {
1519 &summary[..50]
1520 } else {
1521 summary
1522 },
1523 summary.len()
1524 );
1525 }
1526 }
1527
1528 #[test]
1529 fn test_commit_summary_too_long() {
1530 let long_summary = "a".repeat(129); let result = CommitSummary::new(long_summary, 128);
1532 assert!(result.is_err(), "129 char summary should be invalid");
1533
1534 if let Err(CommitGenError::SummaryTooLong { len, max }) = result {
1535 assert_eq!(len, 129);
1536 assert_eq!(max, 128);
1537 } else {
1538 panic!("Expected SummaryTooLong error");
1539 }
1540 }
1541
1542 #[test]
1543 fn test_commit_summary_empty() {
1544 let empty_cases = ["", " ", "\t", "\n"];
1545
1546 for empty in &empty_cases {
1547 assert!(
1548 CommitSummary::new(*empty, 128).is_err(),
1549 "Empty/whitespace-only summary should be invalid"
1550 );
1551 }
1552 }
1553
1554 #[test]
1555 fn test_commit_summary_warnings_uppercase_start() {
1556 let result = CommitSummary::new("Added new feature", 128);
1558 assert!(result.is_ok(), "Should succeed despite uppercase start");
1559 }
1560
1561 #[test]
1562 fn test_commit_summary_warnings_with_period() {
1563 let result = CommitSummary::new("added new feature.", 128);
1565 assert!(result.is_ok(), "Should succeed despite having period");
1566 }
1567
1568 #[test]
1569 fn test_commit_summary_new_unchecked() {
1570 let result = CommitSummary::new_unchecked("Added feature", 128);
1572 assert!(result.is_ok(), "new_unchecked should succeed");
1573 }
1574
1575 #[test]
1576 fn test_commit_summary_len() {
1577 let summary = CommitSummary::new("hello world", 128).unwrap();
1578 assert_eq!(summary.len(), 11);
1579 }
1580
1581 #[test]
1582 fn test_commit_summary_display() {
1583 let summary = CommitSummary::new("fixed bug", 128).unwrap();
1584 assert_eq!(format!("{summary}"), "fixed bug");
1585 }
1586
1587 #[test]
1590 fn test_commit_type_serialize() {
1591 let ct = CommitType::new("feat").unwrap();
1592 let json = serde_json::to_string(&ct).unwrap();
1593 assert_eq!(json, "\"feat\"");
1594 }
1595
1596 #[test]
1597 fn test_commit_type_deserialize() {
1598 let ct: CommitType = serde_json::from_str("\"fix\"").unwrap();
1599 assert_eq!(ct.as_str(), "fix");
1600
1601 let result: serde_json::Result<CommitType> = serde_json::from_str("\"invalid\"");
1603 assert!(result.is_err());
1604 }
1605
1606 #[test]
1607 fn test_scope_serialize() {
1608 let scope = Scope::new("api/client").unwrap();
1609 let json = serde_json::to_string(&scope).unwrap();
1610 assert_eq!(json, "\"api/client\"");
1611 }
1612
1613 #[test]
1614 fn test_scope_deserialize() {
1615 let scope: Scope = serde_json::from_str("\"core\"").unwrap();
1616 assert_eq!(scope.as_str(), "core");
1617
1618 let result: serde_json::Result<Scope> = serde_json::from_str("\"INVALID\"");
1620 assert!(result.is_err());
1621 }
1622
1623 #[test]
1624 fn test_commit_summary_serialize() {
1625 let summary = CommitSummary::new("fixed bug", 128).unwrap();
1626 let json = serde_json::to_string(&summary).unwrap();
1627 assert_eq!(json, "\"fixed bug\"");
1628 }
1629
1630 #[test]
1631 fn test_details_array_parsing() {
1632 let test_cases = [
1634 r#"{"type":"feat","details":[{"text":"item1"},{"text":"item2"}],"issue_refs":[]}"#,
1636 r#"{"type":"feat","details":["item1","item2"],"issue_refs":[]}"#,
1638 ];
1639
1640 for (idx, json) in test_cases.iter().enumerate() {
1641 let result: serde_json::Result<ConventionalAnalysis> = serde_json::from_str(json);
1642 match result {
1643 Ok(analysis) => {
1644 let body_texts = analysis.body_texts();
1645 assert_eq!(
1646 body_texts.len(),
1647 2,
1648 "Case {idx}: Expected 2 body items, got {}",
1649 body_texts.len()
1650 );
1651 assert_eq!(body_texts[0], "item1", "Case {idx}: First item mismatch");
1652 assert_eq!(body_texts[1], "item2", "Case {idx}: Second item mismatch");
1653 },
1654 Err(e) => {
1655 panic!("Case {idx}: Failed to parse: {e}");
1656 },
1657 }
1658 }
1659 }
1660
1661 #[test]
1662 fn test_analysis_detail_with_changelog() {
1663 let json = r#"{
1665 "type": "feat",
1666 "details": [
1667 {"text": "Added new API endpoint", "changelog_category": "Added", "user_visible": true},
1668 {"text": "Refactored internal code", "user_visible": false}
1669 ],
1670 "issue_refs": []
1671 }"#;
1672
1673 let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1674 assert_eq!(analysis.details.len(), 2);
1675 assert_eq!(analysis.details[0].text, "Added new API endpoint");
1676 assert_eq!(analysis.details[0].changelog_category, Some(ChangelogCategory::Added));
1677 assert!(analysis.details[0].user_visible);
1678 assert!(!analysis.details[1].user_visible);
1679
1680 let entries = analysis.changelog_entries();
1682 assert_eq!(entries.len(), 1);
1683 assert!(entries.contains_key(&ChangelogCategory::Added));
1684 }
1685
1686 #[test]
1687 fn test_commit_summary_deserialize() {
1688 let summary: CommitSummary = serde_json::from_str("\"added feature\"").unwrap();
1689 assert_eq!(summary.as_str(), "added feature");
1690
1691 let long = format!("\"{}\"", "a".repeat(129));
1693 let result: serde_json::Result<CommitSummary> = serde_json::from_str(&long);
1694 assert!(result.is_err());
1695
1696 let result: serde_json::Result<CommitSummary> = serde_json::from_str("\"\"");
1698 assert!(result.is_err());
1699 }
1700
1701 #[test]
1702 fn test_conventional_commit_roundtrip() {
1703 let commit = ConventionalCommit {
1704 commit_type: CommitType::new("feat").unwrap(),
1705 scope: Some(Scope::new("api").unwrap()),
1706 summary: CommitSummary::new_unchecked("added endpoint", 128).unwrap(),
1707 body: vec!["detail 1.".to_string(), "detail 2.".to_string()],
1708 footers: vec!["Fixes: #123".to_string()],
1709 };
1710
1711 let json = serde_json::to_string(&commit).unwrap();
1712 let deserialized: ConventionalCommit = serde_json::from_str(&json).unwrap();
1713
1714 assert_eq!(deserialized.commit_type.as_str(), "feat");
1715 assert_eq!(deserialized.scope.unwrap().as_str(), "api");
1716 assert_eq!(deserialized.summary.as_str(), "added endpoint");
1717 assert_eq!(deserialized.body.len(), 2);
1718 assert_eq!(deserialized.footers.len(), 1);
1719 }
1720
1721 #[test]
1722 fn test_scope_null_string_deserializes_to_none() {
1723 let test_cases = [
1725 r#"{"type":"feat","scope":"null","body":[],"issue_refs":[]}"#,
1726 r#"{"type":"feat","scope":"Null","body":[],"issue_refs":[]}"#,
1727 r#"{"type":"feat","scope":"NULL","body":[],"issue_refs":[]}"#,
1728 r#"{"type":"feat","scope":" null ","body":[],"issue_refs":[]}"#,
1729 ];
1730
1731 for (idx, json) in test_cases.iter().enumerate() {
1732 let analysis: ConventionalAnalysis = serde_json::from_str(json)
1733 .unwrap_or_else(|e| panic!("Case {idx} failed to deserialize: {e}"));
1734 assert!(
1735 analysis.scope.is_none(),
1736 "Case {idx}: Expected scope to be None, got {:?}",
1737 analysis.scope
1738 );
1739 }
1740 }
1741
1742 #[test]
1745 fn test_body_array_with_newline_in_string() {
1746 let raw_str = "[\"Item 1\", \"Item\n2\"]";
1750 let value = serde_json::Value::String(raw_str.to_string());
1751
1752 let result = value_to_string_vec(value);
1754
1755 assert_eq!(result.len(), 2);
1757 assert_eq!(result[0], "Item 1");
1758 assert_eq!(result[1], "Item 2");
1761 }
1762
1763 #[test]
1764 fn test_body_array_malformed_truncated() {
1765 let raw_str = "[\"Refactored finance...\", \"Added automatic detection...\".";
1768 let value = serde_json::Value::String(raw_str.to_string());
1769
1770 let result = value_to_string_vec(value);
1771
1772 assert_eq!(result.len(), 2);
1774 assert_eq!(result[0], "Refactored finance...");
1775 assert_eq!(result[1], "Added automatic detection...");
1776 }
1777
1778 #[test]
1779 fn test_hunk_selector_deserialize_all() {
1780 let json = r#""ALL""#;
1781 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1782 assert!(matches!(selector, HunkSelector::All));
1783 }
1784
1785 #[test]
1786 fn test_hunk_selector_deserialize_lines_object() {
1787 let json = r#"{"start": 10, "end": 20}"#;
1788 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1789 match selector {
1790 HunkSelector::Lines { start, end } => {
1791 assert_eq!(start, 10);
1792 assert_eq!(end, 20);
1793 },
1794 _ => panic!("Expected Lines variant"),
1795 }
1796 }
1797
1798 #[test]
1799 fn test_hunk_selector_deserialize_lines_string() {
1800 let json = r#""10-20""#;
1801 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1802 match selector {
1803 HunkSelector::Lines { start, end } => {
1804 assert_eq!(start, 10);
1805 assert_eq!(end, 20);
1806 },
1807 _ => panic!("Expected Lines variant"),
1808 }
1809 }
1810
1811 #[test]
1812 fn test_hunk_selector_deserialize_search_pattern() {
1813 let json = r#"{"pattern": "fn main"}"#;
1814 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1815 match selector {
1816 HunkSelector::Search { pattern } => {
1817 assert_eq!(pattern, "fn main");
1818 },
1819 _ => panic!("Expected Search variant"),
1820 }
1821 }
1822
1823 #[test]
1824 fn test_hunk_selector_deserialize_old_format_hunk_header() {
1825 let json = r#""@@ -10,5 +10,7 @@""#;
1827 let selector: HunkSelector = serde_json::from_str(json).unwrap();
1828 match selector {
1829 HunkSelector::Search { pattern } => {
1830 assert_eq!(pattern, "@@ -10,5 +10,7 @@");
1831 },
1832 _ => panic!("Expected Search variant for old hunk header format"),
1833 }
1834 }
1835
1836 #[test]
1837 fn test_hunk_selector_serialize_all() {
1838 let selector = HunkSelector::All;
1839 let json = serde_json::to_string(&selector).unwrap();
1840 assert_eq!(json, r#""ALL""#);
1841 }
1842
1843 #[test]
1844 fn test_hunk_selector_serialize_lines() {
1845 let selector = HunkSelector::Lines { start: 10, end: 20 };
1846 let json = serde_json::to_value(&selector).unwrap();
1847 assert_eq!(json["start"], 10);
1848 assert_eq!(json["end"], 20);
1849 }
1850
1851 #[test]
1852 fn test_file_change_deserialize_with_all() {
1853 let json = r#"{"path": "src/main.rs", "hunks": ["ALL"]}"#;
1854 let change: FileChange = serde_json::from_str(json).unwrap();
1855 assert_eq!(change.path, "src/main.rs");
1856 assert_eq!(change.hunks.len(), 1);
1857 assert!(matches!(change.hunks[0], HunkSelector::All));
1858 }
1859
1860 #[test]
1861 fn test_file_change_deserialize_with_line_ranges() {
1862 let json = r#"{"path": "src/main.rs", "hunks": [{"start": 10, "end": 20}, {"start": 50, "end": 60}]}"#;
1863 let change: FileChange = serde_json::from_str(json).unwrap();
1864 assert_eq!(change.path, "src/main.rs");
1865 assert_eq!(change.hunks.len(), 2);
1866
1867 match &change.hunks[0] {
1868 HunkSelector::Lines { start, end } => {
1869 assert_eq!(*start, 10);
1870 assert_eq!(*end, 20);
1871 },
1872 _ => panic!("Expected Lines variant"),
1873 }
1874
1875 match &change.hunks[1] {
1876 HunkSelector::Lines { start, end } => {
1877 assert_eq!(*start, 50);
1878 assert_eq!(*end, 60);
1879 },
1880 _ => panic!("Expected Lines variant"),
1881 }
1882 }
1883
1884 #[test]
1885 fn test_file_change_deserialize_mixed_formats() {
1886 let json = r#"{"path": "src/main.rs", "hunks": ["10-20", {"start": 50, "end": 60}]}"#;
1888 let change: FileChange = serde_json::from_str(json).unwrap();
1889 assert_eq!(change.hunks.len(), 2);
1890
1891 match &change.hunks[0] {
1892 HunkSelector::Lines { start, end } => {
1893 assert_eq!(*start, 10);
1894 assert_eq!(*end, 20);
1895 },
1896 _ => panic!("Expected Lines variant"),
1897 }
1898
1899 match &change.hunks[1] {
1900 HunkSelector::Lines { start, end } => {
1901 assert_eq!(*start, 50);
1902 assert_eq!(*end, 60);
1903 },
1904 _ => panic!("Expected Lines variant"),
1905 }
1906 }
1907}