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