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