Skip to main content

llm_git/
compose_types.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::types::{CommitType, Scope};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ComposeHunk {
9   pub hunk_id:      String,
10   pub file_id:      String,
11   pub path:         String,
12   pub old_start:    usize,
13   pub old_count:    usize,
14   pub new_start:    usize,
15   pub new_count:    usize,
16   pub header:       String,
17   pub raw_patch:    String,
18   pub snippet:      String,
19   pub semantic_key: String,
20   pub synthetic:    bool,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ComposeFile {
25   pub file_id:        String,
26   pub path:           String,
27   pub patch_header:   String,
28   pub full_patch:     String,
29   pub summary:        String,
30   pub hunk_ids:       Vec<String>,
31   pub additions:      usize,
32   pub deletions:      usize,
33   pub is_binary:      bool,
34   pub synthetic_only: bool,
35}
36
37/// A file's worktree state as captured when the compose snapshot was taken.
38///
39/// Staging from a pin reproduces the exact bytes that were on disk at capture
40/// time, so edits made while compose runs never leak into its commits.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub enum WorktreePin {
43   /// Object written to the object database at capture time (file blob,
44   /// symlink target, or submodule commit), with its index mode.
45   Object { mode: String, oid: String },
46   /// The path was absent from the worktree at capture time.
47   Deleted,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ComposeSnapshot {
52   pub diff:  String,
53   pub stat:  String,
54   pub files: Vec<ComposeFile>,
55   pub hunks: Vec<ComposeHunk>,
56   /// Worktree content pinned per path at capture time. Empty for snapshots
57   /// built outside the compose flow (tests, cached artifacts).
58   #[serde(default)]
59   pub pins:  BTreeMap<String, WorktreePin>,
60}
61
62impl ComposeSnapshot {
63   pub fn file_by_id(&self, file_id: &str) -> Option<&ComposeFile> {
64      self.files.iter().find(|file| file.file_id == file_id)
65   }
66
67   pub fn file_by_path(&self, path: &str) -> Option<&ComposeFile> {
68      self.files.iter().find(|file| file.path == path)
69   }
70
71   pub fn hunk_by_id(&self, hunk_id: &str) -> Option<&ComposeHunk> {
72      self.hunks.iter().find(|hunk| hunk.hunk_id == hunk_id)
73   }
74
75   pub fn hunks_for_file(&self, file_id: &str) -> Vec<&ComposeHunk> {
76      self
77         .hunks
78         .iter()
79         .filter(|hunk| hunk.file_id == file_id)
80         .collect()
81   }
82
83   pub fn all_hunk_ids(&self) -> Vec<String> {
84      self.hunks.iter().map(|hunk| hunk.hunk_id.clone()).collect()
85   }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ComposeIntentGroup {
90   pub group_id:     String,
91   #[serde(rename = "type")]
92   pub commit_type:  CommitType,
93   #[serde(default, deserialize_with = "deserialize_optional_scope_lossy")]
94   pub scope:        Option<Scope>,
95   #[serde(default)]
96   pub file_ids:     Vec<String>,
97   pub rationale:    String,
98   #[serde(default)]
99   pub dependencies: Vec<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ComposeIntentPlan {
104   pub groups:           Vec<ComposeIntentGroup>,
105   pub dependency_order: Vec<usize>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ComposeBindingAssignment {
110   pub group_id: String,
111   #[serde(default)]
112   pub hunk_ids: Vec<String>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ComposeExecutableGroup {
117   pub group_id:     String,
118   #[serde(rename = "type")]
119   pub commit_type:  CommitType,
120   #[serde(default, deserialize_with = "deserialize_optional_scope_lossy")]
121   pub scope:        Option<Scope>,
122   #[serde(default)]
123   pub file_ids:     Vec<String>,
124   pub rationale:    String,
125   #[serde(default)]
126   pub dependencies: Vec<String>,
127   #[serde(default)]
128   pub hunk_ids:     Vec<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ComposeExecutablePlan {
133   pub groups:           Vec<ComposeExecutableGroup>,
134   pub dependency_order: Vec<usize>,
135}
136
137fn deserialize_optional_scope_lossy<'de, D>(
138   deserializer: D,
139) -> std::result::Result<Option<Scope>, D::Error>
140where
141   D: serde::Deserializer<'de>,
142{
143   let value = Option::<String>::deserialize(deserializer)?;
144   Ok(value.as_deref().and_then(coerce_scope))
145}
146
147fn coerce_scope(raw: &str) -> Option<Scope> {
148   let normalized = raw.trim().replace('\\', "/").to_lowercase();
149
150   let segments: Vec<String> = normalized
151      .split('/')
152      .filter_map(sanitize_scope_segment)
153      .take(2)
154      .collect();
155
156   if segments.is_empty() {
157      return None;
158   }
159
160   Scope::new(segments.join("/")).ok()
161}
162
163fn sanitize_scope_segment(segment: &str) -> Option<String> {
164   let mut out = String::new();
165   let mut last_was_separator = false;
166
167   for ch in segment.trim().chars() {
168      if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
169         out.push(ch);
170         last_was_separator = false;
171      } else if ch == '-' || ch == '_' {
172         if !out.is_empty() && !last_was_separator {
173            out.push(ch);
174            last_was_separator = true;
175         }
176      } else if (ch.is_ascii_whitespace() || ch == '.') && !out.is_empty() && !last_was_separator {
177         out.push('-');
178         last_was_separator = true;
179      }
180   }
181
182   let trimmed = out.trim_matches(['-', '_']).to_string();
183   (!trimmed.is_empty()).then_some(trimmed)
184}