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}