changeset_operations/providers/
release_state_io.rs1use std::fs;
2use std::path::Path;
3
4use changeset_project::{GraduationState, PrereleaseState};
5
6use crate::Result;
7use crate::error::OperationError;
8use crate::traits::ReleaseStateIO;
9
10const PRERELEASE_FILENAME: &str = "pre-release.toml";
11const GRADUATION_FILENAME: &str = "graduation.toml";
12
13pub struct FileSystemReleaseStateIO;
14
15impl FileSystemReleaseStateIO {
16 #[must_use]
17 pub fn new() -> Self {
18 Self
19 }
20}
21
22impl Default for FileSystemReleaseStateIO {
23 fn default() -> Self {
24 Self::new()
25 }
26}
27
28impl ReleaseStateIO for FileSystemReleaseStateIO {
29 fn load_prerelease_state(&self, changeset_dir: &Path) -> Result<Option<PrereleaseState>> {
30 let path = changeset_dir.join(PRERELEASE_FILENAME);
31 load_toml_file(&path)
32 }
33
34 fn save_prerelease_state(&self, changeset_dir: &Path, state: &PrereleaseState) -> Result<()> {
35 let path = changeset_dir.join(PRERELEASE_FILENAME);
36 save_toml_file(&path, state, state.is_empty())
37 }
38
39 fn load_graduation_state(&self, changeset_dir: &Path) -> Result<Option<GraduationState>> {
40 let path = changeset_dir.join(GRADUATION_FILENAME);
41 load_toml_file(&path)
42 }
43
44 fn save_graduation_state(&self, changeset_dir: &Path, state: &GraduationState) -> Result<()> {
45 let path = changeset_dir.join(GRADUATION_FILENAME);
46 save_toml_file(&path, state, state.is_empty())
47 }
48}
49
50fn load_toml_file<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
51 if !path.exists() {
52 return Ok(None);
53 }
54
55 let content = fs::read_to_string(path).map_err(|source| OperationError::ReleaseStateRead {
56 path: path.to_path_buf(),
57 source,
58 })?;
59
60 let state = toml::from_str(&content).map_err(|source| OperationError::ReleaseStateParse {
61 path: path.to_path_buf(),
62 source,
63 })?;
64
65 Ok(Some(state))
66}
67
68fn save_toml_file<T: serde::Serialize>(
69 path: &Path,
70 state: &T,
71 delete_if_empty: bool,
72) -> Result<()> {
73 if delete_if_empty {
74 match fs::remove_file(path) {
75 Ok(()) => return Ok(()),
76 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
77 Err(source) => {
78 return Err(OperationError::ReleaseStateWrite {
79 path: path.to_path_buf(),
80 source,
81 });
82 }
83 }
84 }
85
86 let content =
87 toml::to_string_pretty(state).map_err(|source| OperationError::ReleaseStateSerialize {
88 path: path.to_path_buf(),
89 source,
90 })?;
91 fs::write(path, content).map_err(|source| OperationError::ReleaseStateWrite {
92 path: path.to_path_buf(),
93 source,
94 })?;
95
96 Ok(())
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use tempfile::TempDir;
103
104 fn setup_test_dir() -> TempDir {
105 tempfile::tempdir().expect("failed to create temp dir")
106 }
107
108 mod prerelease_state_io {
109 use super::*;
110
111 #[test]
112 fn load_nonexistent_returns_none() {
113 let dir = setup_test_dir();
114 let io = FileSystemReleaseStateIO::new();
115
116 let result = io.load_prerelease_state(dir.path());
117
118 assert!(result.is_ok());
119 assert!(result.expect("should succeed").is_none());
120 }
121
122 #[test]
123 fn save_and_load_roundtrip() {
124 let dir = setup_test_dir();
125 let io = FileSystemReleaseStateIO::new();
126 let mut state = PrereleaseState::new();
127 state.insert("crate-a".to_string(), "alpha".to_string());
128 state.insert("crate-b".to_string(), "beta".to_string());
129
130 io.save_prerelease_state(dir.path(), &state)
131 .expect("save should succeed");
132 let loaded = io
133 .load_prerelease_state(dir.path())
134 .expect("load should succeed");
135
136 assert!(loaded.is_some());
137 let loaded = loaded.expect("should have state");
138 assert_eq!(loaded.get("crate-a"), Some("alpha"));
139 assert_eq!(loaded.get("crate-b"), Some("beta"));
140 }
141
142 #[test]
143 fn save_empty_state_deletes_file() {
144 let dir = setup_test_dir();
145 let io = FileSystemReleaseStateIO::new();
146 let path = dir.path().join(PRERELEASE_FILENAME);
147
148 let mut state = PrereleaseState::new();
149 state.insert("crate-a".to_string(), "alpha".to_string());
150 io.save_prerelease_state(dir.path(), &state)
151 .expect("save should succeed");
152 assert!(path.exists());
153
154 let empty_state = PrereleaseState::new();
155 io.save_prerelease_state(dir.path(), &empty_state)
156 .expect("save should succeed");
157 assert!(!path.exists());
158 }
159
160 #[test]
161 fn save_empty_state_when_file_doesnt_exist_is_noop() {
162 let dir = setup_test_dir();
163 let io = FileSystemReleaseStateIO::new();
164 let path = dir.path().join(PRERELEASE_FILENAME);
165 let empty_state = PrereleaseState::new();
166
167 let result = io.save_prerelease_state(dir.path(), &empty_state);
168
169 assert!(result.is_ok());
170 assert!(!path.exists());
171 }
172
173 #[test]
174 fn load_invalid_toml_returns_parse_error() {
175 let dir = setup_test_dir();
176 let io = FileSystemReleaseStateIO::new();
177 let path = dir.path().join(PRERELEASE_FILENAME);
178 fs::write(&path, "not valid { toml content").expect("write should succeed");
179
180 let result = io.load_prerelease_state(dir.path());
181
182 let err = result.expect_err("should fail to parse invalid TOML");
183 assert!(
184 matches!(err, OperationError::ReleaseStateParse { .. }),
185 "expected ReleaseStateParse error, got: {err:?}"
186 );
187 }
188 }
189
190 mod graduation_state_io {
191 use super::*;
192
193 #[test]
194 fn load_nonexistent_returns_none() {
195 let dir = setup_test_dir();
196 let io = FileSystemReleaseStateIO::new();
197
198 let result = io.load_graduation_state(dir.path());
199
200 assert!(result.is_ok());
201 assert!(result.expect("should succeed").is_none());
202 }
203
204 #[test]
205 fn save_and_load_roundtrip() {
206 let dir = setup_test_dir();
207 let io = FileSystemReleaseStateIO::new();
208 let mut state = GraduationState::new();
209 state.add("crate-a".to_string());
210 state.add("crate-b".to_string());
211
212 io.save_graduation_state(dir.path(), &state)
213 .expect("save should succeed");
214 let loaded = io
215 .load_graduation_state(dir.path())
216 .expect("load should succeed");
217
218 assert!(loaded.is_some());
219 let loaded = loaded.expect("should have state");
220 assert!(loaded.contains("crate-a"));
221 assert!(loaded.contains("crate-b"));
222 }
223
224 #[test]
225 fn save_empty_state_deletes_file() {
226 let dir = setup_test_dir();
227 let io = FileSystemReleaseStateIO::new();
228 let path = dir.path().join(GRADUATION_FILENAME);
229
230 let mut state = GraduationState::new();
231 state.add("crate-a".to_string());
232 io.save_graduation_state(dir.path(), &state)
233 .expect("save should succeed");
234 assert!(path.exists());
235
236 let empty_state = GraduationState::new();
237 io.save_graduation_state(dir.path(), &empty_state)
238 .expect("save should succeed");
239 assert!(!path.exists());
240 }
241
242 #[test]
243 fn save_empty_state_when_file_doesnt_exist_is_noop() {
244 let dir = setup_test_dir();
245 let io = FileSystemReleaseStateIO::new();
246 let path = dir.path().join(GRADUATION_FILENAME);
247 let empty_state = GraduationState::new();
248
249 let result = io.save_graduation_state(dir.path(), &empty_state);
250
251 assert!(result.is_ok());
252 assert!(!path.exists());
253 }
254
255 #[test]
256 fn load_invalid_toml_returns_parse_error() {
257 let dir = setup_test_dir();
258 let io = FileSystemReleaseStateIO::new();
259 let path = dir.path().join(GRADUATION_FILENAME);
260 fs::write(&path, "graduation = not an array").expect("write should succeed");
261
262 let result = io.load_graduation_state(dir.path());
263
264 let err = result.expect_err("should fail to parse invalid TOML");
265 assert!(
266 matches!(err, OperationError::ReleaseStateParse { .. }),
267 "expected ReleaseStateParse error, got: {err:?}"
268 );
269 }
270 }
271
272 mod toml_format_validation {
273 use super::*;
274
275 #[test]
276 fn prerelease_toml_contains_expected_keys() {
277 let dir = setup_test_dir();
278 let io = FileSystemReleaseStateIO::new();
279 let mut state = PrereleaseState::new();
280 state.insert("crate-a".to_string(), "alpha".to_string());
281 state.insert("crate-b".to_string(), "beta".to_string());
282
283 io.save_prerelease_state(dir.path(), &state)
284 .expect("save should succeed");
285
286 let path = dir.path().join(PRERELEASE_FILENAME);
287 let content = fs::read_to_string(&path).expect("read file");
288
289 assert!(
290 content.contains("crate-a"),
291 "TOML should contain crate-a key"
292 );
293 assert!(content.contains("alpha"), "TOML should contain alpha value");
294 assert!(
295 content.contains("crate-b"),
296 "TOML should contain crate-b key"
297 );
298 assert!(content.contains("beta"), "TOML should contain beta value");
299 }
300
301 #[test]
302 fn graduation_toml_contains_graduation_array() {
303 let dir = setup_test_dir();
304 let io = FileSystemReleaseStateIO::new();
305 let mut state = GraduationState::new();
306 state.add("crate-a".to_string());
307 state.add("crate-b".to_string());
308
309 io.save_graduation_state(dir.path(), &state)
310 .expect("save should succeed");
311
312 let path = dir.path().join(GRADUATION_FILENAME);
313 let content = fs::read_to_string(&path).expect("read file");
314
315 assert!(
316 content.contains("graduation"),
317 "TOML should contain graduation key"
318 );
319 assert!(
320 content.contains("crate-a"),
321 "TOML should contain crate-a in graduation array"
322 );
323 assert!(
324 content.contains("crate-b"),
325 "TOML should contain crate-b in graduation array"
326 );
327 }
328
329 #[test]
330 fn prerelease_toml_is_valid_toml_syntax() {
331 let dir = setup_test_dir();
332 let io = FileSystemReleaseStateIO::new();
333 let mut state = PrereleaseState::new();
334 state.insert("my-special-crate".to_string(), "rc".to_string());
335
336 io.save_prerelease_state(dir.path(), &state)
337 .expect("save should succeed");
338
339 let path = dir.path().join(PRERELEASE_FILENAME);
340 let content = fs::read_to_string(&path).expect("read file");
341
342 let parsed: std::result::Result<toml::Value, _> = toml::from_str(&content);
343 assert!(parsed.is_ok(), "output should be valid TOML: {content}");
344 }
345
346 #[test]
347 fn graduation_toml_is_valid_toml_syntax() {
348 let dir = setup_test_dir();
349 let io = FileSystemReleaseStateIO::new();
350 let mut state = GraduationState::new();
351 state.add("my-special-crate".to_string());
352
353 io.save_graduation_state(dir.path(), &state)
354 .expect("save should succeed");
355
356 let path = dir.path().join(GRADUATION_FILENAME);
357 let content = fs::read_to_string(&path).expect("read file");
358
359 let parsed: std::result::Result<toml::Value, _> = toml::from_str(&content);
360 assert!(parsed.is_ok(), "output should be valid TOML: {content}");
361 }
362
363 #[test]
364 fn prerelease_state_preserves_crate_names_with_hyphens() {
365 let dir = setup_test_dir();
366 let io = FileSystemReleaseStateIO::new();
367 let mut state = PrereleaseState::new();
368 state.insert("my-hyphenated-crate-name".to_string(), "alpha".to_string());
369
370 io.save_prerelease_state(dir.path(), &state)
371 .expect("save should succeed");
372 let loaded = io
373 .load_prerelease_state(dir.path())
374 .expect("load should succeed")
375 .expect("should have state");
376
377 assert_eq!(
378 loaded.get("my-hyphenated-crate-name"),
379 Some("alpha"),
380 "hyphenated crate name should be preserved"
381 );
382 }
383
384 #[test]
385 fn graduation_state_preserves_crate_names_with_hyphens() {
386 let dir = setup_test_dir();
387 let io = FileSystemReleaseStateIO::new();
388 let mut state = GraduationState::new();
389 state.add("my-hyphenated-crate-name".to_string());
390
391 io.save_graduation_state(dir.path(), &state)
392 .expect("save should succeed");
393 let loaded = io
394 .load_graduation_state(dir.path())
395 .expect("load should succeed")
396 .expect("should have state");
397
398 assert!(
399 loaded.contains("my-hyphenated-crate-name"),
400 "hyphenated crate name should be preserved"
401 );
402 }
403
404 #[test]
405 fn prerelease_state_preserves_custom_tags() {
406 let dir = setup_test_dir();
407 let io = FileSystemReleaseStateIO::new();
408 let mut state = PrereleaseState::new();
409 state.insert("crate-a".to_string(), "nightly".to_string());
410 state.insert("crate-b".to_string(), "canary".to_string());
411 state.insert("crate-c".to_string(), "dev123".to_string());
412
413 io.save_prerelease_state(dir.path(), &state)
414 .expect("save should succeed");
415 let loaded = io
416 .load_prerelease_state(dir.path())
417 .expect("load should succeed")
418 .expect("should have state");
419
420 assert_eq!(loaded.get("crate-a"), Some("nightly"));
421 assert_eq!(loaded.get("crate-b"), Some("canary"));
422 assert_eq!(loaded.get("crate-c"), Some("dev123"));
423 }
424 }
425
426 mod error_handling {
427 use super::*;
428
429 #[test]
430 fn save_to_nonexistent_parent_fails() {
431 let dir = setup_test_dir();
432 let io = FileSystemReleaseStateIO::new();
433 let nonexistent_path = dir.path().join("nonexistent").join("subdir");
434
435 let mut state = PrereleaseState::new();
436 state.insert("crate-a".to_string(), "alpha".to_string());
437
438 let result = io.save_prerelease_state(&nonexistent_path, &state);
439
440 assert!(result.is_err());
441 let err = result.expect_err("save should fail for nonexistent directory");
442 assert!(
443 matches!(err, OperationError::ReleaseStateWrite { .. }),
444 "expected ReleaseStateWrite error, got: {err:?}"
445 );
446 }
447
448 #[test]
449 fn load_from_nonexistent_directory_returns_none() {
450 let io = FileSystemReleaseStateIO::new();
451 let nonexistent_path = std::path::Path::new("/this/path/does/not/exist");
452
453 let result = io.load_prerelease_state(nonexistent_path);
454
455 assert!(result.is_ok());
456 assert!(result.expect("should succeed").is_none());
457 }
458
459 #[test]
460 fn load_graduation_from_nonexistent_directory_returns_none() {
461 let io = FileSystemReleaseStateIO::new();
462 let nonexistent_path = std::path::Path::new("/this/path/does/not/exist");
463
464 let result = io.load_graduation_state(nonexistent_path);
465
466 assert!(result.is_ok());
467 assert!(result.expect("should succeed").is_none());
468 }
469
470 #[test]
471 fn load_truncated_toml_returns_parse_error() {
472 let dir = setup_test_dir();
473 let io = FileSystemReleaseStateIO::new();
474 let path = dir.path().join(PRERELEASE_FILENAME);
475 fs::write(&path, "crate-a = \"alpha").expect("write should succeed");
476
477 let result = io.load_prerelease_state(dir.path());
478
479 let err = result.expect_err("should fail to parse truncated TOML");
480 assert!(matches!(err, OperationError::ReleaseStateParse { .. }));
481 }
482
483 #[test]
484 fn save_to_nonexistent_parent_returns_write_error() {
485 let dir = setup_test_dir();
486 let io = FileSystemReleaseStateIO::new();
487 let nonexistent_path = dir.path().join("nonexistent").join("subdir");
488
489 let mut state = PrereleaseState::new();
490 state.insert("crate-a".to_string(), "alpha".to_string());
491
492 let result = io.save_prerelease_state(&nonexistent_path, &state);
493
494 let err = result.expect_err("should fail to write to nonexistent path");
495 assert!(
496 matches!(err, OperationError::ReleaseStateWrite { .. }),
497 "expected ReleaseStateWrite error, got: {err:?}"
498 );
499 }
500 }
501
502 mod default_implementation {
503 use super::*;
504
505 #[test]
506 fn default_creates_new_instance() {
507 let io1 = FileSystemReleaseStateIO::new();
508 let io2 = FileSystemReleaseStateIO;
509
510 let dir = setup_test_dir();
511 let result1 = io1.load_prerelease_state(dir.path());
512 let result2 = io2.load_prerelease_state(dir.path());
513
514 assert!(result1.is_ok());
515 assert!(result2.is_ok());
516 }
517 }
518}