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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub enum WorktreePin {
43 Object { mode: String, oid: String },
46 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 #[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}