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
1071   /// Issue numbers this commit fixes (e.g., --fixes 123 456)
1072   #[arg(long)]
1073   pub fixes: Vec<String>,
1074
1075   /// Issue numbers this commit closes (alias for --fixes)
1076   #[arg(long)]
1077   pub closes: Vec<String>,
1078
1079   /// Issue numbers this commit resolves (alias for --fixes)
1080   #[arg(long)]
1081   pub resolves: Vec<String>,
1082
1083   /// Related issue numbers (e.g., --refs 789)
1084   #[arg(long)]
1085   pub refs: Vec<String>,
1086
1087   /// Mark this commit as a breaking change
1088   #[arg(long)]
1089   pub breaking: bool,
1090
1091   /// GPG sign the commit (equivalent to git commit -S)
1092   #[arg(long, short = 'S')]
1093   pub sign: bool,
1094
1095   /// Add Signed-off-by trailer (equivalent to git commit -s)
1096   #[arg(long, short = 's')]
1097   pub signoff: bool,
1098
1099   /// Amend the previous commit (equivalent to git commit --amend)
1100   #[arg(long)]
1101   pub amend: bool,
1102
1103   /// Skip pre-commit and commit-msg hooks (equivalent to git commit
1104   /// --no-verify)
1105   #[arg(long, short = 'n')]
1106   pub skip_hooks: bool,
1107
1108   /// Path to config file (default: ~/.config/llm-git/config.toml)
1109   #[arg(long)]
1110   pub config: Option<PathBuf>,
1111
1112   /// Generate a shell completion script for the given shell and print it to
1113   /// stdout (bash, zsh, fish, powershell, elvish)
1114   #[arg(long, value_enum, value_name = "SHELL")]
1115   pub completions: Option<clap_complete::Shell>,
1116
1117   /// Additional context to provide to the analysis model (all trailing
1118   /// non-flag text)
1119   #[arg(trailing_var_arg = true)]
1120   pub context: Vec<String>,
1121
1122   // === Fast mode args ===
1123   /// Fast mode: single-call commit generation (skip changelog)
1124   #[arg(long, short = 'f', conflicts_with_all = ["compose", "rewrite", "test"])]
1125   pub fast: bool,
1126
1127   // === Rewrite mode args ===
1128   /// Rewrite git history to conventional commits
1129   #[arg(long, conflicts_with_all = ["target", "copy", "dry_run"])]
1130   pub rewrite: bool,
1131
1132   /// Preview N commits without rewriting
1133   #[arg(long, requires = "rewrite")]
1134   pub rewrite_preview: Option<usize>,
1135
1136   /// Start from this ref (exclusive, e.g., main~50)
1137   #[arg(long, requires = "rewrite")]
1138   pub rewrite_start: Option<String>,
1139
1140   /// Number of parallel API calls
1141   #[arg(long, default_value = "10", requires = "rewrite")]
1142   pub rewrite_parallel: usize,
1143
1144   /// Dry run - show what would be changed
1145   #[arg(long, requires = "rewrite")]
1146   pub rewrite_dry_run: bool,
1147
1148   /// Hide old commit type/scope tags to avoid model influence
1149   #[arg(long, requires = "rewrite")]
1150   pub rewrite_hide_old_types: bool,
1151
1152   /// Exclude old commit message from context when analyzing commits (prevents
1153   /// contamination)
1154   #[arg(long)]
1155   pub exclude_old_message: bool,
1156
1157   // === Compose mode args ===
1158   /// Compose changes into multiple atomic commits
1159   #[arg(long, conflicts_with_all = ["target", "rewrite"])]
1160   pub compose: bool,
1161
1162   /// Preview proposed splits without committing
1163   #[arg(long, requires = "compose")]
1164   pub compose_preview: bool,
1165
1166   /// Maximum number of commits to create
1167   #[arg(long, requires = "compose")]
1168   pub compose_max_commits: Option<usize>,
1169
1170   /// Run tests after each commit
1171   #[arg(long, requires = "compose")]
1172   pub compose_test_after_each: bool,
1173
1174   // === Changelog args ===
1175   /// Disable automatic changelog updates
1176   #[arg(long)]
1177   pub no_changelog: bool,
1178
1179   // === Debug args ===
1180   /// Save intermediate outputs (diff, analysis, summary, changelog) to
1181   /// directory
1182   #[arg(long)]
1183   pub debug_output: Option<PathBuf>,
1184
1185   /// Write detailed profiling trace events as JSON lines to this file
1186   #[arg(long, value_name = "FILE")]
1187   pub trace_output: Option<PathBuf>,
1188
1189   // === Test mode args ===
1190   /// Run fixture-based tests
1191   #[arg(long, conflicts_with_all = ["target", "rewrite", "compose"])]
1192   pub test: bool,
1193
1194   /// Update golden files with current output
1195   #[arg(long, requires = "test")]
1196   pub test_update: bool,
1197
1198   /// Add a new fixture from a commit
1199   #[arg(long, requires = "test")]
1200   pub test_add: Option<String>,
1201
1202   /// Name for the new fixture (required with --test-add)
1203   #[arg(long, requires = "test_add")]
1204   pub test_name: Option<String>,
1205
1206   /// Filter fixtures by name pattern
1207   #[arg(long, requires = "test")]
1208   pub test_filter: Option<String>,
1209
1210   /// List available fixtures
1211   #[arg(long, requires = "test")]
1212   pub test_list: bool,
1213
1214   /// Custom fixtures directory
1215   #[arg(long, requires = "test")]
1216   pub fixtures_dir: Option<PathBuf>,
1217
1218   /// Generate HTML report of test results
1219   #[arg(long, requires = "test")]
1220   pub test_report: Option<PathBuf>,
1221}
1222
1223impl Default for Args {
1224   fn default() -> Self {
1225      Self {
1226         mode:                    Mode::Staged,
1227         target:                  None,
1228         copy:                    false,
1229         dry_run:                 false,
1230         push:                    false,
1231         dir:                     ".".to_string(),
1232         model:                   None,
1233         fixes:                   vec![],
1234         closes:                  vec![],
1235         resolves:                vec![],
1236         refs:                    vec![],
1237         breaking:                false,
1238         sign:                    false,
1239         signoff:                 false,
1240         amend:                   false,
1241         skip_hooks:              false,
1242         config:                  None,
1243         context:                 vec![],
1244         completions:             None,
1245         rewrite:                 false,
1246         rewrite_preview:         None,
1247         rewrite_start:           None,
1248         rewrite_parallel:        10,
1249         rewrite_dry_run:         false,
1250         rewrite_hide_old_types:  false,
1251         exclude_old_message:     false,
1252         fast:                    false,
1253         compose:                 false,
1254         compose_preview:         false,
1255         compose_max_commits:     None,
1256         compose_test_after_each: false,
1257         no_changelog:            false,
1258         debug_output:            None,
1259         trace_output:            None,
1260         test:                    false,
1261         test_update:             false,
1262         test_add:                None,
1263         test_name:               None,
1264         test_filter:             None,
1265         test_list:               false,
1266         fixtures_dir:            None,
1267         test_report:             None,
1268      }
1269   }
1270}
1271fn deserialize_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
1272where
1273   D: serde::Deserializer<'de>,
1274{
1275   let value = Value::deserialize(deserializer)?;
1276   Ok(value_to_string_vec(value))
1277}
1278
1279/// Deserialize analysis details from either structured format or plain strings
1280fn deserialize_analysis_details<'de, D>(
1281   deserializer: D,
1282) -> std::result::Result<Vec<AnalysisDetail>, D::Error>
1283where
1284   D: serde::Deserializer<'de>,
1285{
1286   let value = Value::deserialize(deserializer)?;
1287   match value {
1288      Value::Array(arr) => {
1289         let mut details = Vec::with_capacity(arr.len());
1290         for item in arr {
1291            let detail = match item {
1292               // New structured format: {"text": "...", "changelog_category": "Added", ...}
1293               Value::Object(obj) => {
1294                  let text = obj
1295                     .get("text")
1296                     .and_then(Value::as_str)
1297                     .map(String::from)
1298                     .unwrap_or_default();
1299                  let changelog_category = obj
1300                     .get("changelog_category")
1301                     .and_then(Value::as_str)
1302                     .map(ChangelogCategory::from_name);
1303                  let user_visible = obj
1304                     .get("user_visible")
1305                     .and_then(Value::as_bool)
1306                     .unwrap_or(false);
1307                  AnalysisDetail { text, changelog_category, user_visible }
1308               },
1309               // Old format: plain string
1310               Value::String(s) => AnalysisDetail::simple(s),
1311               _ => continue,
1312            };
1313            if !detail.text.is_empty() {
1314               details.push(detail);
1315            }
1316         }
1317         Ok(details)
1318      },
1319      Value::String(s) => {
1320         // Handle edge case where LLM returns a single string
1321         if s.is_empty() {
1322            Ok(Vec::new())
1323         } else {
1324            Ok(vec![AnalysisDetail::simple(s)])
1325         }
1326      },
1327      Value::Null => Ok(Vec::new()),
1328      _ => Ok(Vec::new()),
1329   }
1330}
1331
1332fn extract_strings_from_malformed_json(input: &str) -> Vec<String> {
1333   let mut strings = Vec::new();
1334   let mut chars = input.chars();
1335
1336   while let Some(c) = chars.next() {
1337      if c == '"' {
1338         let mut current_string = String::new();
1339         let mut escaped = false;
1340
1341         for inner_c in chars.by_ref() {
1342            if escaped {
1343               current_string.push(inner_c);
1344               escaped = false;
1345            } else if inner_c == '\\' {
1346               current_string.push(inner_c);
1347               escaped = true;
1348            } else if inner_c == '"' {
1349               break;
1350            } else {
1351               current_string.push(inner_c);
1352            }
1353         }
1354
1355         // Try to parse as JSON string first
1356         let json_candidate = format!("\"{current_string}\"");
1357         if let Ok(parsed) = serde_json::from_str::<String>(&json_candidate) {
1358            strings.push(parsed);
1359         } else {
1360            // Fallback: Replace newlines with space and try again
1361            let sanitized = current_string.replace(['\n', '\r'], " ");
1362            let json_sanitized = format!("\"{sanitized}\"");
1363            if let Ok(parsed) = serde_json::from_str::<String>(&json_sanitized) {
1364               strings.push(parsed);
1365            } else {
1366               // Ultimate fallback: raw content
1367               strings.push(sanitized);
1368            }
1369         }
1370      }
1371   }
1372   strings
1373}
1374
1375fn value_to_string_vec(value: Value) -> Vec<String> {
1376   match value {
1377      Value::Null => Vec::new(),
1378      Value::String(s) => {
1379         let trimmed = s.trim();
1380
1381         // Try to parse as JSON array if it looks like one
1382         if trimmed.starts_with('[') {
1383            // Remove trailing punctuation and quotes iteratively until stable
1384            // Handles cases like: `[...]".` or `[...].` or `[...]"`
1385            let mut cleaned = trimmed;
1386            loop {
1387               let before = cleaned;
1388               cleaned = cleaned.trim_end_matches(['.', ',', ';', '"', '\'']);
1389               if cleaned == before {
1390                  break;
1391               }
1392            }
1393
1394            // Attempt to parse as JSON array
1395            if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(cleaned) {
1396               return arr
1397                  .into_iter()
1398                  .flat_map(|v| value_to_string_vec(v).into_iter())
1399                  .collect();
1400            }
1401
1402            // Fallback: try sanitizing newlines (LLM sometimes outputs literal newlines in
1403            // JSON strings)
1404            let sanitized = cleaned.replace(['\n', '\r'], " ");
1405            if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(&sanitized) {
1406               return arr
1407                  .into_iter()
1408                  .flat_map(|v| value_to_string_vec(v).into_iter())
1409                  .collect();
1410            }
1411
1412            // Final fallback: Try manual string extraction for truncated/malformed arrays
1413            // e.g. ["Item 1", "Item 2".
1414            let extracted = extract_strings_from_malformed_json(trimmed);
1415            if !extracted.is_empty() {
1416               return extracted;
1417            }
1418         }
1419
1420         // Default: split by lines
1421         s.lines()
1422            .map(str::trim)
1423            .filter(|s| !s.is_empty())
1424            .map(|s| s.to_string())
1425            .collect()
1426      },
1427      Value::Array(arr) => arr
1428         .into_iter()
1429         .flat_map(|v| value_to_string_vec(v).into_iter())
1430         .collect(),
1431      Value::Object(map) => map
1432         .into_iter()
1433         .flat_map(|(k, v)| {
1434            let values = value_to_string_vec(v);
1435            if values.is_empty() {
1436               vec![k]
1437            } else {
1438               values
1439                  .into_iter()
1440                  .map(|val| format!("{k}: {val}"))
1441                  .collect()
1442            }
1443         })
1444         .collect(),
1445      other => vec![other.to_string()],
1446   }
1447}
1448
1449fn deserialize_optional_scope<'de, D>(
1450   deserializer: D,
1451) -> std::result::Result<Option<Scope>, D::Error>
1452where
1453   D: serde::Deserializer<'de>,
1454{
1455   let value = Option::<String>::deserialize(deserializer)?;
1456   Ok(coerce_optional_scope(value.as_deref()))
1457}
1458
1459pub(crate) fn coerce_optional_scope(raw: Option<&str>) -> Option<Scope> {
1460   match raw {
1461      None => None,
1462      Some(scope_str) => {
1463         let trimmed = scope_str.trim();
1464         if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
1465            None
1466         } else {
1467            coerce_scope(trimmed)
1468         }
1469      },
1470   }
1471}
1472
1473fn coerce_scope(raw: &str) -> Option<Scope> {
1474   let normalized = raw.trim().replace('\\', "/").to_lowercase();
1475
1476   let segments: Vec<String> = normalized
1477      .split('/')
1478      .filter_map(sanitize_scope_segment)
1479      .take(2)
1480      .collect();
1481
1482   if segments.is_empty() {
1483      return None;
1484   }
1485
1486   Scope::new(segments.join("/")).ok()
1487}
1488
1489fn sanitize_scope_segment(segment: &str) -> Option<String> {
1490   let mut out = String::new();
1491   let mut last_was_separator = false;
1492
1493   for ch in segment.trim().chars() {
1494      if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
1495         out.push(ch);
1496         last_was_separator = false;
1497      } else if ch == '-' || ch == '_' {
1498         if !out.is_empty() && !last_was_separator {
1499            out.push(ch);
1500            last_was_separator = true;
1501         }
1502      } else if (ch.is_ascii_whitespace() || ch == '.') && !out.is_empty() && !last_was_separator {
1503         out.push('-');
1504         last_was_separator = true;
1505      }
1506   }
1507
1508   let trimmed = out.trim_matches(['-', '_']).to_string();
1509   (!trimmed.is_empty()).then_some(trimmed)
1510}
1511
1512#[cfg(test)]
1513mod tests {
1514   use super::*;
1515
1516   // ========== resolve_model_name Tests ==========
1517
1518   #[test]
1519   fn test_resolve_model_name() {
1520      // Claude short names
1521      assert_eq!(resolve_model_name("sonnet"), "claude-sonnet-4.5");
1522      assert_eq!(resolve_model_name("s"), "claude-sonnet-4.5");
1523      assert_eq!(resolve_model_name("opus"), "claude-opus-4.5");
1524      assert_eq!(resolve_model_name("o"), "claude-opus-4.5");
1525      assert_eq!(resolve_model_name("haiku"), "claude-haiku-4-5");
1526      assert_eq!(resolve_model_name("h"), "claude-haiku-4-5");
1527
1528      // GPT short names
1529      assert_eq!(resolve_model_name("gpt5"), "gpt-5");
1530      assert_eq!(resolve_model_name("g5"), "gpt-5");
1531
1532      // Gemini short names
1533      assert_eq!(resolve_model_name("gemini"), "gemini-2.5-pro");
1534      assert_eq!(resolve_model_name("flash"), "gemini-2.5-flash");
1535
1536      // Pass-through for full names
1537      assert_eq!(resolve_model_name("claude-sonnet-4.5"), "claude-sonnet-4.5");
1538      assert_eq!(resolve_model_name("custom-model"), "custom-model");
1539   }
1540
1541   // ========== CommitType Tests ==========
1542
1543   #[test]
1544   fn test_commit_type_valid() {
1545      let valid_types = [
1546         "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
1547         "revert",
1548      ];
1549
1550      for ty in &valid_types {
1551         assert!(CommitType::new(*ty).is_ok(), "Expected '{ty}' to be valid");
1552      }
1553   }
1554
1555   #[test]
1556   fn test_commit_type_case_normalization() {
1557      // Uppercase should normalize to lowercase
1558      let ct = CommitType::new("FEAT").expect("FEAT should normalize");
1559      assert_eq!(ct.as_str(), "feat");
1560
1561      let ct = CommitType::new("Fix").expect("Fix should normalize");
1562      assert_eq!(ct.as_str(), "fix");
1563
1564      let ct = CommitType::new("ReFaCtOr").expect("ReFaCtOr should normalize");
1565      assert_eq!(ct.as_str(), "refactor");
1566   }
1567
1568   #[test]
1569   fn test_commit_type_invalid() {
1570      let invalid_types = ["invalid", "bug", "feature", "update", "change", "random", "xyz", "123"];
1571
1572      for ty in &invalid_types {
1573         assert!(CommitType::new(*ty).is_err(), "Expected '{ty}' to be invalid");
1574      }
1575   }
1576
1577   #[test]
1578   fn test_commit_type_empty() {
1579      assert!(CommitType::new("").is_err(), "Empty string should be invalid");
1580   }
1581
1582   #[test]
1583   fn test_commit_type_display() {
1584      let ct = CommitType::new("feat").unwrap();
1585      assert_eq!(format!("{ct}"), "feat");
1586   }
1587
1588   #[test]
1589   fn test_commit_type_len() {
1590      let ct = CommitType::new("feat").unwrap();
1591      assert_eq!(ct.len(), 4);
1592
1593      let ct = CommitType::new("refactor").unwrap();
1594      assert_eq!(ct.len(), 8);
1595   }
1596
1597   // ========== Scope Tests ==========
1598
1599   #[test]
1600   fn test_scope_valid_single_segment() {
1601      let valid_scopes = ["core", "api", "lib", "client", "server", "ui", "test-123", "foo_bar"];
1602
1603      for scope in &valid_scopes {
1604         assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1605      }
1606   }
1607
1608   #[test]
1609   fn test_scope_valid_two_segments() {
1610      let valid_scopes = ["api/client", "lib/core", "ui/components", "test-1/foo_2"];
1611
1612      for scope in &valid_scopes {
1613         assert!(Scope::new(*scope).is_ok(), "Expected '{scope}' to be valid");
1614      }
1615   }
1616
1617   #[test]
1618   fn test_scope_invalid_three_segments() {
1619      let scope = Scope::new("a/b/c");
1620      assert!(scope.is_err(), "Three segments should be invalid");
1621
1622      if let Err(CommitGenError::InvalidScope(msg)) = scope {
1623         assert!(msg.contains("3 segments"));
1624      } else {
1625         panic!("Expected InvalidScope error");
1626      }
1627   }
1628
1629   #[test]
1630   fn test_scope_invalid_uppercase() {
1631      let invalid_scopes = ["Core", "API", "MyScope", "api/Client"];
1632
1633      for scope in &invalid_scopes {
1634         assert!(Scope::new(*scope).is_err(), "Expected '{scope}' with uppercase to be invalid");
1635      }
1636   }
1637
1638   #[test]
1639   fn test_scope_invalid_empty_segments() {
1640      let invalid_scopes = ["", "a//b", "/foo", "bar/"];
1641
1642      for scope in &invalid_scopes {
1643         assert!(
1644            Scope::new(*scope).is_err(),
1645            "Expected '{scope}' with empty segments to be invalid"
1646         );
1647      }
1648   }
1649
1650   #[test]
1651   fn test_scope_invalid_chars() {
1652      let invalid_scopes = ["a b", "foo bar", "test@scope", "api/client!", "a.b"];
1653
1654      for scope in &invalid_scopes {
1655         assert!(
1656            Scope::new(*scope).is_err(),
1657            "Expected '{scope}' with invalid chars to be invalid"
1658         );
1659      }
1660   }
1661
1662   #[test]
1663   fn test_scope_segments() {
1664      let scope = Scope::new("core").unwrap();
1665      assert_eq!(scope.segments(), vec!["core"]);
1666
1667      let scope = Scope::new("api/client").unwrap();
1668      assert_eq!(scope.segments(), vec!["api", "client"]);
1669   }
1670
1671   #[test]
1672   fn test_scope_display() {
1673      let scope = Scope::new("api/client").unwrap();
1674      assert_eq!(format!("{scope}"), "api/client");
1675   }
1676
1677   // ========== CommitSummary Tests ==========
1678
1679   #[test]
1680   fn test_commit_summary_valid() {
1681      let summary_72 = "a".repeat(72);
1682      let summary_96 = "a".repeat(96);
1683      let summary_128 = "a".repeat(128);
1684      let valid_summaries = [
1685         "added new feature",
1686         "fixed bug in authentication",
1687         "x",                  // 1 char
1688         summary_72.as_str(),  // exactly 72 chars (guideline)
1689         summary_96.as_str(),  // exactly 96 chars (soft limit)
1690         summary_128.as_str(), // exactly 128 chars (hard limit)
1691      ];
1692
1693      for summary in &valid_summaries {
1694         assert!(
1695            CommitSummary::new(*summary, 128).is_ok(),
1696            "Expected '{}' (len={}) to be valid",
1697            if summary.len() > 50 {
1698               &summary[..50]
1699            } else {
1700               summary
1701            },
1702            summary.len()
1703         );
1704      }
1705   }
1706
1707   #[test]
1708   fn test_commit_summary_too_long() {
1709      let long_summary = "a".repeat(129); // 129 chars (exceeds hard limit)
1710      let result = CommitSummary::new(long_summary, 128);
1711      assert!(result.is_err(), "129 char summary should be invalid");
1712
1713      if let Err(CommitGenError::SummaryTooLong { len, max }) = result {
1714         assert_eq!(len, 129);
1715         assert_eq!(max, 128);
1716      } else {
1717         panic!("Expected SummaryTooLong error");
1718      }
1719   }
1720
1721   #[test]
1722   fn test_commit_summary_empty() {
1723      let empty_cases = ["", "   ", "\t", "\n"];
1724
1725      for empty in &empty_cases {
1726         assert!(
1727            CommitSummary::new(*empty, 128).is_err(),
1728            "Empty/whitespace-only summary should be invalid"
1729         );
1730      }
1731   }
1732
1733   #[test]
1734   fn test_commit_summary_warnings_uppercase_start() {
1735      // Should succeed but emit warning
1736      let result = CommitSummary::new("Added new feature", 128);
1737      assert!(result.is_ok(), "Should succeed despite uppercase start");
1738   }
1739
1740   #[test]
1741   fn test_commit_summary_warnings_with_period() {
1742      // Should succeed but emit warning (periods not allowed in conventional commits)
1743      let result = CommitSummary::new("added new feature.", 128);
1744      assert!(result.is_ok(), "Should succeed despite having period");
1745   }
1746
1747   #[test]
1748   fn test_commit_summary_new_unchecked() {
1749      // new_unchecked should not emit warnings (internal use)
1750      let result = CommitSummary::new_unchecked("Added feature", 128);
1751      assert!(result.is_ok(), "new_unchecked should succeed");
1752   }
1753
1754   #[test]
1755   fn test_commit_summary_len() {
1756      let summary = CommitSummary::new("hello world", 128).unwrap();
1757      assert_eq!(summary.len(), 11);
1758   }
1759
1760   #[test]
1761   fn test_commit_summary_display() {
1762      let summary = CommitSummary::new("fixed bug", 128).unwrap();
1763      assert_eq!(format!("{summary}"), "fixed bug");
1764   }
1765
1766   // ========== Serialization Tests ==========
1767
1768   #[test]
1769   fn test_commit_type_serialize() {
1770      let ct = CommitType::new("feat").unwrap();
1771      let json = serde_json::to_string(&ct).unwrap();
1772      assert_eq!(json, "\"feat\"");
1773   }
1774
1775   #[test]
1776   fn test_commit_type_deserialize() {
1777      let ct: CommitType = serde_json::from_str("\"fix\"").unwrap();
1778      assert_eq!(ct.as_str(), "fix");
1779
1780      // Invalid type should fail deserialization
1781      let result: serde_json::Result<CommitType> = serde_json::from_str("\"invalid\"");
1782      assert!(result.is_err());
1783   }
1784
1785   #[test]
1786   fn test_scope_serialize() {
1787      let scope = Scope::new("api/client").unwrap();
1788      let json = serde_json::to_string(&scope).unwrap();
1789      assert_eq!(json, "\"api/client\"");
1790   }
1791
1792   #[test]
1793   fn test_scope_deserialize() {
1794      let scope: Scope = serde_json::from_str("\"core\"").unwrap();
1795      assert_eq!(scope.as_str(), "core");
1796
1797      // Invalid scope should fail deserialization
1798      let result: serde_json::Result<Scope> = serde_json::from_str("\"INVALID\"");
1799      assert!(result.is_err());
1800   }
1801
1802   #[test]
1803   fn test_commit_summary_serialize() {
1804      let summary = CommitSummary::new("fixed bug", 128).unwrap();
1805      let json = serde_json::to_string(&summary).unwrap();
1806      assert_eq!(json, "\"fixed bug\"");
1807   }
1808
1809   #[test]
1810   fn test_details_array_parsing() {
1811      // Test parsing of details array in various formats
1812      let test_cases = [
1813         // New structured format
1814         r#"{"type":"feat","details":[{"text":"item1"},{"text":"item2"}],"issue_refs":[]}"#,
1815         // Old plain string format (backward compatibility)
1816         r#"{"type":"feat","details":["item1","item2"],"issue_refs":[]}"#,
1817      ];
1818
1819      for (idx, json) in test_cases.iter().enumerate() {
1820         let result: serde_json::Result<ConventionalAnalysis> = serde_json::from_str(json);
1821         match result {
1822            Ok(analysis) => {
1823               let body_texts = analysis.body_texts();
1824               assert_eq!(
1825                  body_texts.len(),
1826                  2,
1827                  "Case {idx}: Expected 2 body items, got {}",
1828                  body_texts.len()
1829               );
1830               assert_eq!(body_texts[0], "item1", "Case {idx}: First item mismatch");
1831               assert_eq!(body_texts[1], "item2", "Case {idx}: Second item mismatch");
1832            },
1833            Err(e) => {
1834               panic!("Case {idx}: Failed to parse: {e}");
1835            },
1836         }
1837      }
1838   }
1839
1840   #[test]
1841   fn test_conventional_analysis_summary_roundtrip() {
1842      let json = r##"{
1843         "type": "feat",
1844         "scope": "api",
1845         "summary": "added holistic commit titles",
1846         "details": [{"text": "Added summary generation to holistic analysis.", "user_visible": false}],
1847         "issue_refs": ["#123"]
1848      }"##;
1849
1850      let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1851      assert_eq!(analysis.summary.as_deref(), Some("added holistic commit titles"));
1852
1853      let serialized = serde_json::to_value(&analysis).unwrap();
1854      assert_eq!(serialized["summary"], "added holistic commit titles");
1855   }
1856
1857   #[test]
1858   fn test_analysis_detail_with_changelog() {
1859      // Test structured detail with changelog metadata
1860      let json = r#"{
1861         "type": "feat",
1862         "details": [
1863            {"text": "Added new API endpoint", "changelog_category": "Added", "user_visible": true},
1864            {"text": "Refactored internal code", "user_visible": false}
1865         ],
1866         "issue_refs": []
1867      }"#;
1868
1869      let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1870      assert_eq!(analysis.details.len(), 2);
1871      assert_eq!(analysis.details[0].text, "Added new API endpoint");
1872      assert_eq!(analysis.details[0].changelog_category, Some(ChangelogCategory::Added));
1873      assert!(analysis.details[0].user_visible);
1874      assert!(!analysis.details[1].user_visible);
1875
1876      // Test changelog_entries helper
1877      let entries = analysis.changelog_entries();
1878      assert_eq!(entries.len(), 1);
1879      assert!(entries.contains_key(&ChangelogCategory::Added));
1880   }
1881
1882   #[test]
1883   fn test_commit_summary_deserialize() {
1884      let summary: CommitSummary = serde_json::from_str("\"added feature\"").unwrap();
1885      assert_eq!(summary.as_str(), "added feature");
1886
1887      // Too long should fail (>128 chars)
1888      let long = format!("\"{}\"", "a".repeat(129));
1889      let result: serde_json::Result<CommitSummary> = serde_json::from_str(&long);
1890      assert!(result.is_err());
1891
1892      // Empty should fail
1893      let result: serde_json::Result<CommitSummary> = serde_json::from_str("\"\"");
1894      assert!(result.is_err());
1895   }
1896
1897   #[test]
1898   fn test_conventional_commit_roundtrip() {
1899      let commit = ConventionalCommit {
1900         commit_type: CommitType::new("feat").unwrap(),
1901         scope:       Some(Scope::new("api").unwrap()),
1902         summary:     CommitSummary::new_unchecked("added endpoint", 128).unwrap(),
1903         body:        vec!["detail 1.".to_string(), "detail 2.".to_string()],
1904         footers:     vec!["Fixes: #123".to_string()],
1905      };
1906
1907      let json = serde_json::to_string(&commit).unwrap();
1908      let deserialized: ConventionalCommit = serde_json::from_str(&json).unwrap();
1909
1910      assert_eq!(deserialized.commit_type.as_str(), "feat");
1911      assert_eq!(deserialized.scope.unwrap().as_str(), "api");
1912      assert_eq!(deserialized.summary.as_str(), "added endpoint");
1913      assert_eq!(deserialized.body.len(), 2);
1914      assert_eq!(deserialized.footers.len(), 1);
1915   }
1916
1917   #[test]
1918   fn test_scope_null_string_deserializes_to_none() {
1919      // LLMs sometimes return "null" as a string instead of JSON null
1920      let test_cases = [
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         r#"{"type":"feat","scope":" null ","body":[],"issue_refs":[]}"#,
1925      ];
1926
1927      for (idx, json) in test_cases.iter().enumerate() {
1928         let analysis: ConventionalAnalysis = serde_json::from_str(json)
1929            .unwrap_or_else(|e| panic!("Case {idx} failed to deserialize: {e}"));
1930         assert!(
1931            analysis.scope.is_none(),
1932            "Case {idx}: Expected scope to be None, got {:?}",
1933            analysis.scope
1934         );
1935      }
1936   }
1937
1938   #[test]
1939   fn test_scope_invalid_model_output_is_coerced() {
1940      let json = r#"{"type":"chore","scope":".github","details":[],"issue_refs":[]}"#;
1941      let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1942      assert_eq!(analysis.scope.as_ref().map(Scope::as_str), Some("github"));
1943   }
1944
1945   #[test]
1946   fn test_scope_path_like_model_output_is_coerced() {
1947      let json = r#"{"type":"chore","scope":"docs//Release Notes","details":[],"issue_refs":[]}"#;
1948      let analysis: ConventionalAnalysis = serde_json::from_str(json).unwrap();
1949      assert_eq!(analysis.scope.as_ref().map(Scope::as_str), Some("docs/release-notes"));
1950   }
1951
1952   // ========== HunkSelector Tests ==========
1953
1954   #[test]
1955   fn test_body_array_with_newline_in_string() {
1956      // This reproduces the issue where literal newlines in the string prevent JSON
1957      // parsing The input mimics what happens when LLM returns a JSON string
1958      // with unescaped newlines
1959      let raw_str = "[\"Item 1\", \"Item\n2\"]";
1960      let value = serde_json::Value::String(raw_str.to_string());
1961
1962      // desired behavior: should clean the newline and parse as array
1963      let result = value_to_string_vec(value);
1964
1965      // It should be ["Item 1", "Item 2"] (newline replaced by space)
1966      assert_eq!(result.len(), 2);
1967      assert_eq!(result[0], "Item 1");
1968      // Depending on implementation, it might be "Item 2" or "Item  2" etc.
1969      // For now let's assume we replace with space.
1970      assert_eq!(result[1], "Item 2");
1971   }
1972
1973   #[test]
1974   fn test_body_array_malformed_truncated() {
1975      // This reproduces the issue where the array is truncated or has trailing
1976      // punctuation
1977      let raw_str = "[\"Refactored finance...\", \"Added automatic detection...\".";
1978      let value = serde_json::Value::String(raw_str.to_string());
1979
1980      let result = value_to_string_vec(value);
1981
1982      // Should recover 2 items
1983      assert_eq!(result.len(), 2);
1984      assert_eq!(result[0], "Refactored finance...");
1985      assert_eq!(result[1], "Added automatic detection...");
1986   }
1987
1988   #[test]
1989   fn test_hunk_selector_deserialize_all() {
1990      let json = r#""ALL""#;
1991      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1992      assert!(matches!(selector, HunkSelector::All));
1993   }
1994
1995   #[test]
1996   fn test_hunk_selector_deserialize_lines_object() {
1997      let json = r#"{"start": 10, "end": 20}"#;
1998      let selector: HunkSelector = serde_json::from_str(json).unwrap();
1999      match selector {
2000         HunkSelector::Lines { start, end } => {
2001            assert_eq!(start, 10);
2002            assert_eq!(end, 20);
2003         },
2004         _ => panic!("Expected Lines variant"),
2005      }
2006   }
2007
2008   #[test]
2009   fn test_hunk_selector_deserialize_lines_string() {
2010      let json = r#""10-20""#;
2011      let selector: HunkSelector = serde_json::from_str(json).unwrap();
2012      match selector {
2013         HunkSelector::Lines { start, end } => {
2014            assert_eq!(start, 10);
2015            assert_eq!(end, 20);
2016         },
2017         _ => panic!("Expected Lines variant"),
2018      }
2019   }
2020
2021   #[test]
2022   fn test_hunk_selector_deserialize_search_pattern() {
2023      let json = r#"{"pattern": "fn main"}"#;
2024      let selector: HunkSelector = serde_json::from_str(json).unwrap();
2025      match selector {
2026         HunkSelector::Search { pattern } => {
2027            assert_eq!(pattern, "fn main");
2028         },
2029         _ => panic!("Expected Search variant"),
2030      }
2031   }
2032
2033   #[test]
2034   fn test_hunk_selector_deserialize_old_format_hunk_header() {
2035      // Old format: hunk headers like "@@ -10,5 +10,7 @@" should be treated as search
2036      let json = r#""@@ -10,5 +10,7 @@""#;
2037      let selector: HunkSelector = serde_json::from_str(json).unwrap();
2038      match selector {
2039         HunkSelector::Search { pattern } => {
2040            assert_eq!(pattern, "@@ -10,5 +10,7 @@");
2041         },
2042         _ => panic!("Expected Search variant for old hunk header format"),
2043      }
2044   }
2045
2046   #[test]
2047   fn test_hunk_selector_serialize_all() {
2048      let selector = HunkSelector::All;
2049      let json = serde_json::to_string(&selector).unwrap();
2050      assert_eq!(json, r#""ALL""#);
2051   }
2052
2053   #[test]
2054   fn test_hunk_selector_serialize_lines() {
2055      let selector = HunkSelector::Lines { start: 10, end: 20 };
2056      let json = serde_json::to_value(&selector).unwrap();
2057      assert_eq!(json["start"], 10);
2058      assert_eq!(json["end"], 20);
2059   }
2060
2061   #[test]
2062   fn test_file_change_deserialize_with_all() {
2063      let json = r#"{"path": "src/main.rs", "hunks": ["ALL"]}"#;
2064      let change: FileChange = serde_json::from_str(json).unwrap();
2065      assert_eq!(change.path, "src/main.rs");
2066      assert_eq!(change.hunks.len(), 1);
2067      assert!(matches!(change.hunks[0], HunkSelector::All));
2068   }
2069
2070   #[test]
2071   fn test_file_change_deserialize_with_line_ranges() {
2072      let json = r#"{"path": "src/main.rs", "hunks": [{"start": 10, "end": 20}, {"start": 50, "end": 60}]}"#;
2073      let change: FileChange = serde_json::from_str(json).unwrap();
2074      assert_eq!(change.path, "src/main.rs");
2075      assert_eq!(change.hunks.len(), 2);
2076
2077      match &change.hunks[0] {
2078         HunkSelector::Lines { start, end } => {
2079            assert_eq!(*start, 10);
2080            assert_eq!(*end, 20);
2081         },
2082         _ => panic!("Expected Lines variant"),
2083      }
2084
2085      match &change.hunks[1] {
2086         HunkSelector::Lines { start, end } => {
2087            assert_eq!(*start, 50);
2088            assert_eq!(*end, 60);
2089         },
2090         _ => panic!("Expected Lines variant"),
2091      }
2092   }
2093
2094   #[test]
2095   fn test_file_change_deserialize_mixed_formats() {
2096      // Mix of string line ranges and object line ranges
2097      let json = r#"{"path": "src/main.rs", "hunks": ["10-20", {"start": 50, "end": 60}]}"#;
2098      let change: FileChange = serde_json::from_str(json).unwrap();
2099      assert_eq!(change.hunks.len(), 2);
2100
2101      match &change.hunks[0] {
2102         HunkSelector::Lines { start, end } => {
2103            assert_eq!(*start, 10);
2104            assert_eq!(*end, 20);
2105         },
2106         _ => panic!("Expected Lines variant"),
2107      }
2108
2109      match &change.hunks[1] {
2110         HunkSelector::Lines { start, end } => {
2111            assert_eq!(*start, 50);
2112            assert_eq!(*end, 60);
2113         },
2114         _ => panic!("Expected Lines variant"),
2115      }
2116   }
2117}