Skip to main content

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, Clone, 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, Clone, 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, Clone, 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 generation (default: sonnet). Use short names
935   /// (sonnet/opus/haiku) or full model names.
936   #[arg(long, short = 'm')]
937   pub model: Option<String>,
938
939   /// Temperature for API calls (0.0-1.0, default: 1.0)
940   #[arg(long, short = 't')]
941   pub temperature: Option<f32>,
942
943   /// Issue numbers this commit fixes (e.g., --fixes 123 456)
944   #[arg(long)]
945   pub fixes: Vec<String>,
946
947   /// Issue numbers this commit closes (alias for --fixes)
948   #[arg(long)]
949   pub closes: Vec<String>,
950
951   /// Issue numbers this commit resolves (alias for --fixes)
952   #[arg(long)]
953   pub resolves: Vec<String>,
954
955   /// Related issue numbers (e.g., --refs 789)
956   #[arg(long)]
957   pub refs: Vec<String>,
958
959   /// Mark this commit as a breaking change
960   #[arg(long)]
961   pub breaking: bool,
962
963   /// GPG sign the commit (equivalent to git commit -S)
964   #[arg(long, short = 'S')]
965   pub sign: bool,
966
967   /// Add Signed-off-by trailer (equivalent to git commit -s)
968   #[arg(long, short = 's')]
969   pub signoff: bool,
970
971   /// Amend the previous commit (equivalent to git commit --amend)
972   #[arg(long)]
973   pub amend: bool,
974
975   /// Skip pre-commit and commit-msg hooks (equivalent to git commit
976   /// --no-verify)
977   #[arg(long, short = 'n')]
978   pub skip_hooks: bool,
979
980   /// Path to config file (default: ~/.config/llm-git/config.toml)
981   #[arg(long)]
982   pub config: Option<PathBuf>,
983
984   /// Additional context to provide to the analysis model (all trailing
985   /// non-flag text)
986   #[arg(trailing_var_arg = true)]
987   pub context: Vec<String>,
988
989   // === Fast mode args ===
990   /// Fast mode: single-call commit generation (skip changelog)
991   #[arg(long, short = 'f', conflicts_with_all = ["compose", "rewrite", "test"])]
992   pub fast: bool,
993
994   // === Rewrite mode args ===
995   /// Rewrite git history to conventional commits
996   #[arg(long, conflicts_with_all = ["target", "copy", "dry_run"])]
997   pub rewrite: bool,
998
999   /// Preview N commits without rewriting
1000   #[arg(long, requires = "rewrite")]
1001   pub rewrite_preview: Option<usize>,
1002
1003   /// Start from this ref (exclusive, e.g., main~50)
1004   #[arg(long, requires = "rewrite")]
1005   pub rewrite_start: Option<String>,
1006
1007   /// Number of parallel API calls
1008   #[arg(long, default_value = "10", requires = "rewrite")]
1009   pub rewrite_parallel: usize,
1010
1011   /// Dry run - show what would be changed
1012   #[arg(long, requires = "rewrite")]
1013   pub rewrite_dry_run: bool,
1014
1015   /// Hide old commit type/scope tags to avoid model influence
1016   #[arg(long, requires = "rewrite")]
1017   pub rewrite_hide_old_types: bool,
1018
1019   /// Exclude old commit message from context when analyzing commits (prevents
1020   /// contamination)
1021   #[arg(long)]
1022   pub exclude_old_message: bool,
1023
1024   // === Compose mode args ===
1025   /// Compose changes into multiple atomic commits
1026   #[arg(long, conflicts_with_all = ["target", "rewrite"])]
1027   pub compose: bool,
1028
1029   /// Preview proposed splits without committing
1030   #[arg(long, requires = "compose")]
1031   pub compose_preview: bool,
1032
1033   /// Maximum number of commits to create
1034   #[arg(long, requires = "compose")]
1035   pub compose_max_commits: Option<usize>,
1036
1037   /// Run tests after each commit
1038   #[arg(long, requires = "compose")]
1039   pub compose_test_after_each: bool,
1040
1041   // === Changelog args ===
1042   /// Disable automatic changelog updates
1043   #[arg(long)]
1044   pub no_changelog: bool,
1045
1046   // === Debug args ===
1047   /// Save intermediate outputs (diff, analysis, summary, changelog) to
1048   /// directory
1049   #[arg(long)]
1050   pub debug_output: Option<PathBuf>,
1051
1052   // === Test mode args ===
1053   /// Run fixture-based tests
1054   #[arg(long, conflicts_with_all = ["target", "rewrite", "compose"])]
1055   pub test: bool,
1056
1057   /// Update golden files with current output
1058   #[arg(long, requires = "test")]
1059   pub test_update: bool,
1060
1061   /// Add a new fixture from a commit
1062   #[arg(long, requires = "test")]
1063   pub test_add: Option<String>,
1064
1065   /// Name for the new fixture (required with --test-add)
1066   #[arg(long, requires = "test_add")]
1067   pub test_name: Option<String>,
1068
1069   /// Filter fixtures by name pattern
1070   #[arg(long, requires = "test")]
1071   pub test_filter: Option<String>,
1072
1073   /// List available fixtures
1074   #[arg(long, requires = "test")]
1075   pub test_list: bool,
1076
1077   /// Custom fixtures directory
1078   #[arg(long, requires = "test")]
1079   pub fixtures_dir: Option<PathBuf>,
1080
1081   /// Generate HTML report of test results
1082   #[arg(long, requires = "test")]
1083   pub test_report: Option<PathBuf>,
1084}
1085
1086impl Default for Args {
1087   fn default() -> Self {
1088      Self {
1089         mode:                    Mode::Staged,
1090         target:                  None,
1091         copy:                    false,
1092         dry_run:                 false,
1093         push:                    false,
1094         dir:                     ".".to_string(),
1095         model:                   None,
1096         temperature:             None,
1097         fixes:                   vec![],
1098         closes:                  vec![],
1099         resolves:                vec![],
1100         refs:                    vec![],
1101         breaking:                false,
1102         sign:                    false,
1103         signoff:                 false,
1104         amend:                   false,
1105         skip_hooks:              false,
1106         config:                  None,
1107         context:                 vec![],
1108         rewrite:                 false,
1109         rewrite_preview:         None,
1110         rewrite_start:           None,
1111         rewrite_parallel:        10,
1112         rewrite_dry_run:         false,
1113         rewrite_hide_old_types:  false,
1114         exclude_old_message:     false,
1115         fast:                    false,
1116         compose:                 false,
1117         compose_preview:         false,
1118         compose_max_commits:     None,
1119         compose_test_after_each: false,
1120         no_changelog:            false,
1121         debug_output:            None,
1122         test:                    false,
1123         test_update:             false,
1124         test_add:                None,
1125         test_name:               None,
1126         test_filter:             None,
1127         test_list:               false,
1128         fixtures_dir:            None,
1129         test_report:             None,
1130      }
1131   }
1132}
1133fn deserialize_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
1134where
1135   D: serde::Deserializer<'de>,
1136{
1137   let value = Value::deserialize(deserializer)?;
1138   Ok(value_to_string_vec(value))
1139}
1140
1141/// Deserialize analysis details from either structured format or plain strings
1142fn deserialize_analysis_details<'de, D>(
1143   deserializer: D,
1144) -> std::result::Result<Vec<AnalysisDetail>, D::Error>
1145where
1146   D: serde::Deserializer<'de>,
1147{
1148   let value = Value::deserialize(deserializer)?;
1149   match value {
1150      Value::Array(arr) => {
1151         let mut details = Vec::with_capacity(arr.len());
1152         for item in arr {
1153            let detail = match item {
1154               // New structured format: {"text": "...", "changelog_category": "Added", ...}
1155               Value::Object(obj) => {
1156                  let text = obj
1157                     .get("text")
1158                     .and_then(Value::as_str)
1159                     .map(String::from)
1160                     .unwrap_or_default();
1161                  let changelog_category = obj
1162                     .get("changelog_category")
1163                     .and_then(Value::as_str)
1164                     .map(ChangelogCategory::from_name);
1165                  let user_visible = obj
1166                     .get("user_visible")
1167                     .and_then(Value::as_bool)
1168                     .unwrap_or(false);
1169                  AnalysisDetail { text, changelog_category, user_visible }
1170               },
1171               // Old format: plain string
1172               Value::String(s) => AnalysisDetail::simple(s),
1173               _ => continue,
1174            };
1175            if !detail.text.is_empty() {
1176               details.push(detail);
1177            }
1178         }
1179         Ok(details)
1180      },
1181      Value::String(s) => {
1182         // Handle edge case where LLM returns a single string
1183         if s.is_empty() {
1184            Ok(Vec::new())
1185         } else {
1186            Ok(vec![AnalysisDetail::simple(s)])
1187         }
1188      },
1189      Value::Null => Ok(Vec::new()),
1190      _ => Ok(Vec::new()),
1191   }
1192}
1193
1194fn extract_strings_from_malformed_json(input: &str) -> Vec<String> {
1195   let mut strings = Vec::new();
1196   let mut chars = input.chars();
1197
1198   while let Some(c) = chars.next() {
1199      if c == '"' {
1200         let mut current_string = String::new();
1201         let mut escaped = false;
1202
1203         for inner_c in chars.by_ref() {
1204            if escaped {
1205               current_string.push(inner_c);
1206               escaped = false;
1207            } else if inner_c == '\\' {
1208               current_string.push(inner_c);
1209               escaped = true;
1210            } else if inner_c == '"' {
1211               break;
1212            } else {
1213               current_string.push(inner_c);
1214            }
1215         }
1216
1217         // Try to parse as JSON string first
1218         let json_candidate = format!("\"{current_string}\"");
1219         if let Ok(parsed) = serde_json::from_str::<String>(&json_candidate) {
1220            strings.push(parsed);
1221         } else {
1222            // Fallback: Replace newlines with space and try again
1223            let sanitized = current_string.replace(['\n', '\r'], " ");
1224            let json_sanitized = format!("\"{sanitized}\"");
1225            if let Ok(parsed) = serde_json::from_str::<String>(&json_sanitized) {
1226               strings.push(parsed);
1227            } else {
1228               // Ultimate fallback: raw content
1229               strings.push(sanitized);
1230            }
1231         }
1232      }
1233   }
1234   strings
1235}
1236
1237fn value_to_string_vec(value: Value) -> Vec<String> {
1238   match value {
1239      Value::Null => Vec::new(),
1240      Value::String(s) => {
1241         let trimmed = s.trim();
1242
1243         // Try to parse as JSON array if it looks like one
1244         if trimmed.starts_with('[') {
1245            // Remove trailing punctuation and quotes iteratively until stable
1246            // Handles cases like: `[...]".` or `[...].` or `[...]"`
1247            let mut cleaned = trimmed;
1248            loop {
1249               let before = cleaned;
1250               cleaned = cleaned.trim_end_matches(['.', ',', ';', '"', '\'']);
1251               if cleaned == before {
1252                  break;
1253               }
1254            }
1255
1256            // Attempt to parse as JSON array
1257            if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(cleaned) {
1258               return arr
1259                  .into_iter()
1260                  .flat_map(|v| value_to_string_vec(v).into_iter())
1261                  .collect();
1262            }
1263
1264            // Fallback: try sanitizing newlines (LLM sometimes outputs literal newlines in
1265            // JSON strings)
1266            let sanitized = cleaned.replace(['\n', '\r'], " ");
1267            if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(&sanitized) {
1268               return arr
1269                  .into_iter()
1270                  .flat_map(|v| value_to_string_vec(v).into_iter())
1271                  .collect();
1272            }
1273
1274            // Final fallback: Try manual string extraction for truncated/malformed arrays
1275            // e.g. ["Item 1", "Item 2".
1276            let extracted = extract_strings_from_malformed_json(trimmed);
1277            if !extracted.is_empty() {
1278               return extracted;
1279            }
1280         }
1281
1282         // Default: split by lines
1283         s.lines()
1284            .map(str::trim)
1285            .filter(|s| !s.is_empty())
1286            .map(|s| s.to_string())
1287            .collect()
1288      },
1289      Value::Array(arr) => arr
1290         .into_iter()
1291         .flat_map(|v| value_to_string_vec(v).into_iter())
1292         .collect(),
1293      Value::Object(map) => map
1294         .into_iter()
1295         .flat_map(|(k, v)| {
1296            let values = value_to_string_vec(v);
1297            if values.is_empty() {
1298               vec![k]
1299            } else {
1300               values
1301                  .into_iter()
1302                  .map(|val| format!("{k}: {val}"))
1303                  .collect()
1304            }
1305         })
1306         .collect(),
1307      other => vec![other.to_string()],
1308   }
1309}
1310
1311fn deserialize_optional_scope<'de, D>(
1312   deserializer: D,
1313) -> std::result::Result<Option<Scope>, D::Error>
1314where
1315   D: serde::Deserializer<'de>,
1316{
1317   let value = Option::<String>::deserialize(deserializer)?;
1318   match value {
1319      None => Ok(None),
1320      Some(scope_str) => {
1321         let trimmed = scope_str.trim();
1322         if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
1323            Ok(None)
1324         } else {
1325            Scope::new(trimmed.to_string())
1326               .map(Some)
1327               .map_err(serde::de::Error::custom)
1328         }
1329      },
1330   }
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335   use super::*;
1336
1337   // ========== resolve_model_name Tests ==========
1338
1339   #[test]
1340   fn test_resolve_model_name() {
1341      // Claude short names
1342      assert_eq!(resolve_model_name("sonnet"), "claude-sonnet-4.5");
1343      assert_eq!(resolve_model_name("s"), "claude-sonnet-4.5");
1344      assert_eq!(resolve_model_name("opus"), "claude-opus-4.5");
1345      assert_eq!(resolve_model_name("o"), "claude-opus-4.5");
1346      assert_eq!(resolve_model_name("haiku"), "claude-haiku-4-5");
1347      assert_eq!(resolve_model_name("h"), "claude-haiku-4-5");
1348
1349      // GPT short names
1350      assert_eq!(resolve_model_name("gpt5"), "gpt-5");
1351      assert_eq!(resolve_model_name("g5"), "gpt-5");
1352
1353      // Gemini short names
1354      assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
1355      assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
1356
1357      // Pass-through for full names
1358      assert_eq!(resolve_model_name("claude-sonnet-4.5"), "claude-sonnet-4.5");
1359      assert_eq!(resolve_model_name("custom-model"), "custom-model");
1360   }
1361
1362   // ========== CommitType Tests ==========
1363
1364   #[test]
1365   fn test_commit_type_valid() {
1366      let valid_types = [
1367         "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
1368         "revert",
1369      ];
1370
1371      for ty in &valid_types {
1372         assert!(CommitType::new(*ty).is_ok(), "Expected '{ty}' to be valid");
1373      }
1374   }
1375
1376   #[test]
1377   fn test_commit_type_case_normalization() {
1378      // Uppercase should normalize to lowercase
1379      let ct = CommitType::new("FEAT").expect("FEAT should normalize");
1380      assert_eq!(ct.as_str(), "feat");
1381
1382      let ct = CommitType::new("Fix").expect("Fix should normalize");
1383      assert_eq!(ct.as_str(), "fix");
1384
1385      let ct = CommitType::new("ReFaCtOr").expect("ReFaCtOr should normalize");
1386      assert_eq!(ct.as_str(), "refactor");
1387   }
1388
1389   #[test]
1390   fn test_commit_type_invalid() {
1391      let invalid_types = ["invalid", "bug", "feature", "update", "change", "random", "xyz", "123"];
1392
1393      for ty in &invalid_types {
1394         assert!(CommitType::new(*ty).is_err(), "Expected '{ty}' to be invalid");
1395      }
1396   }
1397
1398   #[test]
1399   fn test_commit_type_empty() {
1400      assert!(CommitType::new("").is_err(), "Empty string should be invalid");
1401   }
1402
1403   #[test]
1404   fn test_commit_type_display() {
1405      let ct = CommitType::new("feat").unwrap();
1406      assert_eq!(format!("{ct}"), "feat");
1407   }
1408
1409   #[test]
1410   fn test_commit_type_len() {
1411      let ct = CommitType::new("feat").unwrap();
1412      assert_eq!(ct.len(), 4);
1413
1414      let ct = CommitType::new("refactor").unwrap();
1415      assert_eq!(ct.len(), 8);
1416   }
1417
1418   // ========== Scope Tests ==========
1419
1420   #[test]
1421   fn test_scope_valid_single_segment() {
1422      let valid_scopes = ["core", "api", "lib", "client", "server", "ui", "test-123", "foo_bar"];
1423
1424      for scope in &valid_scopes {
1425         assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1426      }
1427   }
1428
1429   #[test]
1430   fn test_scope_valid_two_segments() {
1431      let valid_scopes = ["api/client", "lib/core", "ui/components", "test-1/foo_2"];
1432
1433      for scope in &valid_scopes {
1434         assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1435      }
1436   }
1437
1438   #[test]
1439   fn test_scope_invalid_three_segments() {
1440      let scope = Scope::new("a/b/c");
1441      assert!(scope.is_err(), "Three segments should be invalid");
1442
1443      if let Err(CommitGenError::InvalidScope(msg)) = scope {
1444         assert!(msg.contains("3 segments"));
1445      } else {
1446         panic!("Expected InvalidScope error");
1447      }
1448   }
1449
1450   #[test]
1451   fn test_scope_invalid_uppercase() {
1452      let invalid_scopes = ["Core", "API", "MyScope", "api/Client"];
1453
1454      for scope in &invalid_scopes {
1455         assert!(Scope::new(*scope).is_err(), "Expected '{scope}' with uppercase to be invalid");
1456      }
1457   }
1458
1459   #[test]
1460   fn test_scope_invalid_empty_segments() {
1461      let invalid_scopes = ["", "a//b", "/foo", "bar/"];
1462
1463      for scope in &invalid_scopes {
1464         assert!(
1465            Scope::new(*scope).is_err(),
1466            "Expected '{scope}' with empty segments to be invalid"
1467         );
1468      }
1469   }
1470
1471   #[test]
1472   fn test_scope_invalid_chars() {
1473      let invalid_scopes = ["a b", "foo bar", "test@scope", "api/client!", "a.b"];
1474
1475      for scope in &invalid_scopes {
1476         assert!(
1477            Scope::new(*scope).is_err(),
1478            "Expected '{scope}' with invalid chars to be invalid"
1479         );
1480      }
1481   }
1482
1483   #[test]
1484   fn test_scope_segments() {
1485      let scope = Scope::new("core").unwrap();
1486      assert_eq!(scope.segments(), vec!["core"]);
1487
1488      let scope = Scope::new("api/client").unwrap();
1489      assert_eq!(scope.segments(), vec!["api", "client"]);
1490   }
1491
1492   #[test]
1493   fn test_scope_display() {
1494      let scope = Scope::new("api/client").unwrap();
1495      assert_eq!(format!("{scope}"), "api/client");
1496   }
1497
1498   // ========== CommitSummary Tests ==========
1499
1500   #[test]
1501   fn test_commit_summary_valid() {
1502      let summary_72 = "a".repeat(72);
1503      let summary_96 = "a".repeat(96);
1504      let summary_128 = "a".repeat(128);
1505      let valid_summaries = [
1506         "added new feature",
1507         "fixed bug in authentication",
1508         "x",                  // 1 char
1509         summary_72.as_str(),  // exactly 72 chars (guideline)
1510         summary_96.as_str(),  // exactly 96 chars (soft limit)
1511         summary_128.as_str(), // exactly 128 chars (hard limit)
1512      ];
1513
1514      for summary in &valid_summaries {
1515         assert!(
1516            CommitSummary::new(*summary, 128).is_ok(),
1517            "Expected '{}' (len={}) to be valid",
1518            if summary.len() > 50 {
1519               &summary[..50]
1520            } else {
1521               summary
1522            },
1523            summary.len()
1524         );
1525      }
1526   }
1527
1528   #[test]
1529   fn test_commit_summary_too_long() {
1530      let long_summary = "a".repeat(129); // 129 chars (exceeds hard limit)
1531      let result = CommitSummary::new(long_summary, 128);
1532      assert!(result.is_err(), "129 char summary should be invalid");
1533
1534      if let Err(CommitGenError::SummaryTooLong { len, max }) = result {
1535         assert_eq!(len, 129);
1536         assert_eq!(max, 128);
1537      } else {
1538         panic!("Expected SummaryTooLong error");
1539      }
1540   }
1541
1542   #[test]
1543   fn test_commit_summary_empty() {
1544      let empty_cases = ["", "   ", "\t", "\n"];
1545
1546      for empty in &empty_cases {
1547         assert!(
1548            CommitSummary::new(*empty, 128).is_err(),
1549            "Empty/whitespace-only summary should be invalid"
1550         );
1551      }
1552   }
1553
1554   #[test]
1555   fn test_commit_summary_warnings_uppercase_start() {
1556      // Should succeed but emit warning
1557      let result = CommitSummary::new("Added new feature", 128);
1558      assert!(result.is_ok(), "Should succeed despite uppercase start");
1559   }
1560
1561   #[test]
1562   fn test_commit_summary_warnings_with_period() {
1563      // Should succeed but emit warning (periods not allowed in conventional commits)
1564      let result = CommitSummary::new("added new feature.", 128);
1565      assert!(result.is_ok(), "Should succeed despite having period");
1566   }
1567
1568   #[test]
1569   fn test_commit_summary_new_unchecked() {
1570      // new_unchecked should not emit warnings (internal use)
1571      let result = CommitSummary::new_unchecked("Added feature", 128);
1572      assert!(result.is_ok(), "new_unchecked should succeed");
1573   }
1574
1575   #[test]
1576   fn test_commit_summary_len() {
1577      let summary = CommitSummary::new("hello world", 128).unwrap();
1578      assert_eq!(summary.len(), 11);
1579   }
1580
1581   #[test]
1582   fn test_commit_summary_display() {
1583      let summary = CommitSummary::new("fixed bug", 128).unwrap();
1584      assert_eq!(format!("{summary}"), "fixed bug");
1585   }
1586
1587   // ========== Serialization Tests ==========
1588
1589   #[test]
1590   fn test_commit_type_serialize() {
1591      let ct = CommitType::new("feat").unwrap();
1592      let json = serde_json::to_string(&ct).unwrap();
1593      assert_eq!(json, "\"feat\"");
1594   }
1595
1596   #[test]
1597   fn test_commit_type_deserialize() {
1598      let ct: CommitType = serde_json::from_str("\"fix\"").unwrap();
1599      assert_eq!(ct.as_str(), "fix");
1600
1601      // Invalid type should fail deserialization
1602      let result: serde_json::Result<CommitType> = serde_json::from_str("\"invalid\"");
1603      assert!(result.is_err());
1604   }
1605
1606   #[test]
1607   fn test_scope_serialize() {
1608      let scope = Scope::new("api/client").unwrap();
1609      let json = serde_json::to_string(&scope).unwrap();
1610      assert_eq!(json, "\"api/client\"");
1611   }
1612
1613   #[test]
1614   fn test_scope_deserialize() {
1615      let scope: Scope = serde_json::from_str("\"core\"").unwrap();
1616      assert_eq!(scope.as_str(), "core");
1617
1618      // Invalid scope should fail deserialization
1619      let result: serde_json::Result<Scope> = serde_json::from_str("\"INVALID\"");
1620      assert!(result.is_err());
1621   }
1622
1623   #[test]
1624   fn test_commit_summary_serialize() {
1625      let summary = CommitSummary::new("fixed bug", 128).unwrap();
1626      let json = serde_json::to_string(&summary).unwrap();
1627      assert_eq!(json, "\"fixed bug\"");
1628   }
1629
1630   #[test]
1631   fn test_details_array_parsing() {
1632      // Test parsing of details array in various formats
1633      let test_cases = [
1634         // New structured format
1635         r#"{"type":"feat","details":[{"text":"item1"},{"text":"item2"}],"issue_refs":[]}"#,
1636         // Old plain string format (backward compatibility)
1637         r#"{"type":"feat","details":["item1","item2"],"issue_refs":[]}"#,
1638      ];
1639
1640      for (idx, json) in test_cases.iter().enumerate() {
1641         let result: serde_json::Result<ConventionalAnalysis> = serde_json::from_str(json);
1642         match result {
1643            Ok(analysis) => {
1644               let body_texts = analysis.body_texts();
1645               assert_eq!(
1646                  body_texts.len(),
1647                  2,
1648                  "Case {idx}: Expected 2 body items, got {}",
1649                  body_texts.len()
1650               );
1651               assert_eq!(body_texts[0], "item1", "Case {idx}: First item mismatch");
1652               assert_eq!(body_texts[1], "item2", "Case {idx}: Second item mismatch");
1653            },
1654            Err(e) => {
1655               panic!("Case {idx}: Failed to parse: {e}");
1656            },
1657         }
1658      }
1659   }
1660
1661   #[test]
1662   fn test_analysis_detail_with_changelog() {
1663      // Test structured detail with changelog metadata
1664      let json = r#"{
1665         "type": "feat",
1666         "details": [
1667            {"text": "Added new API endpoint", "changelog_category": "Added", "user_visible": true},
1668            {"text": "Refactored internal code", "user_visible": false}
1669         ],
1670         "issue_refs": []
1671      }"#;
1672
1673      let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1674      assert_eq!(analysis.details.len(), 2);
1675      assert_eq!(analysis.details[0].text, "Added new API endpoint");
1676      assert_eq!(analysis.details[0].changelog_category, Some(ChangelogCategory::Added));
1677      assert!(analysis.details[0].user_visible);
1678      assert!(!analysis.details[1].user_visible);
1679
1680      // Test changelog_entries helper
1681      let entries = analysis.changelog_entries();
1682      assert_eq!(entries.len(), 1);
1683      assert!(entries.contains_key(&ChangelogCategory::Added));
1684   }
1685
1686   #[test]
1687   fn test_commit_summary_deserialize() {
1688      let summary: CommitSummary = serde_json::from_str("\"added feature\"").unwrap();
1689      assert_eq!(summary.as_str(), "added feature");
1690
1691      // Too long should fail (>128 chars)
1692      let long = format!("\"{}\"", "a".repeat(129));
1693      let result: serde_json::Result<CommitSummary> = serde_json::from_str(&long);
1694      assert!(result.is_err());
1695
1696      // Empty should fail
1697      let result: serde_json::Result<CommitSummary> = serde_json::from_str("\"\"");
1698      assert!(result.is_err());
1699   }
1700
1701   #[test]
1702   fn test_conventional_commit_roundtrip() {
1703      let commit = ConventionalCommit {
1704         commit_type: CommitType::new("feat").unwrap(),
1705         scope:       Some(Scope::new("api").unwrap()),
1706         summary:     CommitSummary::new_unchecked("added endpoint", 128).unwrap(),
1707         body:        vec!["detail 1.".to_string(), "detail 2.".to_string()],
1708         footers:     vec!["Fixes: #123".to_string()],
1709      };
1710
1711      let json = serde_json::to_string(&commit).unwrap();
1712      let deserialized: ConventionalCommit = serde_json::from_str(&json).unwrap();
1713
1714      assert_eq!(deserialized.commit_type.as_str(), "feat");
1715      assert_eq!(deserialized.scope.unwrap().as_str(), "api");
1716      assert_eq!(deserialized.summary.as_str(), "added endpoint");
1717      assert_eq!(deserialized.body.len(), 2);
1718      assert_eq!(deserialized.footers.len(), 1);
1719   }
1720
1721   #[test]
1722   fn test_scope_null_string_deserializes_to_none() {
1723      // LLMs sometimes return "null" as a string instead of JSON null
1724      let test_cases = [
1725         r#"{"type":"feat","scope":"null","body":[],"issue_refs":[]}"#,
1726         r#"{"type":"feat","scope":"Null","body":[],"issue_refs":[]}"#,
1727         r#"{"type":"feat","scope":"NULL","body":[],"issue_refs":[]}"#,
1728         r#"{"type":"feat","scope":" null ","body":[],"issue_refs":[]}"#,
1729      ];
1730
1731      for (idx, json) in test_cases.iter().enumerate() {
1732         let analysis: ConventionalAnalysis = serde_json::from_str(json)
1733            .unwrap_or_else(|e| panic!("Case {idx} failed to deserialize: {e}"));
1734         assert!(
1735            analysis.scope.is_none(),
1736            "Case {idx}: Expected scope to be None, got {:?}",
1737            analysis.scope
1738         );
1739      }
1740   }
1741
1742   // ========== HunkSelector Tests ==========
1743
1744   #[test]
1745   fn test_body_array_with_newline_in_string() {
1746      // This reproduces the issue where literal newlines in the string prevent JSON
1747      // parsing The input mimics what happens when LLM returns a JSON string
1748      // with unescaped newlines
1749      let raw_str = "[\"Item 1\", \"Item\n2\"]";
1750      let value = serde_json::Value::String(raw_str.to_string());
1751
1752      // desired behavior: should clean the newline and parse as array
1753      let result = value_to_string_vec(value);
1754
1755      // It should be ["Item 1", "Item 2"] (newline replaced by space)
1756      assert_eq!(result.len(), 2);
1757      assert_eq!(result[0], "Item 1");
1758      // Depending on implementation, it might be "Item 2" or "Item  2" etc.
1759      // For now let's assume we replace with space.
1760      assert_eq!(result[1], "Item 2");
1761   }
1762
1763   #[test]
1764   fn test_body_array_malformed_truncated() {
1765      // This reproduces the issue where the array is truncated or has trailing
1766      // punctuation
1767      let raw_str = "[\"Refactored finance...\", \"Added automatic detection...\".";
1768      let value = serde_json::Value::String(raw_str.to_string());
1769
1770      let result = value_to_string_vec(value);
1771
1772      // Should recover 2 items
1773      assert_eq!(result.len(), 2);
1774      assert_eq!(result[0], "Refactored finance...");
1775      assert_eq!(result[1], "Added automatic detection...");
1776   }
1777
1778   #[test]
1779   fn test_hunk_selector_deserialize_all() {
1780      let json = r#""ALL""#;
1781      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1782      assert!(matches!(selector, HunkSelector::All));
1783   }
1784
1785   #[test]
1786   fn test_hunk_selector_deserialize_lines_object() {
1787      let json = r#"{"start": 10, "end": 20}"#;
1788      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1789      match selector {
1790         HunkSelector::Lines { start, end } => {
1791            assert_eq!(start, 10);
1792            assert_eq!(end, 20);
1793         },
1794         _ => panic!("Expected Lines variant"),
1795      }
1796   }
1797
1798   #[test]
1799   fn test_hunk_selector_deserialize_lines_string() {
1800      let json = r#""10-20""#;
1801      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1802      match selector {
1803         HunkSelector::Lines { start, end } => {
1804            assert_eq!(start, 10);
1805            assert_eq!(end, 20);
1806         },
1807         _ => panic!("Expected Lines variant"),
1808      }
1809   }
1810
1811   #[test]
1812   fn test_hunk_selector_deserialize_search_pattern() {
1813      let json = r#"{"pattern": "fn main"}"#;
1814      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1815      match selector {
1816         HunkSelector::Search { pattern } => {
1817            assert_eq!(pattern, "fn main");
1818         },
1819         _ => panic!("Expected Search variant"),
1820      }
1821   }
1822
1823   #[test]
1824   fn test_hunk_selector_deserialize_old_format_hunk_header() {
1825      // Old format: hunk headers like "@@ -10,5 +10,7 @@" should be treated as search
1826      let json = r#""@@ -10,5 +10,7 @@""#;
1827      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1828      match selector {
1829         HunkSelector::Search { pattern } => {
1830            assert_eq!(pattern, "@@ -10,5 +10,7 @@");
1831         },
1832         _ => panic!("Expected Search variant for old hunk header format"),
1833      }
1834   }
1835
1836   #[test]
1837   fn test_hunk_selector_serialize_all() {
1838      let selector = HunkSelector::All;
1839      let json = serde_json::to_string(&selector).unwrap();
1840      assert_eq!(json, r#""ALL""#);
1841   }
1842
1843   #[test]
1844   fn test_hunk_selector_serialize_lines() {
1845      let selector = HunkSelector::Lines { start: 10, end: 20 };
1846      let json = serde_json::to_value(&selector).unwrap();
1847      assert_eq!(json["start"], 10);
1848      assert_eq!(json["end"], 20);
1849   }
1850
1851   #[test]
1852   fn test_file_change_deserialize_with_all() {
1853      let json = r#"{"path": "src/main.rs", "hunks": ["ALL"]}"#;
1854      let change: FileChange = serde_json::from_str(json).unwrap();
1855      assert_eq!(change.path, "src/main.rs");
1856      assert_eq!(change.hunks.len(), 1);
1857      assert!(matches!(change.hunks[0], HunkSelector::All));
1858   }
1859
1860   #[test]
1861   fn test_file_change_deserialize_with_line_ranges() {
1862      let json = r#"{"path": "src/main.rs", "hunks": [{"start": 10, "end": 20}, {"start": 50, "end": 60}]}"#;
1863      let change: FileChange = serde_json::from_str(json).unwrap();
1864      assert_eq!(change.path, "src/main.rs");
1865      assert_eq!(change.hunks.len(), 2);
1866
1867      match &change.hunks[0] {
1868         HunkSelector::Lines { start, end } => {
1869            assert_eq!(*start, 10);
1870            assert_eq!(*end, 20);
1871         },
1872         _ => panic!("Expected Lines variant"),
1873      }
1874
1875      match &change.hunks[1] {
1876         HunkSelector::Lines { start, end } => {
1877            assert_eq!(*start, 50);
1878            assert_eq!(*end, 60);
1879         },
1880         _ => panic!("Expected Lines variant"),
1881      }
1882   }
1883
1884   #[test]
1885   fn test_file_change_deserialize_mixed_formats() {
1886      // Mix of string line ranges and object line ranges
1887      let json = r#"{"path": "src/main.rs", "hunks": ["10-20", {"start": 50, "end": 60}]}"#;
1888      let change: FileChange = serde_json::from_str(json).unwrap();
1889      assert_eq!(change.hunks.len(), 2);
1890
1891      match &change.hunks[0] {
1892         HunkSelector::Lines { start, end } => {
1893            assert_eq!(*start, 10);
1894            assert_eq!(*end, 20);
1895         },
1896         _ => panic!("Expected Lines variant"),
1897      }
1898
1899      match &change.hunks[1] {
1900         HunkSelector::Lines { start, end } => {
1901            assert_eq!(*start, 50);
1902            assert_eq!(*end, 60);
1903         },
1904         _ => panic!("Expected Lines variant"),
1905      }
1906   }
1907}