Skip to main content

changeset_operations/providers/
release_state_io.rs

1use 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}