1use std::{fmt, path::PathBuf};
2
3use clap::{Parser, ValueEnum};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::error::{CommitGenError, Result};
8
9#[derive(Debug, Clone, ValueEnum)]
10pub enum Mode {
11 Staged,
13 Commit,
15 Unstaged,
17 Compose,
19}
20
21pub fn resolve_model_name(name: &str) -> String {
23 match name {
24 "sonnet" | "s" => "claude-sonnet-4.5",
26 "opus" | "o" => "claude-opus-4.1",
27 "haiku" | "h" => "claude-haiku-4-5",
28 "3.5" | "sonnet-3.5" => "claude-3.5-sonnet",
29 "3.7" | "sonnet-3.7" => "claude-3.7-sonnet",
30
31 "gpt5" | "g5" => "gpt-5",
33 "gpt5-pro" => "gpt-5-pro",
34 "gpt5-mini" => "gpt-5-mini",
35 "gpt5-codex" => "gpt-5-codex",
36
37 "o3" => "o3",
39 "o3-pro" => "o3-pro",
40 "o3-mini" => "o3-mini",
41 "o1" => "o1",
42 "o1-pro" => "o1-pro",
43 "o1-mini" => "o1-mini",
44
45 "gemini" | "g2.5" => "gemini-2.5-pro",
47 "flash" | "g2.5-flash" => "gemini-2.5-flash",
48 "flash-lite" => "gemini-2.5-flash-lite",
49
50 "qwen" | "q480b" => "qwen-3-coder-480b",
52
53 "glm4.6" => "glm-4.6",
55 "glm4.5" => "glm-4.5",
56 "glm-air" => "glm-4.5-air",
57
58 _ => name,
60 }
61 .to_string()
62}
63
64#[derive(Debug, Clone)]
66pub struct ScopeCandidate {
67 pub path: String,
68 pub percentage: f32,
69 pub confidence: f32,
70}
71
72#[derive(Clone, PartialEq, Eq)]
74pub struct CommitType(String);
75
76impl CommitType {
77 const VALID_TYPES: &'static [&'static str] = &[
78 "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert",
79 ];
80
81 pub fn new(s: impl Into<String>) -> Result<Self> {
83 let s = s.into();
84 let normalized = s.to_lowercase();
85
86 if !Self::VALID_TYPES.contains(&normalized.as_str()) {
87 return Err(CommitGenError::InvalidCommitType(format!(
88 "Invalid commit type '{}'. Must be one of: {}",
89 s,
90 Self::VALID_TYPES.join(", ")
91 )));
92 }
93
94 Ok(Self(normalized))
95 }
96
97 pub fn as_str(&self) -> &str {
99 &self.0
100 }
101
102 pub const fn len(&self) -> usize {
104 self.0.len()
105 }
106
107 #[allow(dead_code, reason = "Convenience method for future use")]
109 pub const fn is_empty(&self) -> bool {
110 self.0.is_empty()
111 }
112}
113
114impl fmt::Display for CommitType {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 write!(f, "{}", self.0)
117 }
118}
119
120impl fmt::Debug for CommitType {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 f.debug_tuple("CommitType").field(&self.0).finish()
123 }
124}
125
126impl Serialize for CommitType {
127 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
128 where
129 S: serde::Serializer,
130 {
131 self.0.serialize(serializer)
132 }
133}
134
135impl<'de> Deserialize<'de> for CommitType {
136 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
137 where
138 D: serde::Deserializer<'de>,
139 {
140 let s = String::deserialize(deserializer)?;
141 Self::new(s).map_err(serde::de::Error::custom)
142 }
143}
144
145#[derive(Clone)]
147pub struct CommitSummary(String);
148
149impl CommitSummary {
150 pub fn new(s: impl Into<String>, max_len: usize) -> Result<Self> {
153 Self::new_impl(s, max_len, true)
154 }
155
156 pub(crate) fn new_unchecked(s: impl Into<String>, max_len: usize) -> Result<Self> {
159 Self::new_impl(s, max_len, false)
160 }
161
162 fn new_impl(s: impl Into<String>, max_len: usize, emit_warnings: bool) -> Result<Self> {
163 let s = s.into();
164
165 if s.trim().is_empty() {
167 return Err(CommitGenError::ValidationError("commit summary cannot be empty".to_string()));
168 }
169
170 if s.len() > max_len {
172 return Err(CommitGenError::SummaryTooLong { len: s.len(), max: max_len });
173 }
174
175 if emit_warnings {
176 if let Some(first_char) = s.chars().next()
178 && first_char.is_uppercase()
179 {
180 eprintln!("⚠ warning: commit summary should start with lowercase: {s}");
181 }
182
183 if s.trim_end().ends_with('.') {
185 eprintln!(
186 "⚠ warning: commit summary should NOT end with period (conventional commits \
187 style): {s}"
188 );
189 }
190 }
191
192 Ok(Self(s))
193 }
194
195 pub fn as_str(&self) -> &str {
197 &self.0
198 }
199
200 pub const fn len(&self) -> usize {
202 self.0.len()
203 }
204
205 #[allow(dead_code, reason = "Convenience method for future use")]
207 pub const fn is_empty(&self) -> bool {
208 self.0.is_empty()
209 }
210}
211
212impl fmt::Display for CommitSummary {
213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 write!(f, "{}", self.0)
215 }
216}
217
218impl fmt::Debug for CommitSummary {
219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220 f.debug_tuple("CommitSummary").field(&self.0).finish()
221 }
222}
223
224impl Serialize for CommitSummary {
225 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
226 where
227 S: serde::Serializer,
228 {
229 self.0.serialize(serializer)
230 }
231}
232
233impl<'de> Deserialize<'de> for CommitSummary {
234 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
235 where
236 D: serde::Deserializer<'de>,
237 {
238 let s = String::deserialize(deserializer)?;
239 if s.trim().is_empty() {
241 return Err(serde::de::Error::custom("commit summary cannot be empty"));
242 }
243 if s.len() > 128 {
244 return Err(serde::de::Error::custom(format!(
245 "commit summary must be ≤128 characters, got {}",
246 s.len()
247 )));
248 }
249 Ok(Self(s))
250 }
251}
252
253#[derive(Clone, PartialEq, Eq)]
255pub struct Scope(String);
256
257impl Scope {
258 pub fn new(s: impl Into<String>) -> Result<Self> {
265 let s = s.into();
266 let segments: Vec<&str> = s.split('/').collect();
267
268 if segments.len() > 2 {
269 return Err(CommitGenError::InvalidScope(format!(
270 "scope has {} segments, max 2 allowed",
271 segments.len()
272 )));
273 }
274
275 for segment in &segments {
276 if segment.is_empty() {
277 return Err(CommitGenError::InvalidScope("scope contains empty segment".to_string()));
278 }
279 if !segment
280 .chars()
281 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
282 {
283 return Err(CommitGenError::InvalidScope(format!(
284 "invalid characters in scope segment: {segment}"
285 )));
286 }
287 }
288
289 Ok(Self(s))
290 }
291
292 pub fn as_str(&self) -> &str {
294 &self.0
295 }
296
297 #[allow(dead_code, reason = "Public API method for scope manipulation")]
299 pub fn segments(&self) -> Vec<&str> {
300 self.0.split('/').collect()
301 }
302
303 pub const fn is_empty(&self) -> bool {
305 self.0.is_empty()
306 }
307}
308
309impl fmt::Display for Scope {
310 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311 write!(f, "{}", self.0)
312 }
313}
314
315impl fmt::Debug for Scope {
316 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317 f.debug_tuple("Scope").field(&self.0).finish()
318 }
319}
320
321impl Serialize for Scope {
322 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
323 where
324 S: serde::Serializer,
325 {
326 serializer.serialize_str(&self.0)
327 }
328}
329
330impl<'de> Deserialize<'de> for Scope {
331 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
332 where
333 D: serde::Deserializer<'de>,
334 {
335 let s = String::deserialize(deserializer)?;
336 Self::new(s).map_err(serde::de::Error::custom)
337 }
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ConventionalCommit {
342 pub commit_type: CommitType,
343 pub scope: Option<Scope>,
344 pub summary: CommitSummary,
345 pub body: Vec<String>,
346 pub footers: Vec<String>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct ConventionalAnalysis {
351 #[serde(rename = "type")]
352 pub commit_type: CommitType,
353 #[serde(default, deserialize_with = "deserialize_optional_scope")]
354 pub scope: Option<Scope>,
355 #[serde(default, deserialize_with = "deserialize_string_vec")]
356 pub body: Vec<String>,
357 #[serde(default, deserialize_with = "deserialize_string_vec")]
358 pub issue_refs: Vec<String>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
363pub struct SummaryOutput {
364 pub summary: String,
365}
366
367#[derive(Debug, Clone)]
369pub struct CommitMetadata {
370 pub hash: String,
371 pub author_name: String,
372 pub author_email: String,
373 pub author_date: String,
374 pub committer_name: String,
375 pub committer_email: String,
376 pub committer_date: String,
377 pub message: String,
378 pub parent_hashes: Vec<String>,
379 pub tree_hash: String,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct FileChange {
385 pub path: String,
386 pub hunks: Vec<String>, }
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct ChangeGroup {
392 pub changes: Vec<FileChange>,
393 #[serde(rename = "type")]
394 pub commit_type: CommitType,
395 pub scope: Option<Scope>,
396 pub rationale: String,
397 #[serde(default)]
398 pub dependencies: Vec<usize>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct ComposeAnalysis {
404 pub groups: Vec<ChangeGroup>,
405 pub dependency_order: Vec<usize>,
406}
407
408#[derive(Debug, Serialize)]
410#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
411pub struct Message {
412 pub role: String,
413 pub content: String,
414}
415
416#[derive(Debug, Serialize, Deserialize)]
417#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
418pub struct FunctionParameters {
419 #[serde(rename = "type")]
420 pub param_type: String,
421 pub properties: serde_json::Value,
422 pub required: Vec<String>,
423}
424
425#[derive(Debug, Serialize, Deserialize)]
426#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
427pub struct Function {
428 pub name: String,
429 pub description: String,
430 pub parameters: FunctionParameters,
431}
432
433#[derive(Debug, Serialize, Deserialize)]
434#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
435pub struct Tool {
436 #[serde(rename = "type")]
437 pub tool_type: String,
438 pub function: Function,
439}
440
441#[derive(Parser, Debug)]
443#[command(author, version, about = "Generate git commit messages using Claude AI", long_about = None)]
444pub struct Args {
445 #[arg(long, value_enum, default_value = "staged")]
447 pub mode: Mode,
448
449 #[arg(long)]
451 pub target: Option<String>,
452
453 #[arg(long)]
455 pub copy: bool,
456
457 #[arg(long)]
459 pub dry_run: bool,
460
461 #[arg(long, default_value = ".")]
463 pub dir: String,
464
465 #[arg(long, short = 'm')]
468 pub model: Option<String>,
469
470 #[arg(long)]
472 pub summary_model: Option<String>,
473
474 #[arg(long, short = 't')]
476 pub temperature: Option<f32>,
477
478 #[arg(long)]
480 pub fixes: Vec<String>,
481
482 #[arg(long)]
484 pub closes: Vec<String>,
485
486 #[arg(long)]
488 pub resolves: Vec<String>,
489
490 #[arg(long)]
492 pub refs: Vec<String>,
493
494 #[arg(long)]
496 pub breaking: bool,
497
498 #[arg(long)]
500 pub config: Option<PathBuf>,
501
502 #[arg(trailing_var_arg = true)]
505 pub context: Vec<String>,
506
507 #[arg(long, conflicts_with_all = ["mode", "target", "copy", "dry_run"])]
510 pub rewrite: bool,
511
512 #[arg(long, requires = "rewrite")]
514 pub rewrite_preview: Option<usize>,
515
516 #[arg(long, requires = "rewrite")]
518 pub rewrite_start: Option<String>,
519
520 #[arg(long, default_value = "10", requires = "rewrite")]
522 pub rewrite_parallel: usize,
523
524 #[arg(long, requires = "rewrite")]
526 pub rewrite_dry_run: bool,
527
528 #[arg(long, requires = "rewrite")]
530 pub rewrite_hide_old_types: bool,
531
532 #[arg(long)]
535 pub exclude_old_message: bool,
536
537 #[arg(long, conflicts_with_all = ["mode", "target", "rewrite"])]
540 pub compose: bool,
541
542 #[arg(long, requires = "compose")]
544 pub compose_preview: bool,
545
546 #[arg(long, requires = "compose")]
548 pub compose_interactive: bool,
549
550 #[arg(long, requires = "compose")]
552 pub compose_max_commits: Option<usize>,
553
554 #[arg(long, requires = "compose")]
556 pub compose_test_after_each: bool,
557}
558
559impl Default for Args {
560 fn default() -> Self {
561 Self {
562 mode: Mode::Staged,
563 target: None,
564 copy: false,
565 dry_run: false,
566 dir: ".".to_string(),
567 model: None,
568 summary_model: None,
569 temperature: None,
570 fixes: vec![],
571 closes: vec![],
572 resolves: vec![],
573 refs: vec![],
574 breaking: false,
575 config: None,
576 context: vec![],
577 rewrite: false,
578 rewrite_preview: None,
579 rewrite_start: None,
580 rewrite_parallel: 10,
581 rewrite_dry_run: false,
582 rewrite_hide_old_types: false,
583 exclude_old_message: false,
584 compose: false,
585 compose_preview: false,
586 compose_interactive: false,
587 compose_max_commits: None,
588 compose_test_after_each: false,
589 }
590 }
591}
592fn deserialize_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
593where
594 D: serde::Deserializer<'de>,
595{
596 let value = Value::deserialize(deserializer)?;
597 Ok(value_to_string_vec(value))
598}
599
600fn value_to_string_vec(value: Value) -> Vec<String> {
601 match value {
602 Value::Null => Vec::new(),
603 Value::String(s) => s
604 .lines()
605 .map(str::trim)
606 .filter(|s| !s.is_empty())
607 .map(|s| s.to_string())
608 .collect(),
609 Value::Array(arr) => arr
610 .into_iter()
611 .flat_map(|v| value_to_string_vec(v).into_iter())
612 .collect(),
613 Value::Object(map) => map
614 .into_iter()
615 .flat_map(|(k, v)| {
616 let values = value_to_string_vec(v);
617 if values.is_empty() {
618 vec![k]
619 } else {
620 values
621 .into_iter()
622 .map(|val| format!("{k}: {val}"))
623 .collect()
624 }
625 })
626 .collect(),
627 other => vec![other.to_string()],
628 }
629}
630
631fn deserialize_optional_scope<'de, D>(
632 deserializer: D,
633) -> std::result::Result<Option<Scope>, D::Error>
634where
635 D: serde::Deserializer<'de>,
636{
637 let value = Option::<String>::deserialize(deserializer)?;
638 match value {
639 None => Ok(None),
640 Some(scope_str) => {
641 let trimmed = scope_str.trim();
642 if trimmed.is_empty() {
643 Ok(None)
644 } else {
645 Scope::new(trimmed.to_string())
646 .map(Some)
647 .map_err(serde::de::Error::custom)
648 }
649 },
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
660 fn test_resolve_model_name() {
661 assert_eq!(resolve_model_name("sonnet"), "claude-sonnet-4.5");
663 assert_eq!(resolve_model_name("s"), "claude-sonnet-4.5");
664 assert_eq!(resolve_model_name("opus"), "claude-opus-4.1");
665 assert_eq!(resolve_model_name("o"), "claude-opus-4.1");
666 assert_eq!(resolve_model_name("haiku"), "claude-haiku-4-5");
667 assert_eq!(resolve_model_name("h"), "claude-haiku-4-5");
668
669 assert_eq!(resolve_model_name("gpt5"), "gpt-5");
671 assert_eq!(resolve_model_name("g5"), "gpt-5");
672
673 assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
675 assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
676
677 assert_eq!(resolve_model_name("claude-sonnet-4.5"), "claude-sonnet-4.5");
679 assert_eq!(resolve_model_name("custom-model"), "custom-model");
680 }
681
682 #[test]
685 fn test_commit_type_valid() {
686 let valid_types = [
687 "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
688 "revert",
689 ];
690
691 for ty in &valid_types {
692 assert!(CommitType::new(*ty).is_ok(), "Expected '{ty}' to be valid");
693 }
694 }
695
696 #[test]
697 fn test_commit_type_case_normalization() {
698 let ct = CommitType::new("FEAT").expect("FEAT should normalize");
700 assert_eq!(ct.as_str(), "feat");
701
702 let ct = CommitType::new("Fix").expect("Fix should normalize");
703 assert_eq!(ct.as_str(), "fix");
704
705 let ct = CommitType::new("ReFaCtOr").expect("ReFaCtOr should normalize");
706 assert_eq!(ct.as_str(), "refactor");
707 }
708
709 #[test]
710 fn test_commit_type_invalid() {
711 let invalid_types = ["invalid", "bug", "feature", "update", "change", "random", "xyz", "123"];
712
713 for ty in &invalid_types {
714 assert!(CommitType::new(*ty).is_err(), "Expected '{ty}' to be invalid");
715 }
716 }
717
718 #[test]
719 fn test_commit_type_empty() {
720 assert!(CommitType::new("").is_err(), "Empty string should be invalid");
721 }
722
723 #[test]
724 fn test_commit_type_display() {
725 let ct = CommitType::new("feat").unwrap();
726 assert_eq!(format!("{ct}"), "feat");
727 }
728
729 #[test]
730 fn test_commit_type_len() {
731 let ct = CommitType::new("feat").unwrap();
732 assert_eq!(ct.len(), 4);
733
734 let ct = CommitType::new("refactor").unwrap();
735 assert_eq!(ct.len(), 8);
736 }
737
738 #[test]
741 fn test_scope_valid_single_segment() {
742 let valid_scopes = ["core", "api", "lib", "client", "server", "ui", "test-123", "foo_bar"];
743
744 for scope in &valid_scopes {
745 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
746 }
747 }
748
749 #[test]
750 fn test_scope_valid_two_segments() {
751 let valid_scopes = ["api/client", "lib/core", "ui/components", "test-1/foo_2"];
752
753 for scope in &valid_scopes {
754 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
755 }
756 }
757
758 #[test]
759 fn test_scope_invalid_three_segments() {
760 let scope = Scope::new("a/b/c");
761 assert!(scope.is_err(), "Three segments should be invalid");
762
763 if let Err(CommitGenError::InvalidScope(msg)) = scope {
764 assert!(msg.contains("3 segments"));
765 } else {
766 panic!("Expected InvalidScope error");
767 }
768 }
769
770 #[test]
771 fn test_scope_invalid_uppercase() {
772 let invalid_scopes = ["Core", "API", "MyScope", "api/Client"];
773
774 for scope in &invalid_scopes {
775 assert!(Scope::new(*scope).is_err(), "Expected '{scope}' with uppercase to be invalid");
776 }
777 }
778
779 #[test]
780 fn test_scope_invalid_empty_segments() {
781 let invalid_scopes = ["", "a//b", "/foo", "bar/"];
782
783 for scope in &invalid_scopes {
784 assert!(
785 Scope::new(*scope).is_err(),
786 "Expected '{scope}' with empty segments to be invalid"
787 );
788 }
789 }
790
791 #[test]
792 fn test_scope_invalid_chars() {
793 let invalid_scopes = ["a b", "foo bar", "test@scope", "api/client!", "a.b"];
794
795 for scope in &invalid_scopes {
796 assert!(
797 Scope::new(*scope).is_err(),
798 "Expected '{scope}' with invalid chars to be invalid"
799 );
800 }
801 }
802
803 #[test]
804 fn test_scope_segments() {
805 let scope = Scope::new("core").unwrap();
806 assert_eq!(scope.segments(), vec!["core"]);
807
808 let scope = Scope::new("api/client").unwrap();
809 assert_eq!(scope.segments(), vec!["api", "client"]);
810 }
811
812 #[test]
813 fn test_scope_display() {
814 let scope = Scope::new("api/client").unwrap();
815 assert_eq!(format!("{scope}"), "api/client");
816 }
817
818 #[test]
821 fn test_commit_summary_valid() {
822 let summary_72 = "a".repeat(72);
823 let summary_96 = "a".repeat(96);
824 let summary_128 = "a".repeat(128);
825 let valid_summaries = [
826 "added new feature",
827 "fixed bug in authentication",
828 "x", summary_72.as_str(), summary_96.as_str(), summary_128.as_str(), ];
833
834 for summary in &valid_summaries {
835 assert!(
836 CommitSummary::new(*summary, 128).is_ok(),
837 "Expected '{}' (len={}) to be valid",
838 if summary.len() > 50 {
839 &summary[..50]
840 } else {
841 summary
842 },
843 summary.len()
844 );
845 }
846 }
847
848 #[test]
849 fn test_commit_summary_too_long() {
850 let long_summary = "a".repeat(129); let result = CommitSummary::new(long_summary, 128);
852 assert!(result.is_err(), "129 char summary should be invalid");
853
854 if let Err(CommitGenError::SummaryTooLong { len, max }) = result {
855 assert_eq!(len, 129);
856 assert_eq!(max, 128);
857 } else {
858 panic!("Expected SummaryTooLong error");
859 }
860 }
861
862 #[test]
863 fn test_commit_summary_empty() {
864 let empty_cases = ["", " ", "\t", "\n"];
865
866 for empty in &empty_cases {
867 assert!(
868 CommitSummary::new(*empty, 128).is_err(),
869 "Empty/whitespace-only summary should be invalid"
870 );
871 }
872 }
873
874 #[test]
875 fn test_commit_summary_warnings_uppercase_start() {
876 let result = CommitSummary::new("Added new feature", 128);
878 assert!(result.is_ok(), "Should succeed despite uppercase start");
879 }
880
881 #[test]
882 fn test_commit_summary_warnings_with_period() {
883 let result = CommitSummary::new("added new feature.", 128);
885 assert!(result.is_ok(), "Should succeed despite having period");
886 }
887
888 #[test]
889 fn test_commit_summary_new_unchecked() {
890 let result = CommitSummary::new_unchecked("Added feature", 128);
892 assert!(result.is_ok(), "new_unchecked should succeed");
893 }
894
895 #[test]
896 fn test_commit_summary_len() {
897 let summary = CommitSummary::new("hello world", 128).unwrap();
898 assert_eq!(summary.len(), 11);
899 }
900
901 #[test]
902 fn test_commit_summary_display() {
903 let summary = CommitSummary::new("fixed bug", 128).unwrap();
904 assert_eq!(format!("{summary}"), "fixed bug");
905 }
906
907 #[test]
910 fn test_commit_type_serialize() {
911 let ct = CommitType::new("feat").unwrap();
912 let json = serde_json::to_string(&ct).unwrap();
913 assert_eq!(json, "\"feat\"");
914 }
915
916 #[test]
917 fn test_commit_type_deserialize() {
918 let ct: CommitType = serde_json::from_str("\"fix\"").unwrap();
919 assert_eq!(ct.as_str(), "fix");
920
921 let result: serde_json::Result<CommitType> = serde_json::from_str("\"invalid\"");
923 assert!(result.is_err());
924 }
925
926 #[test]
927 fn test_scope_serialize() {
928 let scope = Scope::new("api/client").unwrap();
929 let json = serde_json::to_string(&scope).unwrap();
930 assert_eq!(json, "\"api/client\"");
931 }
932
933 #[test]
934 fn test_scope_deserialize() {
935 let scope: Scope = serde_json::from_str("\"core\"").unwrap();
936 assert_eq!(scope.as_str(), "core");
937
938 let result: serde_json::Result<Scope> = serde_json::from_str("\"INVALID\"");
940 assert!(result.is_err());
941 }
942
943 #[test]
944 fn test_commit_summary_serialize() {
945 let summary = CommitSummary::new("fixed bug", 128).unwrap();
946 let json = serde_json::to_string(&summary).unwrap();
947 assert_eq!(json, "\"fixed bug\"");
948 }
949
950 #[test]
951 fn test_commit_summary_deserialize() {
952 let summary: CommitSummary = serde_json::from_str("\"added feature\"").unwrap();
953 assert_eq!(summary.as_str(), "added feature");
954
955 let long = format!("\"{}\"", "a".repeat(129));
957 let result: serde_json::Result<CommitSummary> = serde_json::from_str(&long);
958 assert!(result.is_err());
959
960 let result: serde_json::Result<CommitSummary> = serde_json::from_str("\"\"");
962 assert!(result.is_err());
963 }
964
965 #[test]
966 fn test_conventional_commit_roundtrip() {
967 let commit = ConventionalCommit {
968 commit_type: CommitType::new("feat").unwrap(),
969 scope: Some(Scope::new("api").unwrap()),
970 summary: CommitSummary::new_unchecked("added endpoint", 128).unwrap(),
971 body: vec!["detail 1.".to_string(), "detail 2.".to_string()],
972 footers: vec!["Fixes: #123".to_string()],
973 };
974
975 let json = serde_json::to_string(&commit).unwrap();
976 let deserialized: ConventionalCommit = serde_json::from_str(&json).unwrap();
977
978 assert_eq!(deserialized.commit_type.as_str(), "feat");
979 assert_eq!(deserialized.scope.unwrap().as_str(), "api");
980 assert_eq!(deserialized.summary.as_str(), "added endpoint");
981 assert_eq!(deserialized.body.len(), 2);
982 assert_eq!(deserialized.footers.len(), 1);
983 }
984}