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