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