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