changeset_operations/providers/
changeset_io.rs1use 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}