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)]
463 pub push: bool,
464
465 #[arg(long, default_value = ".")]
467 pub dir: String,
468
469 #[arg(long, short = 'm')]
472 pub model: Option<String>,
473
474 #[arg(long)]
476 pub summary_model: Option<String>,
477
478 #[arg(long, short = 't')]
480 pub temperature: Option<f32>,
481
482 #[arg(long)]
484 pub fixes: Vec<String>,
485
486 #[arg(long)]
488 pub closes: Vec<String>,
489
490 #[arg(long)]
492 pub resolves: Vec<String>,
493
494 #[arg(long)]
496 pub refs: Vec<String>,
497
498 #[arg(long)]
500 pub breaking: bool,
501
502 #[arg(long)]
504 pub config: Option<PathBuf>,
505
506 #[arg(trailing_var_arg = true)]
509 pub context: Vec<String>,
510
511 #[arg(long, conflicts_with_all = ["mode", "target", "copy", "dry_run"])]
514 pub rewrite: bool,
515
516 #[arg(long, requires = "rewrite")]
518 pub rewrite_preview: Option<usize>,
519
520 #[arg(long, requires = "rewrite")]
522 pub rewrite_start: Option<String>,
523
524 #[arg(long, default_value = "10", requires = "rewrite")]
526 pub rewrite_parallel: usize,
527
528 #[arg(long, requires = "rewrite")]
530 pub rewrite_dry_run: bool,
531
532 #[arg(long, requires = "rewrite")]
534 pub rewrite_hide_old_types: bool,
535
536 #[arg(long)]
539 pub exclude_old_message: bool,
540
541 #[arg(long, conflicts_with_all = ["mode", "target", "rewrite"])]
544 pub compose: bool,
545
546 #[arg(long, requires = "compose")]
548 pub compose_preview: bool,
549
550 #[arg(long, requires = "compose")]
552 pub compose_interactive: bool,
553
554 #[arg(long, requires = "compose")]
556 pub compose_max_commits: Option<usize>,
557
558 #[arg(long, requires = "compose")]
560 pub compose_test_after_each: bool,
561}
562
563impl Default for Args {
564 fn default() -> Self {
565 Self {
566 mode: Mode::Staged,
567 target: None,
568 copy: false,
569 dry_run: false,
570 push: false,
571 dir: ".".to_string(),
572 model: None,
573 summary_model: None,
574 temperature: None,
575 fixes: vec![],
576 closes: vec![],
577 resolves: vec![],
578 refs: vec![],
579 breaking: false,
580 config: None,
581 context: vec![],
582 rewrite: false,
583 rewrite_preview: None,
584 rewrite_start: None,
585 rewrite_parallel: 10,
586 rewrite_dry_run: false,
587 rewrite_hide_old_types: false,
588 exclude_old_message: false,
589 compose: false,
590 compose_preview: false,
591 compose_interactive: false,
592 compose_max_commits: None,
593 compose_test_after_each: false,
594 }
595 }
596}
597fn deserialize_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
598where
599 D: serde::Deserializer<'de>,
600{
601 let value = Value::deserialize(deserializer)?;
602 Ok(value_to_string_vec(value))
603}
604
605fn value_to_string_vec(value: Value) -> Vec<String> {
606 match value {
607 Value::Null => Vec::new(),
608 Value::String(s) => s
609 .lines()
610 .map(str::trim)
611 .filter(|s| !s.is_empty())
612 .map(|s| s.to_string())
613 .collect(),
614 Value::Array(arr) => arr
615 .into_iter()
616 .flat_map(|v| value_to_string_vec(v).into_iter())
617 .collect(),
618 Value::Object(map) => map
619 .into_iter()
620 .flat_map(|(k, v)| {
621 let values = value_to_string_vec(v);
622 if values.is_empty() {
623 vec![k]
624 } else {
625 values
626 .into_iter()
627 .map(|val| format!("{k}: {val}"))
628 .collect()
629 }
630 })
631 .collect(),
632 other => vec![other.to_string()],
633 }
634}
635
636fn deserialize_optional_scope<'de, D>(
637 deserializer: D,
638) -> std::result::Result<Option<Scope>, D::Error>
639where
640 D: serde::Deserializer<'de>,
641{
642 let value = Option::<String>::deserialize(deserializer)?;
643 match value {
644 None => Ok(None),
645 Some(scope_str) => {
646 let trimmed = scope_str.trim();
647 if trimmed.is_empty() {
648 Ok(None)
649 } else {
650 Scope::new(trimmed.to_string())
651 .map(Some)
652 .map_err(serde::de::Error::custom)
653 }
654 },
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
665 fn test_resolve_model_name() {
666 assert_eq!(resolve_model_name("sonnet"), "claude-sonnet-4.5");
668 assert_eq!(resolve_model_name("s"), "claude-sonnet-4.5");
669 assert_eq!(resolve_model_name("opus"), "claude-opus-4.1");
670 assert_eq!(resolve_model_name("o"), "claude-opus-4.1");
671 assert_eq!(resolve_model_name("haiku"), "claude-haiku-4-5");
672 assert_eq!(resolve_model_name("h"), "claude-haiku-4-5");
673
674 assert_eq!(resolve_model_name("gpt5"), "gpt-5");
676 assert_eq!(resolve_model_name("g5"), "gpt-5");
677
678 assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
680 assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
681
682 assert_eq!(resolve_model_name("claude-sonnet-4.5"), "claude-sonnet-4.5");
684 assert_eq!(resolve_model_name("custom-model"), "custom-model");
685 }
686
687 #[test]
690 fn test_commit_type_valid() {
691 let valid_types = [
692 "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
693 "revert",
694 ];
695
696 for ty in &valid_types {
697 assert!(CommitType::new(*ty).is_ok(), "Expected '{ty}' to be valid");
698 }
699 }
700
701 #[test]
702 fn test_commit_type_case_normalization() {
703 let ct = CommitType::new("FEAT").expect("FEAT should normalize");
705 assert_eq!(ct.as_str(), "feat");
706
707 let ct = CommitType::new("Fix").expect("Fix should normalize");
708 assert_eq!(ct.as_str(), "fix");
709
710 let ct = CommitType::new("ReFaCtOr").expect("ReFaCtOr should normalize");
711 assert_eq!(ct.as_str(), "refactor");
712 }
713
714 #[test]
715 fn test_commit_type_invalid() {
716 let invalid_types = ["invalid", "bug", "feature", "update", "change", "random", "xyz", "123"];
717
718 for ty in &invalid_types {
719 assert!(CommitType::new(*ty).is_err(), "Expected '{ty}' to be invalid");
720 }
721 }
722
723 #[test]
724 fn test_commit_type_empty() {
725 assert!(CommitType::new("").is_err(), "Empty string should be invalid");
726 }
727
728 #[test]
729 fn test_commit_type_display() {
730 let ct = CommitType::new("feat").unwrap();
731 assert_eq!(format!("{ct}"), "feat");
732 }
733
734 #[test]
735 fn test_commit_type_len() {
736 let ct = CommitType::new("feat").unwrap();
737 assert_eq!(ct.len(), 4);
738
739 let ct = CommitType::new("refactor").unwrap();
740 assert_eq!(ct.len(), 8);
741 }
742
743 #[test]
746 fn test_scope_valid_single_segment() {
747 let valid_scopes = ["core", "api", "lib", "client", "server", "ui", "test-123", "foo_bar"];
748
749 for scope in &valid_scopes {
750 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
751 }
752 }
753
754 #[test]
755 fn test_scope_valid_two_segments() {
756 let valid_scopes = ["api/client", "lib/core", "ui/components", "test-1/foo_2"];
757
758 for scope in &valid_scopes {
759 assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
760 }
761 }
762
763 #[test]
764 fn test_scope_invalid_three_segments() {
765 let scope = Scope::new("a/b/c");
766 assert!(scope.is_err(), "Three segments should be invalid");
767
768 if let Err(CommitGenError::InvalidScope(msg)) = scope {
769 assert!(msg.contains("3 segments"));
770 } else {
771 panic!("Expected InvalidScope error");
772 }
773 }
774
775 #[test]
776 fn test_scope_invalid_uppercase() {
777 let invalid_scopes = ["Core", "API", "MyScope", "api/Client"];
778
779 for scope in &invalid_scopes {
780 assert!(Scope::new(*scope).is_err(), "Expected '{scope}' with uppercase to be invalid");
781 }
782 }
783
784 #[test]
785 fn test_scope_invalid_empty_segments() {
786 let invalid_scopes = ["", "a//b", "/foo", "bar/"];
787
788 for scope in &invalid_scopes {
789 assert!(
790 Scope::new(*scope).is_err(),
791 "Expected '{scope}' with empty segments to be invalid"
792 );
793 }
794 }
795
796 #[test]
797 fn test_scope_invalid_chars() {
798 let invalid_scopes = ["a b", "foo bar", "test@scope", "api/client!", "a.b"];
799
800 for scope in &invalid_scopes {
801 assert!(
802 Scope::new(*scope).is_err(),
803 "Expected '{scope}' with invalid chars to be invalid"
804 );
805 }
806 }
807
808 #[test]
809 fn test_scope_segments() {
810 let scope = Scope::new("core").unwrap();
811 assert_eq!(scope.segments(), vec!["core"]);
812
813 let scope = Scope::new("api/client").unwrap();
814 assert_eq!(scope.segments(), vec!["api", "client"]);
815 }
816
817 #[test]
818 fn test_scope_display() {
819 let scope = Scope::new("api/client").unwrap();
820 assert_eq!(format!("{scope}"), "api/client");
821 }
822
823 #[test]
826 fn test_commit_summary_valid() {
827 let summary_72 = "a".repeat(72);
828 let summary_96 = "a".repeat(96);
829 let summary_128 = "a".repeat(128);
830 let valid_summaries = [
831 "added new feature",
832 "fixed bug in authentication",
833 "x", summary_72.as_str(), summary_96.as_str(), summary_128.as_str(), ];
838
839 for summary in &valid_summaries {
840 assert!(
841 CommitSummary::new(*summary, 128).is_ok(),
842 "Expected '{}' (len={}) to be valid",
843 if summary.len() > 50 {
844 &summary[..50]
845 } else {
846 summary
847 },
848 summary.len()
849 );
850 }
851 }
852
853 #[test]
854 fn test_commit_summary_too_long() {
855 let long_summary = "a".repeat(129); let result = CommitSummary::new(long_summary, 128);
857 assert!(result.is_err(), "129 char summary should be invalid");
858
859 if let Err(CommitGenError::SummaryTooLong { len, max }) = result {
860 assert_eq!(len, 129);
861 assert_eq!(max, 128);
862 } else {
863 panic!("Expected SummaryTooLong error");
864 }
865 }
866
867 #[test]
868 fn test_commit_summary_empty() {
869 let empty_cases = ["", " ", "\t", "\n"];
870
871 for empty in &empty_cases {
872 assert!(
873 CommitSummary::new(*empty, 128).is_err(),
874 "Empty/whitespace-only summary should be invalid"
875 );
876 }
877 }
878
879 #[test]
880 fn test_commit_summary_warnings_uppercase_start() {
881 let result = CommitSummary::new("Added new feature", 128);
883 assert!(result.is_ok(), "Should succeed despite uppercase start");
884 }
885
886 #[test]
887 fn test_commit_summary_warnings_with_period() {
888 let result = CommitSummary::new("added new feature.", 128);
890 assert!(result.is_ok(), "Should succeed despite having period");
891 }
892
893 #[test]
894 fn test_commit_summary_new_unchecked() {
895 let result = CommitSummary::new_unchecked("Added feature", 128);
897 assert!(result.is_ok(), "new_unchecked should succeed");
898 }
899
900 #[test]
901 fn test_commit_summary_len() {
902 let summary = CommitSummary::new("hello world", 128).unwrap();
903 assert_eq!(summary.len(), 11);
904 }
905
906 #[test]
907 fn test_commit_summary_display() {
908 let summary = CommitSummary::new("fixed bug", 128).unwrap();
909 assert_eq!(format!("{summary}"), "fixed bug");
910 }
911
912 #[test]
915 fn test_commit_type_serialize() {
916 let ct = CommitType::new("feat").unwrap();
917 let json = serde_json::to_string(&ct).unwrap();
918 assert_eq!(json, "\"feat\"");
919 }
920
921 #[test]
922 fn test_commit_type_deserialize() {
923 let ct: CommitType = serde_json::from_str("\"fix\"").unwrap();
924 assert_eq!(ct.as_str(), "fix");
925
926 let result: serde_json::Result<CommitType> = serde_json::from_str("\"invalid\"");
928 assert!(result.is_err());
929 }
930
931 #[test]
932 fn test_scope_serialize() {
933 let scope = Scope::new("api/client").unwrap();
934 let json = serde_json::to_string(&scope).unwrap();
935 assert_eq!(json, "\"api/client\"");
936 }
937
938 #[test]
939 fn test_scope_deserialize() {
940 let scope: Scope = serde_json::from_str("\"core\"").unwrap();
941 assert_eq!(scope.as_str(), "core");
942
943 let result: serde_json::Result<Scope> = serde_json::from_str("\"INVALID\"");
945 assert!(result.is_err());
946 }
947
948 #[test]
949 fn test_commit_summary_serialize() {
950 let summary = CommitSummary::new("fixed bug", 128).unwrap();
951 let json = serde_json::to_string(&summary).unwrap();
952 assert_eq!(json, "\"fixed bug\"");
953 }
954
955 #[test]
956 fn test_commit_summary_deserialize() {
957 let summary: CommitSummary = serde_json::from_str("\"added feature\"").unwrap();
958 assert_eq!(summary.as_str(), "added feature");
959
960 let long = format!("\"{}\"", "a".repeat(129));
962 let result: serde_json::Result<CommitSummary> = serde_json::from_str(&long);
963 assert!(result.is_err());
964
965 let result: serde_json::Result<CommitSummary> = serde_json::from_str("\"\"");
967 assert!(result.is_err());
968 }
969
970 #[test]
971 fn test_conventional_commit_roundtrip() {
972 let commit = ConventionalCommit {
973 commit_type: CommitType::new("feat").unwrap(),
974 scope: Some(Scope::new("api").unwrap()),
975 summary: CommitSummary::new_unchecked("added endpoint", 128).unwrap(),
976 body: vec!["detail 1.".to_string(), "detail 2.".to_string()],
977 footers: vec!["Fixes: #123".to_string()],
978 };
979
980 let json = serde_json::to_string(&commit).unwrap();
981 let deserialized: ConventionalCommit = serde_json::from_str(&json).unwrap();
982
983 assert_eq!(deserialized.commit_type.as_str(), "feat");
984 assert_eq!(deserialized.scope.unwrap().as_str(), "api");
985 assert_eq!(deserialized.summary.as_str(), "added endpoint");
986 assert_eq!(deserialized.body.len(), 2);
987 assert_eq!(deserialized.footers.len(), 1);
988 }
989}