llm_git/
types.rs

1use std::{collections::HashMap, fmt, path::PathBuf};
2
3use clap::{Parser, ValueEnum};
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::error::{CommitGenError, Result};
9
10// === Commit type configuration ===
11
12/// Configuration for a commit type (feat, fix, refactor, etc.)
13#[derive(Debug, Clone, Default, Deserialize, Serialize)]
14pub struct TypeConfig {
15   /// When to use this type
16   pub description: String,
17
18   /// Code patterns in diffs that indicate this type
19   #[serde(default)]
20   pub diff_indicators: Vec<String>,
21
22   /// File patterns that suggest this type (e.g., "*.md" for docs)
23   #[serde(default)]
24   pub file_patterns: Vec<String>,
25
26   /// Example scenarios for this type
27   #[serde(default)]
28   pub examples: Vec<String>,
29
30   /// Per-type hint for classification guidance
31   #[serde(default)]
32   pub hint: String,
33}
34
35/// Match rules for mapping commits to changelog categories
36#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37pub struct CategoryMatch {
38   /// Match if commit type is one of these
39   #[serde(default)]
40   pub types:         Vec<String>,
41   /// Match if body contains any of these strings (case-insensitive)
42   #[serde(default)]
43   pub body_contains: Vec<String>,
44}
45
46/// Configuration for a changelog category
47#[derive(Debug, Clone, Deserialize, Serialize)]
48pub struct CategoryConfig {
49   /// Category name (e.g., "Added", "Fixed")
50   pub name:    String,
51   /// Display header in changelog (defaults to name if not set)
52   #[serde(default)]
53   pub header:  Option<String>,
54   /// Match rules for this category
55   #[serde(default)]
56   pub r#match: CategoryMatch,
57   /// If true, this is the fallback category when no other matches
58   #[serde(default)]
59   pub default: bool,
60}
61
62impl CategoryConfig {
63   /// Get the header to display in changelog
64   pub fn header(&self) -> &str {
65      self.header.as_deref().unwrap_or(&self.name)
66   }
67}
68
69/// Default commit types with rich guidance for AI prompts
70/// Order defines priority: first type checked first in decision tree
71pub fn default_types() -> IndexMap<String, TypeConfig> {
72   IndexMap::from([
73      ("feat".to_string(), TypeConfig {
74         description: "New public API surface OR user-observable capability/behavior change"
75            .to_string(),
76         diff_indicators: vec![
77            "pub fn".to_string(),
78            "pub struct".to_string(),
79            "pub enum".to_string(),
80            "export function".to_string(),
81            "#[arg]".to_string(),
82         ],
83         file_patterns: vec![],
84         examples: vec![
85            "Added pub fn process_batch() → feat (new API)".to_string(),
86            "Migrated HTTP client to async → feat (behavior change)".to_string(),
87         ],
88         ..Default::default()
89      }),
90      ("fix".to_string(), TypeConfig {
91         description: "Fixes incorrect behavior (bugs, crashes, wrong outputs, race conditions)"
92            .to_string(),
93         diff_indicators: vec![
94            "unwrap() → ?".to_string(),
95            "bounds check".to_string(),
96            "off-by-one".to_string(),
97            "error handling".to_string(),
98         ],
99         ..Default::default()
100      }),
101      ("refactor".to_string(), TypeConfig {
102         description: "Internal restructuring with provably unchanged behavior".to_string(),
103         diff_indicators: vec![
104            "rename".to_string(),
105            "extract".to_string(),
106            "consolidate".to_string(),
107            "reorganize".to_string(),
108         ],
109         examples: vec!["Renamed internal module structure → refactor (no API change)".to_string()],
110         hint: "Requires proof: same tests pass, same API. If behavior changes, use feat."
111            .to_string(),
112         ..Default::default()
113      }),
114      ("docs".to_string(), TypeConfig {
115         description: "Documentation only changes".to_string(),
116         file_patterns: vec!["*.md".to_string(), "doc comments".to_string()],
117         ..Default::default()
118      }),
119      ("test".to_string(), TypeConfig {
120         description: "Adding or modifying tests".to_string(),
121         file_patterns: vec![
122            "*_test.rs".to_string(),
123            "tests/".to_string(),
124            "*.test.ts".to_string(),
125         ],
126         ..Default::default()
127      }),
128      ("chore".to_string(), TypeConfig {
129         description: "Maintenance tasks, dependencies, tooling".to_string(),
130         file_patterns: vec![
131            ".gitignore".to_string(),
132            "*.lock".to_string(),
133            "config files".to_string(),
134         ],
135         ..Default::default()
136      }),
137      ("style".to_string(), TypeConfig {
138         description: "Formatting, whitespace changes (no logic change)".to_string(),
139         diff_indicators: vec!["whitespace".to_string(), "formatting".to_string()],
140         hint: "Variable/function renames are refactor, not style.".to_string(),
141         ..Default::default()
142      }),
143      ("perf".to_string(), TypeConfig {
144         description: "Performance improvements (proven faster)".to_string(),
145         diff_indicators: vec![
146            "optimization".to_string(),
147            "cache".to_string(),
148            "batch".to_string(),
149         ],
150         ..Default::default()
151      }),
152      ("build".to_string(), TypeConfig {
153         description: "Build system, dependency changes".to_string(),
154         file_patterns: vec![
155            "Cargo.toml".to_string(),
156            "package.json".to_string(),
157            "Makefile".to_string(),
158         ],
159         ..Default::default()
160      }),
161      ("ci".to_string(), TypeConfig {
162         description: "CI/CD configuration".to_string(),
163         file_patterns: vec![".github/workflows/".to_string(), ".gitlab-ci.yml".to_string()],
164         ..Default::default()
165      }),
166      ("revert".to_string(), TypeConfig {
167         description: "Reverts a previous commit".to_string(),
168         diff_indicators: vec!["Revert".to_string()],
169         ..Default::default()
170      }),
171   ])
172}
173
174/// Default global hint for cross-type disambiguation
175pub fn default_classifier_hint() -> String {
176   r"CRITICAL - feat vs refactor:
177- feat: ANY observable behavior change OR new public API
178- refactor: ONLY when provably unchanged (same tests, same API)
179When in doubt, prefer feat over refactor."
180      .to_string()
181}
182
183/// Default categories matching current hardcoded behavior
184/// Order defines render order; `body_contains` checked before types
185pub fn default_categories() -> Vec<CategoryConfig> {
186   vec![
187      CategoryConfig {
188         name:    "Breaking".to_string(),
189         header:  Some("Breaking Changes".to_string()),
190         r#match: CategoryMatch {
191            types:         vec![],
192            body_contains: vec!["breaking".to_string(), "incompatible".to_string()],
193         },
194         default: false,
195      },
196      CategoryConfig {
197         name:    "Added".to_string(),
198         header:  None,
199         r#match: CategoryMatch { types: vec!["feat".to_string()], body_contains: vec![] },
200         default: false,
201      },
202      CategoryConfig {
203         name:    "Changed".to_string(),
204         header:  None,
205         r#match: CategoryMatch::default(),
206         default: true,
207      },
208      CategoryConfig {
209         name:    "Deprecated".to_string(),
210         header:  None,
211         r#match: CategoryMatch::default(),
212         default: false,
213      },
214      CategoryConfig {
215         name:    "Removed".to_string(),
216         header:  None,
217         r#match: CategoryMatch {
218            types:         vec!["revert".to_string()],
219            body_contains: vec![],
220         },
221         default: false,
222      },
223      CategoryConfig {
224         name:    "Fixed".to_string(),
225         header:  None,
226         r#match: CategoryMatch { types: vec!["fix".to_string()], body_contains: vec![] },
227         default: false,
228      },
229      CategoryConfig {
230         name:    "Security".to_string(),
231         header:  None,
232         r#match: CategoryMatch::default(),
233         default: false,
234      },
235   ]
236}
237
238// === Changelog types ===
239
240/// Category for changelog entries (Keep a Changelog format)
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
242pub enum ChangelogCategory {
243   Added,
244   Changed,
245   Fixed,
246   Deprecated,
247   Removed,
248   Security,
249   Breaking,
250}
251
252impl ChangelogCategory {
253   /// Display name for changelog section headers
254   pub const fn as_str(&self) -> &'static str {
255      match self {
256         Self::Added => "Added",
257         Self::Changed => "Changed",
258         Self::Fixed => "Fixed",
259         Self::Deprecated => "Deprecated",
260         Self::Removed => "Removed",
261         Self::Security => "Security",
262         Self::Breaking => "Breaking Changes",
263      }
264   }
265
266   /// Parse category from name string (case-insensitive)
267   /// Falls back to Changed for unknown names
268   #[must_use]
269   pub fn from_name(name: &str) -> Self {
270      match name.to_lowercase().as_str() {
271         "added" => Self::Added,
272         "changed" => Self::Changed,
273         "fixed" => Self::Fixed,
274         "deprecated" => Self::Deprecated,
275         "removed" => Self::Removed,
276         "security" => Self::Security,
277         "breaking" | "breaking changes" => Self::Breaking,
278         _ => Self::Changed,
279      }
280   }
281
282   /// Map commit type to changelog category (legacy method, prefer config-based
283   /// `resolve_category`)
284   pub fn from_commit_type(commit_type: &str, body: &[String]) -> Self {
285      // Check body for breaking change indicators
286      let has_breaking = body.iter().any(|s| {
287         let lower = s.to_lowercase();
288         lower.contains("breaking") || lower.contains("incompatible")
289      });
290
291      if has_breaking {
292         return Self::Breaking;
293      }
294
295      match commit_type {
296         "feat" => Self::Added,
297         "fix" => Self::Fixed,
298         "revert" => Self::Removed,
299         // Everything else: refactor, perf, docs, test, style, build, ci, chore
300         _ => Self::Changed,
301      }
302   }
303
304   /// Order for rendering in changelog (Breaking first, then standard order)
305   pub const fn render_order() -> &'static [Self] {
306      &[
307         Self::Breaking,
308         Self::Added,
309         Self::Changed,
310         Self::Deprecated,
311         Self::Removed,
312         Self::Fixed,
313         Self::Security,
314      ]
315   }
316}
317
318/// Maps a CHANGELOG.md to the files it covers
319#[derive(Debug, Clone)]
320pub struct ChangelogBoundary {
321   /// Path to the CHANGELOG.md file
322   pub changelog_path: PathBuf,
323   /// Files within this changelog's boundary
324   pub files:          Vec<String>,
325   /// Git diff for these files only
326   pub diff:           String,
327   /// Git stat for these files only
328   pub stat:           String,
329}
330
331/// Parsed [Unreleased] section from a CHANGELOG.md
332#[derive(Debug, Clone, Default)]
333pub struct UnreleasedSection {
334   /// Line number where [Unreleased] header starts (0-indexed)
335   pub header_line: usize,
336   /// Line number where next version or EOF occurs (0-indexed, exclusive)
337   pub end_line:    usize,
338   /// Existing entries by category
339   pub entries:     HashMap<ChangelogCategory, Vec<String>>,
340}
341
342#[derive(Debug, Clone, ValueEnum)]
343pub enum Mode {
344   /// Analyze staged changes
345   Staged,
346   /// Analyze a specific commit
347   Commit,
348   /// Analyze unstaged changes
349   Unstaged,
350   /// Compose changes into multiple commits
351   Compose,
352}
353
354/// Resolve model name from short aliases to full `LiteLLM` model names
355pub fn resolve_model_name(name: &str) -> String {
356   match name {
357      // Claude short names
358      "sonnet" | "s" => "claude-sonnet-4.5",
359      "opus" | "o" | "o4.5" => "claude-opus-4.5",
360      "haiku" | "h" => "claude-haiku-4-5",
361      "3.5" | "sonnet-3.5" => "claude-3.5-sonnet",
362      "3.7" | "sonnet-3.7" => "claude-3.7-sonnet",
363
364      // GPT short names
365      "gpt5" | "g5" => "gpt-5",
366      "gpt5-pro" => "gpt-5-pro",
367      "gpt5-mini" => "gpt-5-mini",
368      "gpt5-codex" => "gpt-5-codex",
369
370      // o-series short names
371      "o3" => "o3",
372      "o3-pro" => "o3-pro",
373      "o3-mini" => "o3-mini",
374      "o1" => "o1",
375      "o1-pro" => "o1-pro",
376      "o1-mini" => "o1-mini",
377
378      // Gemini short names
379      "gemini" | "g2.5" => "gemini-2.5-pro",
380      "flash" | "g2.5-flash" => "gemini-2.5-flash",
381      "flash-lite" => "gemini-2.5-flash-lite",
382
383      // Cerebras
384      "qwen" | "q480b" => "qwen-3-coder-480b",
385
386      // GLM models
387      "glm4.6" => "glm-4.6",
388      "glm4.5" => "glm-4.5",
389      "glm-air" => "glm-4.5-air",
390
391      // Otherwise pass through as-is (allows full model names)
392      _ => name,
393   }
394   .to_string()
395}
396
397/// Scope candidate with metadata for inference
398#[derive(Debug, Clone)]
399pub struct ScopeCandidate {
400   pub path:       String,
401   pub percentage: f32,
402   pub confidence: f32,
403}
404
405/// Type-safe commit type with validation
406#[derive(Clone, PartialEq, Eq)]
407pub struct CommitType(String);
408
409impl CommitType {
410   const VALID_TYPES: &'static [&'static str] = &[
411      "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert",
412   ];
413
414   /// Create new `CommitType` with validation
415   pub fn new(s: impl Into<String>) -> Result<Self> {
416      let s = s.into();
417      let normalized = s.to_lowercase();
418
419      if !Self::VALID_TYPES.contains(&normalized.as_str()) {
420         return Err(CommitGenError::InvalidCommitType(format!(
421            "Invalid commit type '{}'. Must be one of: {}",
422            s,
423            Self::VALID_TYPES.join(", ")
424         )));
425      }
426
427      Ok(Self(normalized))
428   }
429
430   /// Returns inner string slice
431   pub fn as_str(&self) -> &str {
432      &self.0
433   }
434
435   /// Returns length of commit type
436   pub const fn len(&self) -> usize {
437      self.0.len()
438   }
439
440   /// Checks if commit type is empty
441   #[allow(dead_code, reason = "Convenience method for future use")]
442   pub const fn is_empty(&self) -> bool {
443      self.0.is_empty()
444   }
445}
446
447impl fmt::Display for CommitType {
448   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449      write!(f, "{}", self.0)
450   }
451}
452
453impl fmt::Debug for CommitType {
454   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455      f.debug_tuple("CommitType").field(&self.0).finish()
456   }
457}
458
459impl Serialize for CommitType {
460   fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
461   where
462      S: serde::Serializer,
463   {
464      self.0.serialize(serializer)
465   }
466}
467
468impl<'de> Deserialize<'de> for CommitType {
469   fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
470   where
471      D: serde::Deserializer<'de>,
472   {
473      let s = String::deserialize(deserializer)?;
474      Self::new(s).map_err(serde::de::Error::custom)
475   }
476}
477
478/// Type-safe commit summary with validation
479#[derive(Clone)]
480pub struct CommitSummary(String);
481
482impl CommitSummary {
483   /// Creates new `CommitSummary` with strict length validation and format
484   /// warnings
485   pub fn new(s: impl Into<String>, max_len: usize) -> Result<Self> {
486      Self::new_impl(s, max_len, true)
487   }
488
489   /// Internal constructor allowing warning suppression (used by
490   /// post-processing)
491   pub(crate) fn new_unchecked(s: impl Into<String>, max_len: usize) -> Result<Self> {
492      Self::new_impl(s, max_len, false)
493   }
494
495   fn new_impl(s: impl Into<String>, max_len: usize, emit_warnings: bool) -> Result<Self> {
496      let s = s.into();
497
498      // Strict validation: must not be empty
499      if s.trim().is_empty() {
500         return Err(CommitGenError::ValidationError("commit summary cannot be empty".to_string()));
501      }
502
503      // Strict validation: must be ≤ max_len characters (hard limit from config)
504      if s.len() > max_len {
505         return Err(CommitGenError::SummaryTooLong { len: s.len(), max: max_len });
506      }
507
508      if emit_warnings {
509         // Warning-only: should start with lowercase
510         if let Some(first_char) = s.chars().next()
511            && first_char.is_uppercase()
512         {
513            crate::style::warn(&format!("commit summary should start with lowercase: {s}"));
514         }
515
516         // Warning-only: should NOT end with period (conventional commits style)
517         if s.trim_end().ends_with('.') {
518            crate::style::warn(&format!(
519               "commit summary should NOT end with period (conventional commits style): {s}"
520            ));
521         }
522      }
523
524      Ok(Self(s))
525   }
526
527   /// Returns inner string slice
528   pub fn as_str(&self) -> &str {
529      &self.0
530   }
531
532   /// Returns length of summary
533   pub const fn len(&self) -> usize {
534      self.0.len()
535   }
536
537   /// Checks if summary is empty
538   #[allow(dead_code, reason = "Convenience method for future use")]
539   pub const fn is_empty(&self) -> bool {
540      self.0.is_empty()
541   }
542}
543
544impl fmt::Display for CommitSummary {
545   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546      write!(f, "{}", self.0)
547   }
548}
549
550impl fmt::Debug for CommitSummary {
551   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
552      f.debug_tuple("CommitSummary").field(&self.0).finish()
553   }
554}
555
556impl Serialize for CommitSummary {
557   fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
558   where
559      S: serde::Serializer,
560   {
561      self.0.serialize(serializer)
562   }
563}
564
565impl<'de> Deserialize<'de> for CommitSummary {
566   fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
567   where
568      D: serde::Deserializer<'de>,
569   {
570      let s = String::deserialize(deserializer)?;
571      // During deserialization, bypass warnings to avoid console spam
572      if s.trim().is_empty() {
573         return Err(serde::de::Error::custom("commit summary cannot be empty"));
574      }
575      if s.len() > 128 {
576         return Err(serde::de::Error::custom(format!(
577            "commit summary must be ≤128 characters, got {}",
578            s.len()
579         )));
580      }
581      Ok(Self(s))
582   }
583}
584
585/// Type-safe scope for conventional commits
586#[derive(Clone, PartialEq, Eq)]
587pub struct Scope(String);
588
589impl Scope {
590   /// Creates new scope with validation
591   ///
592   /// Rules:
593   /// - Max 2 segments separated by `/`
594   /// - Only lowercase alphanumeric with `/`, `-`, `_`
595   /// - No empty segments
596   pub fn new(s: impl Into<String>) -> Result<Self> {
597      let s = s.into();
598      let segments: Vec<&str> = s.split('/').collect();
599
600      if segments.len() > 2 {
601         return Err(CommitGenError::InvalidScope(format!(
602            "scope has {} segments, max 2 allowed",
603            segments.len()
604         )));
605      }
606
607      for segment in &segments {
608         if segment.is_empty() {
609            return Err(CommitGenError::InvalidScope("scope contains empty segment".to_string()));
610         }
611         if !segment
612            .chars()
613            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
614         {
615            return Err(CommitGenError::InvalidScope(format!(
616               "invalid characters in scope segment: {segment}"
617            )));
618         }
619      }
620
621      Ok(Self(s))
622   }
623
624   /// Returns inner string slice
625   pub fn as_str(&self) -> &str {
626      &self.0
627   }
628
629   /// Splits scope by `/` into segments
630   #[allow(dead_code, reason = "Public API method for scope manipulation")]
631   pub fn segments(&self) -> Vec<&str> {
632      self.0.split('/').collect()
633   }
634
635   /// Check if scope is empty
636   pub const fn is_empty(&self) -> bool {
637      self.0.is_empty()
638   }
639}
640
641impl fmt::Display for Scope {
642   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
643      write!(f, "{}", self.0)
644   }
645}
646
647impl fmt::Debug for Scope {
648   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
649      f.debug_tuple("Scope").field(&self.0).finish()
650   }
651}
652
653impl Serialize for Scope {
654   fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
655   where
656      S: serde::Serializer,
657   {
658      serializer.serialize_str(&self.0)
659   }
660}
661
662impl<'de> Deserialize<'de> for Scope {
663   fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
664   where
665      D: serde::Deserializer<'de>,
666   {
667      let s = String::deserialize(deserializer)?;
668      Self::new(s).map_err(serde::de::Error::custom)
669   }
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
673pub struct ConventionalCommit {
674   pub commit_type: CommitType,
675   pub scope:       Option<Scope>,
676   pub summary:     CommitSummary,
677   pub body:        Vec<String>,
678   pub footers:     Vec<String>,
679}
680
681/// A single detail point from the analysis with optional changelog metadata
682#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct AnalysisDetail {
684   /// The detail text (past-tense sentence)
685   pub text:               String,
686   /// Changelog category if this detail is user-visible
687   #[serde(default, skip_serializing_if = "Option::is_none")]
688   pub changelog_category: Option<ChangelogCategory>,
689   /// Whether this detail should appear in the changelog
690   #[serde(default)]
691   pub user_visible:       bool,
692}
693
694impl AnalysisDetail {
695   /// Create a simple detail without changelog metadata (backward
696   /// compatibility)
697   pub fn simple(text: impl Into<String>) -> Self {
698      Self { text: text.into(), changelog_category: None, user_visible: false }
699   }
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct ConventionalAnalysis {
704   #[serde(rename = "type")]
705   pub commit_type: CommitType,
706   #[serde(default, deserialize_with = "deserialize_optional_scope")]
707   pub scope:       Option<Scope>,
708   /// Structured detail points with optional changelog metadata
709   #[serde(default, deserialize_with = "deserialize_analysis_details")]
710   pub details:     Vec<AnalysisDetail>,
711   #[serde(default, deserialize_with = "deserialize_string_vec")]
712   pub issue_refs:  Vec<String>,
713}
714
715impl ConventionalAnalysis {
716   /// Get the detail texts as a simple Vec<String> (for summary generation)
717   pub fn body_texts(&self) -> Vec<String> {
718      self.details.iter().map(|d| d.text.clone()).collect()
719   }
720
721   /// Get user-visible details grouped by changelog category
722   pub fn changelog_entries(&self) -> std::collections::HashMap<ChangelogCategory, Vec<String>> {
723      let mut entries = std::collections::HashMap::new();
724      for detail in &self.details {
725         if detail.user_visible
726            && let Some(category) = detail.changelog_category
727         {
728            entries
729               .entry(category)
730               .or_insert_with(Vec::new)
731               .push(detail.text.clone());
732         }
733      }
734      entries
735   }
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
739#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
740pub struct SummaryOutput {
741   pub summary: String,
742}
743
744/// Metadata for a single commit during history rewrite
745#[derive(Debug, Clone)]
746pub struct CommitMetadata {
747   pub hash:            String,
748   pub author_name:     String,
749   pub author_email:    String,
750   pub author_date:     String,
751   pub committer_name:  String,
752   pub committer_email: String,
753   pub committer_date:  String,
754   pub message:         String,
755   pub parent_hashes:   Vec<String>,
756   pub tree_hash:       String,
757}
758
759/// Selector for which hunks to include in a file change
760#[derive(Debug, Clone)]
761pub enum HunkSelector {
762   /// All changes in the file
763   All,
764   /// Specific line ranges (1-indexed, inclusive)
765   Lines { start: usize, end: usize },
766   /// Search pattern to match lines
767   Search { pattern: String },
768}
769
770impl Serialize for HunkSelector {
771   fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
772   where
773      S: serde::Serializer,
774   {
775      match self {
776         Self::All => serializer.serialize_str("ALL"),
777         Self::Lines { start, end } => {
778            use serde::ser::SerializeStruct;
779            let mut state = serializer.serialize_struct("Lines", 2)?;
780            state.serialize_field("start", start)?;
781            state.serialize_field("end", end)?;
782            state.end()
783         },
784         Self::Search { pattern } => {
785            use serde::ser::SerializeStruct;
786            let mut state = serializer.serialize_struct("Search", 1)?;
787            state.serialize_field("pattern", pattern)?;
788            state.end()
789         },
790      }
791   }
792}
793
794impl<'de> Deserialize<'de> for HunkSelector {
795   fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
796   where
797      D: serde::Deserializer<'de>,
798   {
799      let value = Value::deserialize(deserializer)?;
800
801      match value {
802         // String "ALL" -> All variant
803         Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(Self::All),
804         // Old format: hunk headers like "@@ -10,5 +10,7 @@" -> treat as search pattern
805         Value::String(s) if s.starts_with("@@") => Ok(Self::Search { pattern: s }),
806         // New format: line range string like "10-20"
807         Value::String(s) if s.contains('-') => {
808            let parts: Vec<&str> = s.split('-').collect();
809            if parts.len() == 2 {
810               let start = parts[0].trim().parse().map_err(serde::de::Error::custom)?;
811               let end = parts[1].trim().parse().map_err(serde::de::Error::custom)?;
812               Ok(Self::Lines { start, end })
813            } else {
814               Err(serde::de::Error::custom(format!("Invalid line range format: {s}")))
815            }
816         },
817         // Object with start/end fields -> Lines
818         Value::Object(map) if map.contains_key("start") && map.contains_key("end") => {
819            let start = map
820               .get("start")
821               .and_then(|v| v.as_u64())
822               .ok_or_else(|| serde::de::Error::custom("Invalid start field"))?
823               as usize;
824            let end = map
825               .get("end")
826               .and_then(|v| v.as_u64())
827               .ok_or_else(|| serde::de::Error::custom("Invalid end field"))?
828               as usize;
829            Ok(Self::Lines { start, end })
830         },
831         // Object with pattern field -> Search
832         Value::Object(map) if map.contains_key("pattern") => {
833            let pattern = map
834               .get("pattern")
835               .and_then(|v| v.as_str())
836               .ok_or_else(|| serde::de::Error::custom("Invalid pattern field"))?
837               .to_string();
838            Ok(Self::Search { pattern })
839         },
840         // Fallback: treat other strings as search patterns
841         Value::String(s) => Ok(Self::Search { pattern: s }),
842         _ => Err(serde::de::Error::custom("Invalid HunkSelector format")),
843      }
844   }
845}
846
847/// File change with specific hunks
848#[derive(Debug, Clone, Serialize, Deserialize)]
849pub struct FileChange {
850   pub path:  String,
851   pub hunks: Vec<HunkSelector>,
852}
853
854/// Represents a logical group of changes for compose mode
855#[derive(Debug, Clone, Serialize, Deserialize)]
856pub struct ChangeGroup {
857   pub changes:      Vec<FileChange>,
858   #[serde(rename = "type")]
859   pub commit_type:  CommitType,
860   pub scope:        Option<Scope>,
861   pub rationale:    String,
862   #[serde(default)]
863   pub dependencies: Vec<usize>,
864}
865
866/// Result of compose analysis
867#[derive(Debug, Clone, Serialize, Deserialize)]
868pub struct ComposeAnalysis {
869   pub groups:           Vec<ChangeGroup>,
870   pub dependency_order: Vec<usize>,
871}
872
873// API types for OpenRouter/LiteLLM communication
874#[derive(Debug, Serialize)]
875#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
876pub struct Message {
877   pub role:    String,
878   pub content: String,
879}
880
881#[derive(Debug, Serialize, Deserialize)]
882#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
883pub struct FunctionParameters {
884   #[serde(rename = "type")]
885   pub param_type: String,
886   pub properties: serde_json::Value,
887   pub required:   Vec<String>,
888}
889
890#[derive(Debug, Serialize, Deserialize)]
891#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
892pub struct Function {
893   pub name:        String,
894   pub description: String,
895   pub parameters:  FunctionParameters,
896}
897
898#[derive(Debug, Serialize, Deserialize)]
899#[allow(dead_code, reason = "Used by src/api/mod.rs in binary but not in tests")]
900pub struct Tool {
901   #[serde(rename = "type")]
902   pub tool_type: String,
903   pub function:  Function,
904}
905
906// CLI Args
907#[derive(Parser, Debug)]
908#[command(author, version, about = "Generate git commit messages using Claude AI", long_about = None)]
909pub struct Args {
910   /// What to analyze
911   #[arg(long, value_enum, default_value = "staged")]
912   pub mode: Mode,
913
914   /// Commit hash/ref when using --mode=commit
915   #[arg(long)]
916   pub target: Option<String>,
917
918   /// Copy the message to clipboard
919   #[arg(long)]
920   pub copy: bool,
921
922   /// Preview without committing (default is to commit for staged mode)
923   #[arg(long)]
924   pub dry_run: bool,
925
926   /// Push changes after committing
927   #[arg(long, short = 'p')]
928   pub push: bool,
929
930   /// Directory to run git commands in
931   #[arg(long, default_value = ".")]
932   pub dir: String,
933
934   /// Model for analysis (default: sonnet). Use short names (sonnet/opus/haiku)
935   /// or full names
936   #[arg(long, short = 'm')]
937   pub model: Option<String>,
938
939   /// Model for summary creation (default: haiku)
940   #[arg(long)]
941   pub summary_model: Option<String>,
942
943   /// Temperature for API calls (0.0-1.0, default: 1.0)
944   #[arg(long, short = 't')]
945   pub temperature: Option<f32>,
946
947   /// Issue numbers this commit fixes (e.g., --fixes 123 456)
948   #[arg(long)]
949   pub fixes: Vec<String>,
950
951   /// Issue numbers this commit closes (alias for --fixes)
952   #[arg(long)]
953   pub closes: Vec<String>,
954
955   /// Issue numbers this commit resolves (alias for --fixes)
956   #[arg(long)]
957   pub resolves: Vec<String>,
958
959   /// Related issue numbers (e.g., --refs 789)
960   #[arg(long)]
961   pub refs: Vec<String>,
962
963   /// Mark this commit as a breaking change
964   #[arg(long)]
965   pub breaking: bool,
966
967   /// GPG sign the commit (equivalent to git commit -S)
968   #[arg(long, short = 'S')]
969   pub sign: bool,
970
971   /// Skip pre-commit and commit-msg hooks (equivalent to git commit
972   /// --no-verify)
973   #[arg(long, short = 'n')]
974   pub skip_hooks: bool,
975
976   /// Path to config file (default: ~/.config/llm-git/config.toml)
977   #[arg(long)]
978   pub config: Option<PathBuf>,
979
980   /// Additional context to provide to the analysis model (all trailing
981   /// non-flag text)
982   #[arg(trailing_var_arg = true)]
983   pub context: Vec<String>,
984
985   // === Rewrite mode args ===
986   /// Rewrite git history to conventional commits
987   #[arg(long, conflicts_with_all = ["target", "copy", "dry_run"])]
988   pub rewrite: bool,
989
990   /// Preview N commits without rewriting
991   #[arg(long, requires = "rewrite")]
992   pub rewrite_preview: Option<usize>,
993
994   /// Start from this ref (exclusive, e.g., main~50)
995   #[arg(long, requires = "rewrite")]
996   pub rewrite_start: Option<String>,
997
998   /// Number of parallel API calls
999   #[arg(long, default_value = "10", requires = "rewrite")]
1000   pub rewrite_parallel: usize,
1001
1002   /// Dry run - show what would be changed
1003   #[arg(long, requires = "rewrite")]
1004   pub rewrite_dry_run: bool,
1005
1006   /// Hide old commit type/scope tags to avoid model influence
1007   #[arg(long, requires = "rewrite")]
1008   pub rewrite_hide_old_types: bool,
1009
1010   /// Exclude old commit message from context when analyzing commits (prevents
1011   /// contamination)
1012   #[arg(long)]
1013   pub exclude_old_message: bool,
1014
1015   // === Compose mode args ===
1016   /// Compose changes into multiple atomic commits
1017   #[arg(long, conflicts_with_all = ["target", "rewrite"])]
1018   pub compose: bool,
1019
1020   /// Preview proposed splits without committing
1021   #[arg(long, requires = "compose")]
1022   pub compose_preview: bool,
1023
1024   /// Maximum number of commits to create
1025   #[arg(long, requires = "compose")]
1026   pub compose_max_commits: Option<usize>,
1027
1028   /// Run tests after each commit
1029   #[arg(long, requires = "compose")]
1030   pub compose_test_after_each: bool,
1031
1032   // === Changelog args ===
1033   /// Disable automatic changelog updates
1034   #[arg(long)]
1035   pub no_changelog: bool,
1036
1037   // === Debug args ===
1038   /// Save intermediate outputs (diff, analysis, summary, changelog) to
1039   /// directory
1040   #[arg(long)]
1041   pub debug_output: Option<PathBuf>,
1042
1043   // === Test mode args ===
1044   /// Run fixture-based tests
1045   #[arg(long, conflicts_with_all = ["target", "rewrite", "compose"])]
1046   pub test: bool,
1047
1048   /// Update golden files with current output
1049   #[arg(long, requires = "test")]
1050   pub test_update: bool,
1051
1052   /// Add a new fixture from a commit
1053   #[arg(long, requires = "test")]
1054   pub test_add: Option<String>,
1055
1056   /// Name for the new fixture (required with --test-add)
1057   #[arg(long, requires = "test_add")]
1058   pub test_name: Option<String>,
1059
1060   /// Filter fixtures by name pattern
1061   #[arg(long, requires = "test")]
1062   pub test_filter: Option<String>,
1063
1064   /// List available fixtures
1065   #[arg(long, requires = "test")]
1066   pub test_list: bool,
1067
1068   /// Custom fixtures directory
1069   #[arg(long, requires = "test")]
1070   pub fixtures_dir: Option<PathBuf>,
1071
1072   /// Generate HTML report of test results
1073   #[arg(long, requires = "test")]
1074   pub test_report: Option<PathBuf>,
1075}
1076
1077impl Default for Args {
1078   fn default() -> Self {
1079      Self {
1080         mode:                    Mode::Staged,
1081         target:                  None,
1082         copy:                    false,
1083         dry_run:                 false,
1084         push:                    false,
1085         dir:                     ".".to_string(),
1086         model:                   None,
1087         summary_model:           None,
1088         temperature:             None,
1089         fixes:                   vec![],
1090         closes:                  vec![],
1091         resolves:                vec![],
1092         refs:                    vec![],
1093         breaking:                false,
1094         sign:                    false,
1095         skip_hooks:              false,
1096         config:                  None,
1097         context:                 vec![],
1098         rewrite:                 false,
1099         rewrite_preview:         None,
1100         rewrite_start:           None,
1101         rewrite_parallel:        10,
1102         rewrite_dry_run:         false,
1103         rewrite_hide_old_types:  false,
1104         exclude_old_message:     false,
1105         compose:                 false,
1106         compose_preview:         false,
1107         compose_max_commits:     None,
1108         compose_test_after_each: false,
1109         no_changelog:            false,
1110         debug_output:            None,
1111         test:                    false,
1112         test_update:             false,
1113         test_add:                None,
1114         test_name:               None,
1115         test_filter:             None,
1116         test_list:               false,
1117         fixtures_dir:            None,
1118         test_report:             None,
1119      }
1120   }
1121}
1122fn deserialize_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
1123where
1124   D: serde::Deserializer<'de>,
1125{
1126   let value = Value::deserialize(deserializer)?;
1127   Ok(value_to_string_vec(value))
1128}
1129
1130/// Deserialize analysis details from either structured format or plain strings
1131fn deserialize_analysis_details<'de, D>(
1132   deserializer: D,
1133) -> std::result::Result<Vec<AnalysisDetail>, D::Error>
1134where
1135   D: serde::Deserializer<'de>,
1136{
1137   let value = Value::deserialize(deserializer)?;
1138   match value {
1139      Value::Array(arr) => {
1140         let mut details = Vec::with_capacity(arr.len());
1141         for item in arr {
1142            let detail = match item {
1143               // New structured format: {"text": "...", "changelog_category": "Added", ...}
1144               Value::Object(obj) => {
1145                  let text = obj
1146                     .get("text")
1147                     .and_then(Value::as_str)
1148                     .map(String::from)
1149                     .unwrap_or_default();
1150                  let changelog_category = obj
1151                     .get("changelog_category")
1152                     .and_then(Value::as_str)
1153                     .map(ChangelogCategory::from_name);
1154                  let user_visible = obj
1155                     .get("user_visible")
1156                     .and_then(Value::as_bool)
1157                     .unwrap_or(false);
1158                  AnalysisDetail { text, changelog_category, user_visible }
1159               },
1160               // Old format: plain string
1161               Value::String(s) => AnalysisDetail::simple(s),
1162               _ => continue,
1163            };
1164            if !detail.text.is_empty() {
1165               details.push(detail);
1166            }
1167         }
1168         Ok(details)
1169      },
1170      Value::String(s) => {
1171         // Handle edge case where LLM returns a single string
1172         if s.is_empty() {
1173            Ok(Vec::new())
1174         } else {
1175            Ok(vec![AnalysisDetail::simple(s)])
1176         }
1177      },
1178      Value::Null => Ok(Vec::new()),
1179      _ => Ok(Vec::new()),
1180   }
1181}
1182
1183fn extract_strings_from_malformed_json(input: &str) -> Vec<String> {
1184   let mut strings = Vec::new();
1185   let mut chars = input.chars();
1186
1187   while let Some(c) = chars.next() {
1188      if c == '"' {
1189         let mut current_string = String::new();
1190         let mut escaped = false;
1191
1192         for inner_c in chars.by_ref() {
1193            if escaped {
1194               current_string.push(inner_c);
1195               escaped = false;
1196            } else if inner_c == '\\' {
1197               current_string.push(inner_c);
1198               escaped = true;
1199            } else if inner_c == '"' {
1200               break;
1201            } else {
1202               current_string.push(inner_c);
1203            }
1204         }
1205
1206         // Try to parse as JSON string first
1207         let json_candidate = format!("\"{current_string}\"");
1208         if let Ok(parsed) = serde_json::from_str::<String>(&json_candidate) {
1209            strings.push(parsed);
1210         } else {
1211            // Fallback: Replace newlines with space and try again
1212            let sanitized = current_string.replace(['\n', '\r'], " ");
1213            let json_sanitized = format!("\"{sanitized}\"");
1214            if let Ok(parsed) = serde_json::from_str::<String>(&json_sanitized) {
1215               strings.push(parsed);
1216            } else {
1217               // Ultimate fallback: raw content
1218               strings.push(sanitized);
1219            }
1220         }
1221      }
1222   }
1223   strings
1224}
1225
1226fn value_to_string_vec(value: Value) -> Vec<String> {
1227   match value {
1228      Value::Null => Vec::new(),
1229      Value::String(s) => {
1230         let trimmed = s.trim();
1231
1232         // Try to parse as JSON array if it looks like one
1233         if trimmed.starts_with('[') {
1234            // Remove trailing punctuation and quotes iteratively until stable
1235            // Handles cases like: `[...]".` or `[...].` or `[...]"`
1236            let mut cleaned = trimmed;
1237            loop {
1238               let before = cleaned;
1239               cleaned = cleaned.trim_end_matches(['.', ',', ';', '"', '\'']);
1240               if cleaned == before {
1241                  break;
1242               }
1243            }
1244
1245            // Attempt to parse as JSON array
1246            if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(cleaned) {
1247               return arr
1248                  .into_iter()
1249                  .flat_map(|v| value_to_string_vec(v).into_iter())
1250                  .collect();
1251            }
1252
1253            // Fallback: try sanitizing newlines (LLM sometimes outputs literal newlines in
1254            // JSON strings)
1255            let sanitized = cleaned.replace(['\n', '\r'], " ");
1256            if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(&sanitized) {
1257               return arr
1258                  .into_iter()
1259                  .flat_map(|v| value_to_string_vec(v).into_iter())
1260                  .collect();
1261            }
1262
1263            // Final fallback: Try manual string extraction for truncated/malformed arrays
1264            // e.g. ["Item 1", "Item 2".
1265            let extracted = extract_strings_from_malformed_json(trimmed);
1266            if !extracted.is_empty() {
1267               return extracted;
1268            }
1269         }
1270
1271         // Default: split by lines
1272         s.lines()
1273            .map(str::trim)
1274            .filter(|s| !s.is_empty())
1275            .map(|s| s.to_string())
1276            .collect()
1277      },
1278      Value::Array(arr) => arr
1279         .into_iter()
1280         .flat_map(|v| value_to_string_vec(v).into_iter())
1281         .collect(),
1282      Value::Object(map) => map
1283         .into_iter()
1284         .flat_map(|(k, v)| {
1285            let values = value_to_string_vec(v);
1286            if values.is_empty() {
1287               vec![k]
1288            } else {
1289               values
1290                  .into_iter()
1291                  .map(|val| format!("{k}: {val}"))
1292                  .collect()
1293            }
1294         })
1295         .collect(),
1296      other => vec![other.to_string()],
1297   }
1298}
1299
1300fn deserialize_optional_scope<'de, D>(
1301   deserializer: D,
1302) -> std::result::Result<Option<Scope>, D::Error>
1303where
1304   D: serde::Deserializer<'de>,
1305{
1306   let value = Option::<String>::deserialize(deserializer)?;
1307   match value {
1308      None => Ok(None),
1309      Some(scope_str) => {
1310         let trimmed = scope_str.trim();
1311         if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
1312            Ok(None)
1313         } else {
1314            Scope::new(trimmed.to_string())
1315               .map(Some)
1316               .map_err(serde::de::Error::custom)
1317         }
1318      },
1319   }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324   use super::*;
1325
1326   // ========== resolve_model_name Tests ==========
1327
1328   #[test]
1329   fn test_resolve_model_name() {
1330      // Claude short names
1331      assert_eq!(resolve_model_name("sonnet"), "claude-sonnet-4.5");
1332      assert_eq!(resolve_model_name("s"), "claude-sonnet-4.5");
1333      assert_eq!(resolve_model_name("opus"), "claude-opus-4.5");
1334      assert_eq!(resolve_model_name("o"), "claude-opus-4.5");
1335      assert_eq!(resolve_model_name("haiku"), "claude-haiku-4-5");
1336      assert_eq!(resolve_model_name("h"), "claude-haiku-4-5");
1337
1338      // GPT short names
1339      assert_eq!(resolve_model_name("gpt5"), "gpt-5");
1340      assert_eq!(resolve_model_name("g5"), "gpt-5");
1341
1342      // Gemini short names
1343      assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
1344      assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
1345
1346      // Pass-through for full names
1347      assert_eq!(resolve_model_name("claude-sonnet-4.5"), "claude-sonnet-4.5");
1348      assert_eq!(resolve_model_name("custom-model"), "custom-model");
1349   }
1350
1351   // ========== CommitType Tests ==========
1352
1353   #[test]
1354   fn test_commit_type_valid() {
1355      let valid_types = [
1356         "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
1357         "revert",
1358      ];
1359
1360      for ty in &valid_types {
1361         assert!(CommitType::new(*ty).is_ok(), "Expected '{ty}' to be valid");
1362      }
1363   }
1364
1365   #[test]
1366   fn test_commit_type_case_normalization() {
1367      // Uppercase should normalize to lowercase
1368      let ct = CommitType::new("FEAT").expect("FEAT should normalize");
1369      assert_eq!(ct.as_str(), "feat");
1370
1371      let ct = CommitType::new("Fix").expect("Fix should normalize");
1372      assert_eq!(ct.as_str(), "fix");
1373
1374      let ct = CommitType::new("ReFaCtOr").expect("ReFaCtOr should normalize");
1375      assert_eq!(ct.as_str(), "refactor");
1376   }
1377
1378   #[test]
1379   fn test_commit_type_invalid() {
1380      let invalid_types = ["invalid", "bug", "feature", "update", "change", "random", "xyz", "123"];
1381
1382      for ty in &invalid_types {
1383         assert!(CommitType::new(*ty).is_err(), "Expected '{ty}' to be invalid");
1384      }
1385   }
1386
1387   #[test]
1388   fn test_commit_type_empty() {
1389      assert!(CommitType::new("").is_err(), "Empty string should be invalid");
1390   }
1391
1392   #[test]
1393   fn test_commit_type_display() {
1394      let ct = CommitType::new("feat").unwrap();
1395      assert_eq!(format!("{ct}"), "feat");
1396   }
1397
1398   #[test]
1399   fn test_commit_type_len() {
1400      let ct = CommitType::new("feat").unwrap();
1401      assert_eq!(ct.len(), 4);
1402
1403      let ct = CommitType::new("refactor").unwrap();
1404      assert_eq!(ct.len(), 8);
1405   }
1406
1407   // ========== Scope Tests ==========
1408
1409   #[test]
1410   fn test_scope_valid_single_segment() {
1411      let valid_scopes = ["core", "api", "lib", "client", "server", "ui", "test-123", "foo_bar"];
1412
1413      for scope in &valid_scopes {
1414         assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1415      }
1416   }
1417
1418   #[test]
1419   fn test_scope_valid_two_segments() {
1420      let valid_scopes = ["api/client", "lib/core", "ui/components", "test-1/foo_2"];
1421
1422      for scope in &valid_scopes {
1423         assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1424      }
1425   }
1426
1427   #[test]
1428   fn test_scope_invalid_three_segments() {
1429      let scope = Scope::new("a/b/c");
1430      assert!(scope.is_err(), "Three segments should be invalid");
1431
1432      if let Err(CommitGenError::InvalidScope(msg)) = scope {
1433         assert!(msg.contains("3 segments"));
1434      } else {
1435         panic!("Expected InvalidScope error");
1436      }
1437   }
1438
1439   #[test]
1440   fn test_scope_invalid_uppercase() {
1441      let invalid_scopes = ["Core", "API", "MyScope", "api/Client"];
1442
1443      for scope in &invalid_scopes {
1444         assert!(Scope::new(*scope).is_err(), "Expected '{scope}' with uppercase to be invalid");
1445      }
1446   }
1447
1448   #[test]
1449   fn test_scope_invalid_empty_segments() {
1450      let invalid_scopes = ["", "a//b", "/foo", "bar/"];
1451
1452      for scope in &invalid_scopes {
1453         assert!(
1454            Scope::new(*scope).is_err(),
1455            "Expected '{scope}' with empty segments to be invalid"
1456         );
1457      }
1458   }
1459
1460   #[test]
1461   fn test_scope_invalid_chars() {
1462      let invalid_scopes = ["a b", "foo bar", "test@scope", "api/client!", "a.b"];
1463
1464      for scope in &invalid_scopes {
1465         assert!(
1466            Scope::new(*scope).is_err(),
1467            "Expected '{scope}' with invalid chars to be invalid"
1468         );
1469      }
1470   }
1471
1472   #[test]
1473   fn test_scope_segments() {
1474      let scope = Scope::new("core").unwrap();
1475      assert_eq!(scope.segments(), vec!["core"]);
1476
1477      let scope = Scope::new("api/client").unwrap();
1478      assert_eq!(scope.segments(), vec!["api", "client"]);
1479   }
1480
1481   #[test]
1482   fn test_scope_display() {
1483      let scope = Scope::new("api/client").unwrap();
1484      assert_eq!(format!("{scope}"), "api/client");
1485   }
1486
1487   // ========== CommitSummary Tests ==========
1488
1489   #[test]
1490   fn test_commit_summary_valid() {
1491      let summary_72 = "a".repeat(72);
1492      let summary_96 = "a".repeat(96);
1493      let summary_128 = "a".repeat(128);
1494      let valid_summaries = [
1495         "added new feature",
1496         "fixed bug in authentication",
1497         "x",                  // 1 char
1498         summary_72.as_str(),  // exactly 72 chars (guideline)
1499         summary_96.as_str(),  // exactly 96 chars (soft limit)
1500         summary_128.as_str(), // exactly 128 chars (hard limit)
1501      ];
1502
1503      for summary in &valid_summaries {
1504         assert!(
1505            CommitSummary::new(*summary, 128).is_ok(),
1506            "Expected '{}' (len={}) to be valid",
1507            if summary.len() > 50 {
1508               &summary[..50]
1509            } else {
1510               summary
1511            },
1512            summary.len()
1513         );
1514      }
1515   }
1516
1517   #[test]
1518   fn test_commit_summary_too_long() {
1519      let long_summary = "a".repeat(129); // 129 chars (exceeds hard limit)
1520      let result = CommitSummary::new(long_summary, 128);
1521      assert!(result.is_err(), "129 char summary should be invalid");
1522
1523      if let Err(CommitGenError::SummaryTooLong { len, max }) = result {
1524         assert_eq!(len, 129);
1525         assert_eq!(max, 128);
1526      } else {
1527         panic!("Expected SummaryTooLong error");
1528      }
1529   }
1530
1531   #[test]
1532   fn test_commit_summary_empty() {
1533      let empty_cases = ["", "   ", "\t", "\n"];
1534
1535      for empty in &empty_cases {
1536         assert!(
1537            CommitSummary::new(*empty, 128).is_err(),
1538            "Empty/whitespace-only summary should be invalid"
1539         );
1540      }
1541   }
1542
1543   #[test]
1544   fn test_commit_summary_warnings_uppercase_start() {
1545      // Should succeed but emit warning
1546      let result = CommitSummary::new("Added new feature", 128);
1547      assert!(result.is_ok(), "Should succeed despite uppercase start");
1548   }
1549
1550   #[test]
1551   fn test_commit_summary_warnings_with_period() {
1552      // Should succeed but emit warning (periods not allowed in conventional commits)
1553      let result = CommitSummary::new("added new feature.", 128);
1554      assert!(result.is_ok(), "Should succeed despite having period");
1555   }
1556
1557   #[test]
1558   fn test_commit_summary_new_unchecked() {
1559      // new_unchecked should not emit warnings (internal use)
1560      let result = CommitSummary::new_unchecked("Added feature", 128);
1561      assert!(result.is_ok(), "new_unchecked should succeed");
1562   }
1563
1564   #[test]
1565   fn test_commit_summary_len() {
1566      let summary = CommitSummary::new("hello world", 128).unwrap();
1567      assert_eq!(summary.len(), 11);
1568   }
1569
1570   #[test]
1571   fn test_commit_summary_display() {
1572      let summary = CommitSummary::new("fixed bug", 128).unwrap();
1573      assert_eq!(format!("{summary}"), "fixed bug");
1574   }
1575
1576   // ========== Serialization Tests ==========
1577
1578   #[test]
1579   fn test_commit_type_serialize() {
1580      let ct = CommitType::new("feat").unwrap();
1581      let json = serde_json::to_string(&ct).unwrap();
1582      assert_eq!(json, "\"feat\"");
1583   }
1584
1585   #[test]
1586   fn test_commit_type_deserialize() {
1587      let ct: CommitType = serde_json::from_str("\"fix\"").unwrap();
1588      assert_eq!(ct.as_str(), "fix");
1589
1590      // Invalid type should fail deserialization
1591      let result: serde_json::Result<CommitType> = serde_json::from_str("\"invalid\"");
1592      assert!(result.is_err());
1593   }
1594
1595   #[test]
1596   fn test_scope_serialize() {
1597      let scope = Scope::new("api/client").unwrap();
1598      let json = serde_json::to_string(&scope).unwrap();
1599      assert_eq!(json, "\"api/client\"");
1600   }
1601
1602   #[test]
1603   fn test_scope_deserialize() {
1604      let scope: Scope = serde_json::from_str("\"core\"").unwrap();
1605      assert_eq!(scope.as_str(), "core");
1606
1607      // Invalid scope should fail deserialization
1608      let result: serde_json::Result<Scope> = serde_json::from_str("\"INVALID\"");
1609      assert!(result.is_err());
1610   }
1611
1612   #[test]
1613   fn test_commit_summary_serialize() {
1614      let summary = CommitSummary::new("fixed bug", 128).unwrap();
1615      let json = serde_json::to_string(&summary).unwrap();
1616      assert_eq!(json, "\"fixed bug\"");
1617   }
1618
1619   #[test]
1620   fn test_details_array_parsing() {
1621      // Test parsing of details array in various formats
1622      let test_cases = [
1623         // New structured format
1624         r#"{"type":"feat","details":[{"text":"item1"},{"text":"item2"}],"issue_refs":[]}"#,
1625         // Old plain string format (backward compatibility)
1626         r#"{"type":"feat","details":["item1","item2"],"issue_refs":[]}"#,
1627      ];
1628
1629      for (idx, json) in test_cases.iter().enumerate() {
1630         let result: serde_json::Result<ConventionalAnalysis> = serde_json::from_str(json);
1631         match result {
1632            Ok(analysis) => {
1633               let body_texts = analysis.body_texts();
1634               assert_eq!(
1635                  body_texts.len(),
1636                  2,
1637                  "Case {idx}: Expected 2 body items, got {}",
1638                  body_texts.len()
1639               );
1640               assert_eq!(body_texts[0], "item1", "Case {idx}: First item mismatch");
1641               assert_eq!(body_texts[1], "item2", "Case {idx}: Second item mismatch");
1642            },
1643            Err(e) => {
1644               panic!("Case {idx}: Failed to parse: {e}");
1645            },
1646         }
1647      }
1648   }
1649
1650   #[test]
1651   fn test_analysis_detail_with_changelog() {
1652      // Test structured detail with changelog metadata
1653      let json = r#"{
1654         "type": "feat",
1655         "details": [
1656            {"text": "Added new API endpoint", "changelog_category": "Added", "user_visible": true},
1657            {"text": "Refactored internal code", "user_visible": false}
1658         ],
1659         "issue_refs": []
1660      }"#;
1661
1662      let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1663      assert_eq!(analysis.details.len(), 2);
1664      assert_eq!(analysis.details[0].text, "Added new API endpoint");
1665      assert_eq!(analysis.details[0].changelog_category, Some(ChangelogCategory::Added));
1666      assert!(analysis.details[0].user_visible);
1667      assert!(!analysis.details[1].user_visible);
1668
1669      // Test changelog_entries helper
1670      let entries = analysis.changelog_entries();
1671      assert_eq!(entries.len(), 1);
1672      assert!(entries.contains_key(&ChangelogCategory::Added));
1673   }
1674
1675   #[test]
1676   fn test_commit_summary_deserialize() {
1677      let summary: CommitSummary = serde_json::from_str("\"added feature\"").unwrap();
1678      assert_eq!(summary.as_str(), "added feature");
1679
1680      // Too long should fail (>128 chars)
1681      let long = format!("\"{}\"", "a".repeat(129));
1682      let result: serde_json::Result<CommitSummary> = serde_json::from_str(&long);
1683      assert!(result.is_err());
1684
1685      // Empty should fail
1686      let result: serde_json::Result<CommitSummary> = serde_json::from_str("\"\"");
1687      assert!(result.is_err());
1688   }
1689
1690   #[test]
1691   fn test_conventional_commit_roundtrip() {
1692      let commit = ConventionalCommit {
1693         commit_type: CommitType::new("feat").unwrap(),
1694         scope:       Some(Scope::new("api").unwrap()),
1695         summary:     CommitSummary::new_unchecked("added endpoint", 128).unwrap(),
1696         body:        vec!["detail 1.".to_string(), "detail 2.".to_string()],
1697         footers:     vec!["Fixes: #123".to_string()],
1698      };
1699
1700      let json = serde_json::to_string(&commit).unwrap();
1701      let deserialized: ConventionalCommit = serde_json::from_str(&json).unwrap();
1702
1703      assert_eq!(deserialized.commit_type.as_str(), "feat");
1704      assert_eq!(deserialized.scope.unwrap().as_str(), "api");
1705      assert_eq!(deserialized.summary.as_str(), "added endpoint");
1706      assert_eq!(deserialized.body.len(), 2);
1707      assert_eq!(deserialized.footers.len(), 1);
1708   }
1709
1710   #[test]
1711   fn test_scope_null_string_deserializes_to_none() {
1712      // LLMs sometimes return "null" as a string instead of JSON null
1713      let test_cases = [
1714         r#"{"type":"feat","scope":"null","body":[],"issue_refs":[]}"#,
1715         r#"{"type":"feat","scope":"Null","body":[],"issue_refs":[]}"#,
1716         r#"{"type":"feat","scope":"NULL","body":[],"issue_refs":[]}"#,
1717         r#"{"type":"feat","scope":" null ","body":[],"issue_refs":[]}"#,
1718      ];
1719
1720      for (idx, json) in test_cases.iter().enumerate() {
1721         let analysis: ConventionalAnalysis = serde_json::from_str(json)
1722            .unwrap_or_else(|e| panic!("Case {idx} failed to deserialize: {e}"));
1723         assert!(
1724            analysis.scope.is_none(),
1725            "Case {idx}: Expected scope to be None, got {:?}",
1726            analysis.scope
1727         );
1728      }
1729   }
1730
1731   // ========== HunkSelector Tests ==========
1732
1733   #[test]
1734   fn test_body_array_with_newline_in_string() {
1735      // This reproduces the issue where literal newlines in the string prevent JSON
1736      // parsing The input mimics what happens when LLM returns a JSON string
1737      // with unescaped newlines
1738      let raw_str = "[\"Item 1\", \"Item\n2\"]";
1739      let value = serde_json::Value::String(raw_str.to_string());
1740
1741      // desired behavior: should clean the newline and parse as array
1742      let result = value_to_string_vec(value);
1743
1744      // It should be ["Item 1", "Item 2"] (newline replaced by space)
1745      assert_eq!(result.len(), 2);
1746      assert_eq!(result[0], "Item 1");
1747      // Depending on implementation, it might be "Item 2" or "Item  2" etc.
1748      // For now let's assume we replace with space.
1749      assert_eq!(result[1], "Item 2");
1750   }
1751
1752   #[test]
1753   fn test_body_array_malformed_truncated() {
1754      // This reproduces the issue where the array is truncated or has trailing
1755      // punctuation
1756      let raw_str = "[\"Refactored finance...\", \"Added automatic detection...\".";
1757      let value = serde_json::Value::String(raw_str.to_string());
1758
1759      let result = value_to_string_vec(value);
1760
1761      // Should recover 2 items
1762      assert_eq!(result.len(), 2);
1763      assert_eq!(result[0], "Refactored finance...");
1764      assert_eq!(result[1], "Added automatic detection...");
1765   }
1766
1767   #[test]
1768   fn test_hunk_selector_deserialize_all() {
1769      let json = r#""ALL""#;
1770      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1771      assert!(matches!(selector, HunkSelector::All));
1772   }
1773
1774   #[test]
1775   fn test_hunk_selector_deserialize_lines_object() {
1776      let json = r#"{"start": 10, "end": 20}"#;
1777      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1778      match selector {
1779         HunkSelector::Lines { start, end } => {
1780            assert_eq!(start, 10);
1781            assert_eq!(end, 20);
1782         },
1783         _ => panic!("Expected Lines variant"),
1784      }
1785   }
1786
1787   #[test]
1788   fn test_hunk_selector_deserialize_lines_string() {
1789      let json = r#""10-20""#;
1790      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1791      match selector {
1792         HunkSelector::Lines { start, end } => {
1793            assert_eq!(start, 10);
1794            assert_eq!(end, 20);
1795         },
1796         _ => panic!("Expected Lines variant"),
1797      }
1798   }
1799
1800   #[test]
1801   fn test_hunk_selector_deserialize_search_pattern() {
1802      let json = r#"{"pattern": "fn main"}"#;
1803      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1804      match selector {
1805         HunkSelector::Search { pattern } => {
1806            assert_eq!(pattern, "fn main");
1807         },
1808         _ => panic!("Expected Search variant"),
1809      }
1810   }
1811
1812   #[test]
1813   fn test_hunk_selector_deserialize_old_format_hunk_header() {
1814      // Old format: hunk headers like "@@ -10,5 +10,7 @@" should be treated as search
1815      let json = r#""@@ -10,5 +10,7 @@""#;
1816      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1817      match selector {
1818         HunkSelector::Search { pattern } => {
1819            assert_eq!(pattern, "@@ -10,5 +10,7 @@");
1820         },
1821         _ => panic!("Expected Search variant for old hunk header format"),
1822      }
1823   }
1824
1825   #[test]
1826   fn test_hunk_selector_serialize_all() {
1827      let selector = HunkSelector::All;
1828      let json = serde_json::to_string(&selector).unwrap();
1829      assert_eq!(json, r#""ALL""#);
1830   }
1831
1832   #[test]
1833   fn test_hunk_selector_serialize_lines() {
1834      let selector = HunkSelector::Lines { start: 10, end: 20 };
1835      let json = serde_json::to_value(&selector).unwrap();
1836      assert_eq!(json["start"], 10);
1837      assert_eq!(json["end"], 20);
1838   }
1839
1840   #[test]
1841   fn test_file_change_deserialize_with_all() {
1842      let json = r#"{"path": "src/main.rs", "hunks": ["ALL"]}"#;
1843      let change: FileChange = serde_json::from_str(json).unwrap();
1844      assert_eq!(change.path, "src/main.rs");
1845      assert_eq!(change.hunks.len(), 1);
1846      assert!(matches!(change.hunks[0], HunkSelector::All));
1847   }
1848
1849   #[test]
1850   fn test_file_change_deserialize_with_line_ranges() {
1851      let json = r#"{"path": "src/main.rs", "hunks": [{"start": 10, "end": 20}, {"start": 50, "end": 60}]}"#;
1852      let change: FileChange = serde_json::from_str(json).unwrap();
1853      assert_eq!(change.path, "src/main.rs");
1854      assert_eq!(change.hunks.len(), 2);
1855
1856      match &change.hunks[0] {
1857         HunkSelector::Lines { start, end } => {
1858            assert_eq!(*start, 10);
1859            assert_eq!(*end, 20);
1860         },
1861         _ => panic!("Expected Lines variant"),
1862      }
1863
1864      match &change.hunks[1] {
1865         HunkSelector::Lines { start, end } => {
1866            assert_eq!(*start, 50);
1867            assert_eq!(*end, 60);
1868         },
1869         _ => panic!("Expected Lines variant"),
1870      }
1871   }
1872
1873   #[test]
1874   fn test_file_change_deserialize_mixed_formats() {
1875      // Mix of string line ranges and object line ranges
1876      let json = r#"{"path": "src/main.rs", "hunks": ["10-20", {"start": 50, "end": 60}]}"#;
1877      let change: FileChange = serde_json::from_str(json).unwrap();
1878      assert_eq!(change.hunks.len(), 2);
1879
1880      match &change.hunks[0] {
1881         HunkSelector::Lines { start, end } => {
1882            assert_eq!(*start, 10);
1883            assert_eq!(*end, 20);
1884         },
1885         _ => panic!("Expected Lines variant"),
1886      }
1887
1888      match &change.hunks[1] {
1889         HunkSelector::Lines { start, end } => {
1890            assert_eq!(*start, 50);
1891            assert_eq!(*end, 60);
1892         },
1893         _ => panic!("Expected Lines variant"),
1894      }
1895   }
1896}