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   /// Push changes after committing
462   #[arg(long)]
463   pub push: bool,
464
465   /// Directory to run git commands in
466   #[arg(long, default_value = ".")]
467   pub dir: String,
468
469   /// Model for analysis (default: sonnet). Use short names (sonnet/opus/haiku)
470   /// or full names
471   #[arg(long, short = 'm')]
472   pub model: Option<String>,
473
474   /// Model for summary creation (default: haiku)
475   #[arg(long)]
476   pub summary_model: Option<String>,
477
478   /// Temperature for API calls (0.0-1.0, default: 1.0)
479   #[arg(long, short = 't')]
480   pub temperature: Option<f32>,
481
482   /// Issue numbers this commit fixes (e.g., --fixes 123 456)
483   #[arg(long)]
484   pub fixes: Vec<String>,
485
486   /// Issue numbers this commit closes (alias for --fixes)
487   #[arg(long)]
488   pub closes: Vec<String>,
489
490   /// Issue numbers this commit resolves (alias for --fixes)
491   #[arg(long)]
492   pub resolves: Vec<String>,
493
494   /// Related issue numbers (e.g., --refs 789)
495   #[arg(long)]
496   pub refs: Vec<String>,
497
498   /// Mark this commit as a breaking change
499   #[arg(long)]
500   pub breaking: bool,
501
502   /// Path to config file (default: ~/.config/llm-git/config.toml)
503   #[arg(long)]
504   pub config: Option<PathBuf>,
505
506   /// Additional context to provide to the analysis model (all trailing
507   /// non-flag text)
508   #[arg(trailing_var_arg = true)]
509   pub context: Vec<String>,
510
511   // === Rewrite mode args ===
512   /// Rewrite git history to conventional commits
513   #[arg(long, conflicts_with_all = ["mode", "target", "copy", "dry_run"])]
514   pub rewrite: bool,
515
516   /// Preview N commits without rewriting
517   #[arg(long, requires = "rewrite")]
518   pub rewrite_preview: Option<usize>,
519
520   /// Start from this ref (exclusive, e.g., main~50)
521   #[arg(long, requires = "rewrite")]
522   pub rewrite_start: Option<String>,
523
524   /// Number of parallel API calls
525   #[arg(long, default_value = "10", requires = "rewrite")]
526   pub rewrite_parallel: usize,
527
528   /// Dry run - show what would be changed
529   #[arg(long, requires = "rewrite")]
530   pub rewrite_dry_run: bool,
531
532   /// Hide old commit type/scope tags to avoid model influence
533   #[arg(long, requires = "rewrite")]
534   pub rewrite_hide_old_types: bool,
535
536   /// Exclude old commit message from context when analyzing commits (prevents
537   /// contamination)
538   #[arg(long)]
539   pub exclude_old_message: bool,
540
541   // === Compose mode args ===
542   /// Compose changes into multiple atomic commits
543   #[arg(long, conflicts_with_all = ["mode", "target", "rewrite"])]
544   pub compose: bool,
545
546   /// Preview proposed splits without committing
547   #[arg(long, requires = "compose")]
548   pub compose_preview: bool,
549
550   /// Allow interactive refinement of splits
551   #[arg(long, requires = "compose")]
552   pub compose_interactive: bool,
553
554   /// Maximum number of commits to create
555   #[arg(long, requires = "compose")]
556   pub compose_max_commits: Option<usize>,
557
558   /// Run tests after each commit
559   #[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   // ========== resolve_model_name Tests ==========
663
664   #[test]
665   fn test_resolve_model_name() {
666      // Claude short names
667      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      // GPT short names
675      assert_eq!(resolve_model_name("gpt5"), "gpt-5");
676      assert_eq!(resolve_model_name("g5"), "gpt-5");
677
678      // Gemini short names
679      assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
680      assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
681
682      // Pass-through for full names
683      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   // ========== CommitType Tests ==========
688
689   #[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      // Uppercase should normalize to lowercase
704      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   // ========== Scope Tests ==========
744
745   #[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   // ========== CommitSummary Tests ==========
824
825   #[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",                  // 1 char
834         summary_72.as_str(),  // exactly 72 chars (guideline)
835         summary_96.as_str(),  // exactly 96 chars (soft limit)
836         summary_128.as_str(), // exactly 128 chars (hard limit)
837      ];
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); // 129 chars (exceeds hard limit)
856      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      // Should succeed but emit warning
882      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      // Should succeed but emit warning (periods not allowed in conventional commits)
889      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      // new_unchecked should not emit warnings (internal use)
896      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   // ========== Serialization Tests ==========
913
914   #[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      // Invalid type should fail deserialization
927      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      // Invalid scope should fail deserialization
944      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      // Too long should fail (>128 chars)
961      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      // Empty should fail
966      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}