Skip to main content

llm_git/
compose_types.rs

1use serde::{Deserialize, Serialize};
2
3use crate::types::{CommitType, Scope};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ComposeHunk {
7   pub hunk_id:      String,
8   pub file_id:      String,
9   pub path:         String,
10   pub old_start:    usize,
11   pub old_count:    usize,
12   pub new_start:    usize,
13   pub new_count:    usize,
14   pub header:       String,
15   pub raw_patch:    String,
16   pub snippet:      String,
17   pub semantic_key: String,
18   pub synthetic:    bool,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ComposeFile {
23   pub file_id:        String,
24   pub path:           String,
25   pub patch_header:   String,
26   pub full_patch:     String,
27   pub summary:        String,
28   pub hunk_ids:       Vec<String>,
29   pub additions:      usize,
30   pub deletions:      usize,
31   pub is_binary:      bool,
32   pub synthetic_only: bool,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ComposeSnapshot {
37   pub diff:  String,
38   pub stat:  String,
39   pub files: Vec<ComposeFile>,
40   pub hunks: Vec<ComposeHunk>,
41}
42
43impl ComposeSnapshot {
44   pub fn file_by_id(&self, file_id: &str) -> Option<&ComposeFile> {
45      self.files.iter().find(|file| file.file_id == file_id)
46   }
47
48   pub fn file_by_path(&self, path: &str) -> Option<&ComposeFile> {
49      self.files.iter().find(|file| file.path == path)
50   }
51
52   pub fn hunk_by_id(&self, hunk_id: &str) -> Option<&ComposeHunk> {
53      self.hunks.iter().find(|hunk| hunk.hunk_id == hunk_id)
54   }
55
56   pub fn hunks_for_file(&self, file_id: &str) -> Vec<&ComposeHunk> {
57      self
58         .hunks
59         .iter()
60         .filter(|hunk| hunk.file_id == file_id)
61         .collect()
62   }
63
64   pub fn all_hunk_ids(&self) -> Vec<String> {
65      self.hunks.iter().map(|hunk| hunk.hunk_id.clone()).collect()
66   }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ComposeIntentGroup {
71   pub group_id:     String,
72   #[serde(rename = "type")]
73   pub commit_type:  CommitType,
74   #[serde(default, deserialize_with = "deserialize_optional_scope_lossy")]
75   pub scope:        Option<Scope>,
76   #[serde(default)]
77   pub file_ids:     Vec<String>,
78   pub rationale:    String,
79   #[serde(default)]
80   pub dependencies: Vec<String>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ComposeIntentPlan {
85   pub groups:           Vec<ComposeIntentGroup>,
86   pub dependency_order: Vec<usize>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ComposeBindingAssignment {
91   pub group_id: String,
92   #[serde(default)]
93   pub hunk_ids: Vec<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ComposeExecutableGroup {
98   pub group_id:     String,
99   #[serde(rename = "type")]
100   pub commit_type:  CommitType,
101   #[serde(default, deserialize_with = "deserialize_optional_scope_lossy")]
102   pub scope:        Option<Scope>,
103   #[serde(default)]
104   pub file_ids:     Vec<String>,
105   pub rationale:    String,
106   #[serde(default)]
107   pub dependencies: Vec<String>,
108   #[serde(default)]
109   pub hunk_ids:     Vec<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ComposeExecutablePlan {
114   pub groups:           Vec<ComposeExecutableGroup>,
115   pub dependency_order: Vec<usize>,
116}
117
118fn deserialize_optional_scope_lossy<'de, D>(
119   deserializer: D,
120) -> std::result::Result<Option<Scope>, D::Error>
121where
122   D: serde::Deserializer<'de>,
123{
124   let value = Option::<String>::deserialize(deserializer)?;
125   Ok(value.as_deref().and_then(coerce_scope))
126}
127
128fn coerce_scope(raw: &str) -> Option<Scope> {
129   let normalized = raw.trim().replace('\\', "/").to_lowercase();
130
131   let segments: Vec<String> = normalized
132      .split('/')
133      .filter_map(sanitize_scope_segment)
134      .take(2)
135      .collect();
136
137   if segments.is_empty() {
138      return None;
139   }
140
141   Scope::new(segments.join("/")).ok()
142}
143
144fn sanitize_scope_segment(segment: &str) -> Option<String> {
145   let mut out = String::new();
146   let mut last_was_separator = false;
147
148   for ch in segment.trim().chars() {
149      if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
150         out.push(ch);
151         last_was_separator = false;
152      } else if ch == '-' || ch == '_' {
153         if !out.is_empty() && !last_was_separator {
154            out.push(ch);
155            last_was_separator = true;
156         }
157      } else if (ch.is_ascii_whitespace() || ch == '.') && !out.is_empty() && !last_was_separator {
158         out.push('-');
159         last_was_separator = true;
160      }
161   }
162
163   let trimmed = out.trim_matches(['-', '_']).to_string();
164   (!trimmed.is_empty()).then_some(trimmed)
165}