1use std::path::PathBuf;
7
8use crate::model::patch::FileId;
9use crate::model::types::{EpochId, GitOid, WorkspaceId};
10
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
17pub enum ChangeKind {
18 Added,
20 Modified,
22 Deleted,
24}
25
26impl std::fmt::Display for ChangeKind {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 Self::Added => write!(f, "added"),
30 Self::Modified => write!(f, "modified"),
31 Self::Deleted => write!(f, "deleted"),
32 }
33 }
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct FileChange {
55 pub path: PathBuf,
57 pub kind: ChangeKind,
59 pub content: Option<Vec<u8>>,
61 pub file_id: Option<FileId>,
65 pub blob: Option<GitOid>,
72}
73
74impl FileChange {
75 #[must_use]
80 pub const fn new(path: PathBuf, kind: ChangeKind, content: Option<Vec<u8>>) -> Self {
81 Self {
82 path,
83 kind,
84 content,
85 file_id: None,
86 blob: None,
87 }
88 }
89
90 #[must_use]
96 pub const fn with_identity(
97 path: PathBuf,
98 kind: ChangeKind,
99 content: Option<Vec<u8>>,
100 file_id: Option<FileId>,
101 blob: Option<GitOid>,
102 ) -> Self {
103 Self {
104 path,
105 kind,
106 content,
107 file_id,
108 blob,
109 }
110 }
111
112 #[must_use]
114 pub const fn is_deletion(&self) -> bool {
115 matches!(self.kind, ChangeKind::Deleted)
116 }
117
118 #[must_use]
120 pub const fn has_content(&self) -> bool {
121 self.content.is_some()
122 }
123}
124
125#[derive(Clone, Debug)]
136pub struct PatchSet {
137 pub workspace_id: WorkspaceId,
139 pub epoch: EpochId,
141 pub changes: Vec<FileChange>,
143}
144
145impl PatchSet {
146 #[must_use]
148 pub fn new(workspace_id: WorkspaceId, epoch: EpochId, mut changes: Vec<FileChange>) -> Self {
149 changes.sort_by(|a, b| a.path.cmp(&b.path));
151 Self {
152 workspace_id,
153 epoch,
154 changes,
155 }
156 }
157
158 #[must_use]
160 pub const fn is_empty(&self) -> bool {
161 self.changes.is_empty()
162 }
163
164 #[must_use]
166 pub const fn change_count(&self) -> usize {
167 self.changes.len()
168 }
169
170 #[must_use]
172 pub fn added_count(&self) -> usize {
173 self.changes
174 .iter()
175 .filter(|c| matches!(c.kind, ChangeKind::Added))
176 .count()
177 }
178
179 #[must_use]
181 pub fn modified_count(&self) -> usize {
182 self.changes
183 .iter()
184 .filter(|c| matches!(c.kind, ChangeKind::Modified))
185 .count()
186 }
187
188 #[must_use]
190 pub fn deleted_count(&self) -> usize {
191 self.changes
192 .iter()
193 .filter(|c| matches!(c.kind, ChangeKind::Deleted))
194 .count()
195 }
196
197 #[must_use]
202 pub fn is_deletion_only(&self) -> bool {
203 !self.is_empty()
204 && self
205 .changes
206 .iter()
207 .all(|c| matches!(c.kind, ChangeKind::Deleted))
208 }
209
210 pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
212 self.changes.iter().map(|c| &c.path)
213 }
214}
215
216#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::model::types::{EpochId, WorkspaceId};
224
225 fn make_epoch() -> EpochId {
226 EpochId::new(&"a".repeat(40)).unwrap()
227 }
228
229 fn make_ws() -> WorkspaceId {
230 WorkspaceId::new("test-ws").unwrap()
231 }
232
233 #[test]
234 fn change_kind_display() {
235 assert_eq!(format!("{}", ChangeKind::Added), "added");
236 assert_eq!(format!("{}", ChangeKind::Modified), "modified");
237 assert_eq!(format!("{}", ChangeKind::Deleted), "deleted");
238 }
239
240 #[test]
241 fn file_change_deletion_has_no_content() {
242 let fc = FileChange::new(PathBuf::from("gone.rs"), ChangeKind::Deleted, None);
243 assert!(fc.is_deletion());
244 assert!(!fc.has_content());
245 }
246
247 #[test]
248 fn file_change_add_has_content() {
249 let fc = FileChange::new(
250 PathBuf::from("new.rs"),
251 ChangeKind::Added,
252 Some(b"fn main() {}".to_vec()),
253 );
254 assert!(!fc.is_deletion());
255 assert!(fc.has_content());
256 }
257
258 #[test]
259 fn patch_set_empty() {
260 let ps = PatchSet::new(make_ws(), make_epoch(), vec![]);
261 assert!(ps.is_empty());
262 assert_eq!(ps.change_count(), 0);
263 assert!(!ps.is_deletion_only());
264 }
265
266 #[test]
267 fn patch_set_sorts_by_path() {
268 let changes = vec![
269 FileChange::new(PathBuf::from("z.rs"), ChangeKind::Added, Some(vec![])),
270 FileChange::new(PathBuf::from("a.rs"), ChangeKind::Added, Some(vec![])),
271 FileChange::new(PathBuf::from("m.rs"), ChangeKind::Modified, Some(vec![])),
272 ];
273 let ps = PatchSet::new(make_ws(), make_epoch(), changes);
274 let paths: Vec<_> = ps.paths().collect();
275 assert_eq!(paths[0], &PathBuf::from("a.rs"));
276 assert_eq!(paths[1], &PathBuf::from("m.rs"));
277 assert_eq!(paths[2], &PathBuf::from("z.rs"));
278 }
279
280 #[test]
281 fn patch_set_deletion_only() {
282 let changes = vec![
283 FileChange::new(PathBuf::from("old.rs"), ChangeKind::Deleted, None),
284 FileChange::new(PathBuf::from("other.rs"), ChangeKind::Deleted, None),
285 ];
286 let ps = PatchSet::new(make_ws(), make_epoch(), changes);
287 assert!(ps.is_deletion_only());
288 assert!(!ps.is_empty());
289 assert_eq!(ps.deleted_count(), 2);
290 }
291
292 #[test]
293 fn patch_set_mixed_not_deletion_only() {
294 let changes = vec![
295 FileChange::new(PathBuf::from("old.rs"), ChangeKind::Deleted, None),
296 FileChange::new(PathBuf::from("new.rs"), ChangeKind::Added, Some(vec![])),
297 ];
298 let ps = PatchSet::new(make_ws(), make_epoch(), changes);
299 assert!(!ps.is_deletion_only());
300 }
301
302 #[test]
303 fn patch_set_counts() {
304 let changes = vec![
305 FileChange::new(PathBuf::from("add.rs"), ChangeKind::Added, Some(vec![])),
306 FileChange::new(PathBuf::from("add2.rs"), ChangeKind::Added, Some(vec![])),
307 FileChange::new(PathBuf::from("mod.rs"), ChangeKind::Modified, Some(vec![])),
308 FileChange::new(PathBuf::from("del.rs"), ChangeKind::Deleted, None),
309 ];
310 let ps = PatchSet::new(make_ws(), make_epoch(), changes);
311 assert_eq!(ps.added_count(), 2);
312 assert_eq!(ps.modified_count(), 1);
313 assert_eq!(ps.deleted_count(), 1);
314 assert_eq!(ps.change_count(), 4);
315 }
316}