ephemeris_release/
edits.rs1use std::collections::BTreeMap;
4use std::path::PathBuf;
5
6use ephemeris_files::{FilesError, PathError, RepositoryPath, RepositoryRoot};
7
8#[derive(Debug, thiserror::Error)]
10pub(crate) enum EditError {
11 #[error("multiple release edits target {0}")]
13 Duplicate(RepositoryPath),
14 #[error("release edits plan {0} from different file contents")]
16 SnapshotMismatch(RepositoryPath),
17 #[error(transparent)]
19 Path(#[from] PathError),
20 #[error(transparent)]
22 Files(#[from] FilesError),
23}
24
25#[derive(Clone, Debug, Eq, PartialEq)]
26struct Edit {
27 expected: Option<Vec<u8>>,
28 replacement: Option<Vec<u8>>,
29}
30
31#[derive(Clone, Debug, Eq, PartialEq)]
33pub(crate) struct PlannedEdits {
34 root: RepositoryRoot,
35 files: BTreeMap<RepositoryPath, Edit>,
36}
37
38impl PlannedEdits {
39 pub(crate) fn new(root: &RepositoryRoot) -> Self {
41 Self {
42 root: root.clone(),
43 files: BTreeMap::new(),
44 }
45 }
46
47 pub(crate) fn add(
64 &mut self,
65 path: RepositoryPath,
66 expected: Option<Vec<u8>>,
67 replacement: Option<Vec<u8>>,
68 ) -> Result<(), EditError> {
69 path.resolve(&self.root)?;
70 if let Some(existing) = self.files.get(&path) {
71 return if existing.expected == expected && existing.replacement == replacement {
72 Ok(())
73 } else {
74 Err(EditError::Duplicate(path))
75 };
76 }
77 self.files.insert(
78 path,
79 Edit {
80 expected,
81 replacement,
82 },
83 );
84 Ok(())
85 }
86
87 pub(crate) fn compose<E: From<EditError>>(
97 &mut self,
98 path: RepositoryPath,
99 current: &[u8],
100 render: impl FnOnce(&[u8]) -> Result<Vec<u8>, E>,
101 ) -> Result<(), E> {
102 path.resolve(&self.root).map_err(EditError::from)?;
103 if let Some(existing) = self.files.get_mut(&path) {
104 if existing.expected.as_deref() != Some(current) {
105 return Err(EditError::SnapshotMismatch(path).into());
106 }
107 let base = existing.replacement.as_deref().unwrap_or(current);
108 existing.replacement = Some(render(base)?);
109 } else {
110 let replacement = render(current)?;
111 self.files.insert(
112 path,
113 Edit {
114 expected: Some(current.to_vec()),
115 replacement: Some(replacement),
116 },
117 );
118 }
119 Ok(())
120 }
121
122 pub(crate) fn apply(&self) -> Result<(), EditError> {
129 let entries: Vec<_> = self
130 .files
131 .iter()
132 .map(|(path, edit)| {
133 (
134 path.clone(),
135 edit.expected.clone(),
136 edit.replacement.clone(),
137 )
138 })
139 .collect();
140 if entries.is_empty() {
141 return Ok(());
142 }
143 let plan = ephemeris_files::Plan::for_expected_updates(&self.root, &entries)?;
144 plan.apply(ephemeris_files::DirtyFiles::Refuse)
145 .map_err(EditError::Files)
146 }
147
148 #[must_use]
150 pub(crate) fn changed_paths(&self) -> Vec<PathBuf> {
151 self.files
152 .iter()
153 .filter(|(_, edit)| edit.replacement.is_some())
154 .map(|(path, _)| path.as_path().to_path_buf())
155 .collect()
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use std::fs;
162 #[cfg(unix)]
163 use std::os::unix::fs::symlink;
164 use std::path::Path;
165
166 use ephemeris_files::RepositoryRoot;
167
168 use super::{EditError, PlannedEdits};
169
170 #[cfg(unix)]
171 #[test]
172 fn a_path_through_a_symlink_out_of_the_root_is_rejected_at_planning() {
173 let root = tempfile::tempdir().unwrap();
174 let outside = tempfile::tempdir().unwrap();
175 symlink(outside.path(), root.path().join("link")).unwrap();
176 let mut edits = PlannedEdits::new(&RepositoryRoot::new(root.path()).unwrap());
177 assert!(matches!(
178 edits.add(
179 "link/CHANGELOG.md".parse().unwrap(),
180 None,
181 Some(b"x".to_vec())
182 ),
183 Err(EditError::Path(_))
184 ));
185 }
186
187 #[test]
188 fn identical_repeated_contribution_is_a_no_op() {
189 let root = tempfile::tempdir().unwrap();
190 fs::write(root.path().join("Cargo.toml"), "version = \"1.0.0\"\n").unwrap();
191 let repository = git2::Repository::init(root.path()).unwrap();
192 let mut index = repository.index().unwrap();
193 index.add_path(Path::new("Cargo.toml")).unwrap();
194 index.write().unwrap();
195 let mut edits = PlannedEdits::new(&RepositoryRoot::new(root.path()).unwrap());
196 edits
197 .add(
198 "Cargo.toml".parse().unwrap(),
199 Some(b"version = \"1.0.0\"\n".to_vec()),
200 Some(b"version = \"1.0.1\"\n".to_vec()),
201 )
202 .unwrap();
203 edits
204 .add(
205 "Cargo.toml".parse().unwrap(),
206 Some(b"version = \"1.0.0\"\n".to_vec()),
207 Some(b"version = \"1.0.1\"\n".to_vec()),
208 )
209 .unwrap();
210 edits.apply().unwrap();
211 assert_eq!(
212 fs::read_to_string(root.path().join("Cargo.toml")).unwrap(),
213 "version = \"1.0.1\"\n"
214 );
215 }
216
217 #[test]
218 fn conflicting_and_stale_mixed_target_edits_write_nothing() {
219 let root = tempfile::tempdir().unwrap();
220 fs::write(root.path().join("VERSION"), "1.0.0\n").unwrap();
221 fs::write(root.path().join("Cargo.toml"), "version = \"1.0.0\"\n").unwrap();
222 let mut edits = PlannedEdits::new(&RepositoryRoot::new(root.path()).unwrap());
223 edits
224 .add(
225 "VERSION".parse().unwrap(),
226 Some(b"1.0.0\n".to_vec()),
227 Some(b"1.0.1\n".to_vec()),
228 )
229 .unwrap();
230 edits
231 .add(
232 "Cargo.toml".parse().unwrap(),
233 Some(b"version = \"1.0.0\"\n".to_vec()),
234 Some(b"version = \"1.0.1\"\n".to_vec()),
235 )
236 .unwrap();
237 assert!(matches!(
238 edits.add(
239 "VERSION".parse().unwrap(),
240 Some(b"1.0.0\n".to_vec()),
241 Some(b"1.0.2\n".to_vec()),
242 ),
243 Err(EditError::Duplicate(_))
244 ));
245 fs::write(root.path().join("Cargo.toml"), "external edit\n").unwrap();
246 assert!(matches!(edits.apply(), Err(EditError::Files(_))));
247 assert_eq!(fs::read(root.path().join("VERSION")).unwrap(), b"1.0.0\n");
248 }
249
250 #[test]
251 fn absent_file_is_a_stale_checked_snapshot() {
252 let root = tempfile::tempdir().unwrap();
253 git2::Repository::init(root.path()).unwrap();
254 let mut edits = PlannedEdits::new(&RepositoryRoot::new(root.path()).unwrap());
255 edits
256 .add(
257 "missing/CHANGELOG.md".parse().unwrap(),
258 None,
259 Some(b"# Changelog\n".to_vec()),
260 )
261 .unwrap();
262 edits.apply().unwrap();
263 assert_eq!(
264 fs::read(root.path().join("missing/CHANGELOG.md")).unwrap(),
265 b"# Changelog\n"
266 );
267 }
268}