Skip to main content

changeset_operations/providers/
changeset_io.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use changeset_core::Changeset;
5use changeset_parse::{parse_changeset, serialize_changeset};
6use changeset_project::CHANGESETS_SUBDIR;
7use semver::Version;
8
9use crate::Result;
10use crate::error::OperationError;
11use crate::traits::{ChangesetReader, ChangesetWriter};
12
13const MAX_FILENAME_ATTEMPTS: usize = 100;
14
15pub struct FileSystemChangesetIO {
16    project_root: PathBuf,
17}
18
19impl FileSystemChangesetIO {
20    #[must_use]
21    pub fn new(project_root: &Path) -> Self {
22        Self {
23            project_root: project_root.to_path_buf(),
24        }
25    }
26
27    fn resolve_base_path(&self, changeset_dir: &Path) -> PathBuf {
28        if changeset_dir.is_absolute() {
29            changeset_dir.to_path_buf()
30        } else {
31            self.project_root.join(changeset_dir)
32        }
33    }
34
35    fn list_changesets_filtered(
36        &self,
37        changeset_dir: &Path,
38        consumed_only: bool,
39    ) -> Result<Vec<PathBuf>> {
40        let base_path = self.resolve_base_path(changeset_dir);
41        let full_path = base_path.join(CHANGESETS_SUBDIR);
42
43        let entries = match fs::read_dir(&full_path) {
44            Ok(entries) => entries,
45            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
46            Err(source) => {
47                return Err(OperationError::ChangesetList {
48                    path: full_path,
49                    source,
50                });
51            }
52        };
53
54        let mut changesets = Vec::new();
55
56        for entry in entries {
57            let entry = entry.map_err(|source| OperationError::ChangesetList {
58                path: full_path.clone(),
59                source,
60            })?;
61            let path = entry.path();
62
63            if path.extension().is_none_or(|ext| ext != "md") {
64                continue;
65            }
66
67            let relative = path
68                .strip_prefix(&self.project_root)
69                .map_or_else(|_| path.clone(), Path::to_path_buf);
70
71            let content =
72                fs::read_to_string(&path).map_err(|source| OperationError::ChangesetFileRead {
73                    path: path.clone(),
74                    source,
75                })?;
76
77            let changeset =
78                parse_changeset(&content).map_err(|source| OperationError::ChangesetParse {
79                    path: path.clone(),
80                    source,
81                })?;
82
83            let is_consumed = changeset.consumed_for_prerelease().is_some();
84
85            if consumed_only == is_consumed {
86                changesets.push(relative);
87            }
88        }
89
90        Ok(changesets)
91    }
92
93    fn resolve_changeset_path(&self, changeset_dir: &Path, path: &Path) -> Result<PathBuf> {
94        if path.is_absolute() {
95            Ok(path.to_path_buf())
96        } else if path.starts_with(changeset_dir) {
97            Ok(self.project_root.join(path))
98        } else {
99            let full_changeset_dir = self.resolve_base_path(changeset_dir);
100            let filename =
101                path.file_name()
102                    .ok_or_else(|| OperationError::InvalidChangesetPath {
103                        path: path.to_path_buf(),
104                        reason: "path has no filename component",
105                    })?;
106            Ok(full_changeset_dir.join(CHANGESETS_SUBDIR).join(filename))
107        }
108    }
109}
110
111impl ChangesetReader for FileSystemChangesetIO {
112    fn read_changeset(&self, relative_path: &Path) -> Result<Changeset> {
113        let full_path = self.project_root.join(relative_path);
114        let content =
115            fs::read_to_string(&full_path).map_err(|source| OperationError::ChangesetFileRead {
116                path: full_path.clone(),
117                source,
118            })?;
119        parse_changeset(&content).map_err(|source| OperationError::ChangesetParse {
120            path: full_path,
121            source,
122        })
123    }
124
125    fn list_changesets(&self, changeset_dir: &Path) -> Result<Vec<PathBuf>> {
126        self.list_changesets_filtered(changeset_dir, false)
127    }
128
129    fn list_consumed_changesets(&self, changeset_dir: &Path) -> Result<Vec<PathBuf>> {
130        self.list_changesets_filtered(changeset_dir, true)
131    }
132}
133
134impl ChangesetWriter for FileSystemChangesetIO {
135    fn write_changeset(&self, changeset_dir: &Path, changeset: &Changeset) -> Result<String> {
136        let changesets_subdir = changeset_dir.join(CHANGESETS_SUBDIR);
137        let filename = generate_unique_filename(&changesets_subdir);
138        let file_path = changesets_subdir.join(&filename);
139
140        let content = serialize_changeset(changeset)?;
141        fs::write(&file_path, content).map_err(OperationError::ChangesetFileWrite)?;
142
143        Ok(filename)
144    }
145
146    fn restore_changeset(&self, path: &Path, changeset: &Changeset) -> Result<()> {
147        let full_path = if path.is_absolute() {
148            path.to_path_buf()
149        } else {
150            self.project_root.join(path)
151        };
152
153        let content = serialize_changeset(changeset)?;
154        fs::write(&full_path, content).map_err(OperationError::ChangesetFileWrite)?;
155
156        Ok(())
157    }
158
159    fn filename_exists(&self, changeset_dir: &Path, filename: &str) -> bool {
160        changeset_dir
161            .join(CHANGESETS_SUBDIR)
162            .join(filename)
163            .exists()
164    }
165
166    fn mark_consumed_for_prerelease(
167        &self,
168        changeset_dir: &Path,
169        paths: &[&Path],
170        version: &Version,
171    ) -> Result<()> {
172        let version_string = version.to_string();
173        for path in paths {
174            let full_path = self.resolve_changeset_path(changeset_dir, path)?;
175            update_changeset_file(&full_path, |changeset| {
176                changeset.set_consumed_for_prerelease(Some(version_string.clone()));
177            })?;
178        }
179        Ok(())
180    }
181
182    fn clear_consumed_for_prerelease(&self, changeset_dir: &Path, paths: &[&Path]) -> Result<()> {
183        for path in paths {
184            let full_path = self.resolve_changeset_path(changeset_dir, path)?;
185            update_changeset_file(&full_path, |changeset| {
186                changeset.set_consumed_for_prerelease(None);
187            })?;
188        }
189        Ok(())
190    }
191}
192
193fn update_changeset_file<F>(full_path: &Path, updater: F) -> Result<()>
194where
195    F: FnOnce(&mut Changeset),
196{
197    let content =
198        fs::read_to_string(full_path).map_err(|source| OperationError::ChangesetFileRead {
199            path: full_path.to_path_buf(),
200            source,
201        })?;
202
203    let mut changeset =
204        parse_changeset(&content).map_err(|source| OperationError::ChangesetParse {
205            path: full_path.to_path_buf(),
206            source,
207        })?;
208
209    updater(&mut changeset);
210
211    let serialized = serialize_changeset(&changeset)?;
212    fs::write(full_path, serialized).map_err(OperationError::ChangesetFileWrite)?;
213
214    Ok(())
215}
216
217fn generate_unique_filename(changeset_dir: &Path) -> String {
218    for _ in 0..MAX_FILENAME_ATTEMPTS {
219        if let Some(name) = petname::petname(3, "-") {
220            let filename = format!("{name}.md");
221
222            if !changeset_dir.join(&filename).exists() {
223                return filename;
224            }
225        }
226    }
227
228    let timestamp = std::time::SystemTime::now()
229        .duration_since(std::time::UNIX_EPOCH)
230        .map(|d| d.as_millis())
231        .unwrap_or(0);
232    format!("changeset-{timestamp}.md")
233}