llm_git/
types.rs

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   /// Analyze staged changes
12   Staged,
13   /// Analyze a specific commit
14   Commit,
15   /// Analyze unstaged changes
16   Unstaged,
17   /// Compose changes into multiple commits
18   Compose,
19}
20
21/// Resolve model name from short aliases to full `LiteLLM` model names
22pub fn resolve_model_name(name: &str) -> String {
23   match name {
24      // Claude short names
25      "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      // GPT short names
32      "gpt5" | "g5" => "gpt-5",
33      "gpt5-pro" => "gpt-5-pro",
34      "gpt5-mini" => "gpt-5-mini",
35      "gpt5-codex" => "gpt-5-codex",
36
37      // o-series short names
38      "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 short names
46      "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      // Cerebras
51      "qwen" | "q480b" => "qwen-3-coder-480b",
52
53      // GLM models
54      "glm4.6" => "glm-4.6",
55      "glm4.5" => "glm-4.5",
56      "glm-air" => "glm-4.5-air",
57
58      // Otherwise pass through as-is (allows full model names)
59      _ => name,
60   }
61   .to_string()
62}
63
64/// Scope candidate with metadata for inference
65#[derive(Debug, Clone)]
66pub struct ScopeCandidate {
67   pub path:       String,
68   pub percentage: f32,
69   pub confidence: f32,
70}
71
72/// Type-safe commit type with validation
73#[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   /// Create new `CommitType` with validation
82   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   /// Returns inner string slice
98   pub fn as_str(&self) -> &str {
99      &self.0
100   }
101
102   /// Returns length of commit type
103   pub const fn len(&self) -> usize {
104      self.0.len()
105   }
106
107   /// Checks if commit type is empty
108   #[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/// Type-safe commit summary with validation
146#[derive(Clone)]
147pub struct CommitSummary(String);
148
149impl CommitSummary {
150   /// Creates new `CommitSummary` with strict length validation and format
151   /// warnings
152   pub fn new(s: impl Into<String>, max_len: usize) -> Result<Self> {
153      Self::new_impl(s, max_len, true)
154   }
155
156   /// Internal constructor allowing warning suppression (used by
157   /// post-processing)
158   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      // Strict validation: must not be empty
166      if s.trim().is_empty() {
167         return Err(CommitGenError::ValidationError("commit summary cannot be empty".to_string()));
168      }
169
170      // Strict validation: must be ≤ max_len characters (hard limit from config)
171      if s.len() > max_len {
172         return Err(CommitGenError::SummaryTooLong { len: s.len(), max: max_len });
173      }
174
175      if emit_warnings {
176         // Warning-only: should start with lowercase
177         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         // Warning-only: should NOT end with period (conventional commits style)
184         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   /// Returns inner string slice
196   pub fn as_str(&self) -> &str {
197      &self.0
198   }
199
200   /// Returns length of summary
201   pub const fn len(&self) -> usize {
202      self.0.len()
203   }
204
205   /// Checks if summary is empty
206   #[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      // During deserialization, bypass warnings to avoid console spam
240      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/// Type-safe scope for conventional commits
254#[derive(Clone, PartialEq, Eq)]
255pub struct Scope(String);
256
257impl Scope {
258   /// Creates new scope with validation
259   ///
260   /// Rules:
261   /// - Max 2 segments separated by `/`
262   /// - Only lowercase alphanumeric with `/`, `-`, `_`
263   /// - No empty segments
264   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   /// Returns inner string slice
293   pub fn as_str(&self) -> &str {
294      &self.0
295   }
296
297   /// Splits scope by `/` into segments
298   #[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   /// Check if scope is empty
304   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/// Metadata for a single commit during history rewrite
368#[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/// File change with specific hunks
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct FileChange {
385   pub path:  String,
386   pub hunks: Vec<String>, // Hunk headers (e.g., "@@ -10,5 +10,7 @@") or "ALL" for entire file
387}
388
389/// Represents a logical group of changes for compose mode
390#[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/// Result of compose analysis
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct ComposeAnalysis {
404   pub groups:           Vec<ChangeGroup>,
405   pub dependency_order: Vec<usize>,
406}
407
408// API types for OpenRouter/LiteLLM communication
409#[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// CLI Args
442#[derive(Parser, Debug)]
443#[command(author, version, about = "Generate git commit messages using Claude AI", long_about = None)]
444pub struct Args {
445   /// What to analyze
446   #[arg(long, value_enum, default_value = "staged")]
447   pub mode: Mode,
448
449   /// Commit hash/ref when using --mode=commit
450   #[arg(long)]
451   pub target: Option<String>,
452
453   /// Copy the message to clipboard
454   #[arg(long)]
455   pub copy: bool,
456
457   /// Preview without committing (default is to commit for staged mode)
458   #[arg(long)]
459   pub dry_run: bool,
460
461   /// Directory to run git commands in
462   #[arg(long, default_value = ".")]
463   pub dir: String,
464
465   /// Model for analysis (default: sonnet). Use short names (sonnet/opus/haiku)
466   /// or full names
467   #[arg(long, short = 'm')]
468   pub model: Option<String>,
469
470   /// Model for summary creation (default: haiku)
471   #[arg(long)]
472   pub summary_model: Option<String>,
473
474   /// Temperature for API calls (0.0-1.0, default: 1.0)
475   #[arg(long, short = 't')]
476   pub temperature: Option<f32>,
477
478   /// Issue numbers this commit fixes (e.g., --fixes 123 456)
479   #[arg(long)]
480   pub fixes: Vec<String>,
481
482   /// Issue numbers this commit closes (alias for --fixes)
483   #[arg(long)]
484   pub closes: Vec<String>,
485
486   /// Issue numbers this commit resolves (alias for --fixes)
487   #[arg(long)]
488   pub resolves: Vec<String>,
489
490   /// Related issue numbers (e.g., --refs 789)
491   #[arg(long)]
492   pub refs: Vec<String>,
493
494   /// Mark this commit as a breaking change
495   #[arg(long)]
496   pub breaking: bool,
497
498   /// Path to config file (default: ~/.config/llm-git/config.toml)
499   #[arg(long)]
500   pub config: Option<PathBuf>,
501
502   /// Additional context to provide to the analysis model (all trailing
503   /// non-flag text)
504   #[arg(trailing_var_arg = true)]
505   pub context: Vec<String>,
506
507   // === Rewrite mode args ===
508   /// Rewrite git history to conventional commits
509   #[arg(long, conflicts_with_all = ["mode", "target", "copy", "dry_run"])]
510   pub rewrite: bool,
511
512   /// Preview N commits without rewriting
513   #[arg(long, requires = "rewrite")]
514   pub rewrite_preview: Option<usize>,
515
516   /// Start from this ref (exclusive, e.g., main~50)
517   #[arg(long, requires = "rewrite")]
518   pub rewrite_start: Option<String>,
519
520   /// Number of parallel API calls
521   #[arg(long, default_value = "10", requires = "rewrite")]
522   pub rewrite_parallel: usize,
523
524   /// Dry run - show what would be changed
525   #[arg(long, requires = "rewrite")]
526   pub rewrite_dry_run: bool,
527
528   /// Hide old commit type/scope tags to avoid model influence
529   #[arg(long, requires = "rewrite")]
530   pub rewrite_hide_old_types: bool,
531
532   /// Exclude old commit message from context when analyzing commits (prevents
533   /// contamination)
534   #[arg(long)]
535   pub exclude_old_message: bool,
536
537   // === Compose mode args ===
538   /// Compose changes into multiple atomic commits
539   #[arg(long, conflicts_with_all = ["mode", "target", "rewrite"])]
540   pub compose: bool,
541
542   /// Preview proposed splits without committing
543   #[arg(long, requires = "compose")]
544   pub compose_preview: bool,
545
546   /// Allow interactive refinement of splits
547   #[arg(long, requires = "compose")]
548   pub compose_interactive: bool,
549
550   /// Maximum number of commits to create
551   #[arg(long, requires = "compose")]
552   pub compose_max_commits: Option<usize>,
553
554   /// Run tests after each commit
555   #[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   // ========== resolve_model_name Tests ==========
658
659   #[test]
660   fn test_resolve_model_name() {
661      // Claude short names
662      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      // GPT short names
670      assert_eq!(resolve_model_name("gpt5"), "gpt-5");
671      assert_eq!(resolve_model_name("g5"), "gpt-5");
672
673      // Gemini short names
674      assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
675      assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
676
677      // Pass-through for full names
678      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   // ========== CommitType Tests ==========
683
684   #[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      // Uppercase should normalize to lowercase
699      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   // ========== Scope Tests ==========
739
740   #[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   // ========== CommitSummary Tests ==========
819
820   #[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",                  // 1 char
829         summary_72.as_str(),  // exactly 72 chars (guideline)
830         summary_96.as_str(),  // exactly 96 chars (soft limit)
831         summary_128.as_str(), // exactly 128 chars (hard limit)
832      ];
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); // 129 chars (exceeds hard limit)
851      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      // Should succeed but emit warning
877      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      // Should succeed but emit warning (periods not allowed in conventional commits)
884      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      // new_unchecked should not emit warnings (internal use)
891      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   // ========== Serialization Tests ==========
908
909   #[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      // Invalid type should fail deserialization
922      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      // Invalid scope should fail deserialization
939      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      // Too long should fail (>128 chars)
956      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      // Empty should fail
961      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}