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