Skip to main content

ephemeris_release/
edits.rs

1//! Shared stale-checked transaction for edits proposed by release targets.
2
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5
6use ephemeris_files::{FilesError, PathError, RepositoryPath, RepositoryRoot};
7
8/// Two planned edits conflict or the file transaction failed.
9#[derive(Debug, thiserror::Error)]
10pub(crate) enum EditError {
11    /// Two contributions disagree about one path.
12    #[error("multiple release edits target {0}")]
13    Duplicate(RepositoryPath),
14    /// A path was planned from other bytes than another contribution saw.
15    #[error("release edits plan {0} from different file contents")]
16    SnapshotMismatch(RepositoryPath),
17    /// A planned path is a symlink or lies outside the repository.
18    #[error(transparent)]
19    Path(#[from] PathError),
20    /// The file transaction refused or failed.
21    #[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/// A set of file snapshots and optional replacements from multiple targets.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub(crate) struct PlannedEdits {
34    root: RepositoryRoot,
35    files: BTreeMap<RepositoryPath, Edit>,
36}
37
38impl PlannedEdits {
39    /// Start a transaction rooted at a repository.
40    pub(crate) fn new(root: &RepositoryRoot) -> Self {
41        Self {
42            root: root.clone(),
43            files: BTreeMap::new(),
44        }
45    }
46
47    /// Add one repository-relative file snapshot and an optional replacement.
48    ///
49    /// `expected = None` means the file must not exist yet. `replacement =
50    /// None` records a read-only snapshot so other target edits cannot
51    /// invalidate it.
52    ///
53    /// Adding the exact same snapshot and replacement for a path already
54    /// present is a no-op, so an ecosystem adapter that recomputes the same
55    /// shared edit for multiple units (for example, a Cargo workspace-wide
56    /// version write shared by several packages) can contribute it once per
57    /// unit without tracking which unit contributed it first.
58    ///
59    /// # Errors
60    /// Returns an error for a path that is a symlink or escapes the root,
61    /// or for a path already contributed with different expected or
62    /// replacement content.
63    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    /// Rewrite the bytes planned for `path`, or plan an edit of `current`
88    /// when the path has none yet.
89    ///
90    /// `render` receives the bytes planned so far, so several contributors
91    /// can change one file. The stale check stays on `current`.
92    ///
93    /// # Errors
94    /// Returns the error of `render`, or an edit error for a path that is a
95    /// symlink or escapes the root, or that was planned from other bytes.
96    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    /// Check every source snapshot, then apply all replacements in one file
123    /// transaction.
124    ///
125    /// # Errors
126    /// Returns an error if any path changed since planning or safe application
127    /// fails.
128    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    /// Return repository-relative paths that this transaction will write.
149    #[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}