Skip to main content

ephemeris_release/adapters/
document.rs

1//! Versions at a key path inside a TOML, YAML, or JSON document.
2
3use std::path::{Path, PathBuf};
4
5use ephemeris_documents::{
6    DocumentError, DocumentSpec, Format, InlineWidth, ManagedPath, ValueError,
7};
8
9const TOML_INLINE_WIDTH: usize = 100;
10
11/// A document cannot hold or take the version.
12#[derive(Debug, thiserror::Error)]
13pub(crate) enum DocumentTargetError {
14    /// The file extension names no supported format.
15    #[error("{} is not a .toml, .yaml, .yml, .json, or .json5 file", path.display())]
16    UnknownFormat {
17        /// The document.
18        path: PathBuf,
19    },
20    /// The key path is absent from the document.
21    #[error("{} has no value at {key}", path.display())]
22    MissingKey {
23        /// The document.
24        path: PathBuf,
25        /// The key path of the version.
26        key: ManagedPath,
27    },
28    /// The value at the key path is not a string.
29    #[error("the value at {key} in {} is not a string", path.display())]
30    NotString {
31        /// The document.
32        path: PathBuf,
33        /// The key path of the version.
34        key: ManagedPath,
35    },
36    /// The document cannot be read or rewritten.
37    #[error("cannot process {}", path.display())]
38    Document {
39        /// The document.
40        path: PathBuf,
41        /// The failure.
42        #[source]
43        source: DocumentError,
44    },
45}
46
47fn format(path: &Path) -> Result<Format, DocumentTargetError> {
48    let inline_width = InlineWidth::try_from(TOML_INLINE_WIDTH).map_err(|error| {
49        DocumentTargetError::Document {
50            path: path.to_path_buf(),
51            source: DocumentError::from(error),
52        }
53    })?;
54    path.extension()
55        .and_then(|extension| extension.to_str())
56        .and_then(|extension| Format::from_extension(extension, inline_width))
57        .ok_or_else(|| DocumentTargetError::UnknownFormat {
58            path: path.to_path_buf(),
59        })
60}
61
62/// The string at `key` in `text`.
63pub(super) fn read(
64    path: &Path,
65    key: &ManagedPath,
66    text: &str,
67) -> Result<String, DocumentTargetError> {
68    match format(path)?.read_string(text, key) {
69        Ok(Some(version)) => Ok(version),
70        Ok(None) => Err(DocumentTargetError::MissingKey {
71            path: path.to_path_buf(),
72            key: key.clone(),
73        }),
74        Err(DocumentError::Value {
75            source: ValueError::NotString,
76            path: _,
77        }) => Err(DocumentTargetError::NotString {
78            path: path.to_path_buf(),
79            key: key.clone(),
80        }),
81        Err(source) => Err(DocumentTargetError::Document {
82            path: path.to_path_buf(),
83            source,
84        }),
85    }
86}
87
88/// `before` with only the string at `key` set to `version`.
89pub(super) fn render(
90    path: &Path,
91    key: &ManagedPath,
92    before: &[u8],
93    version: &str,
94) -> Result<Vec<u8>, DocumentTargetError> {
95    DocumentSpec::set_string(format(path)?, key, version)
96        .reconcile(Some(before))
97        .map_err(|source| DocumentTargetError::Document {
98            path: path.to_path_buf(),
99            source,
100        })
101}
102
103#[cfg(test)]
104mod tests {
105    use std::fs;
106    use std::path::Path;
107
108    use ephemeris_documents::ManagedPath;
109    use ephemeris_files::{RepositoryPath, RepositoryRoot};
110
111    use super::DocumentTargetError;
112    use crate::adapters::changelog::Entry;
113    use crate::adapters::holder::{Holder, HolderError, Plan};
114    use crate::edits::PlannedEdits;
115    use crate::version::Version;
116
117    const CHART: &str = "# Chart for the app\napiVersion: v2\nname: app\n# bumped on \
118                         release\nversion: 1.2.3 # current\nappVersion: \"9.9.9\"\n";
119    const PYPROJECT: &str = "[build-system]\nrequires = [\"setuptools\"]\n\n[package]\n# the \
120                             version\nversion = \"0.4.0\"\nname = \"tool\"\n";
121
122    fn inputs(
123        root: &Path,
124        file: &str,
125        key: &[&str],
126    ) -> (RepositoryRoot, RepositoryPath, RepositoryPath, ManagedPath) {
127        let segments: Vec<String> = key.iter().map(|segment| (*segment).to_owned()).collect();
128        (
129            RepositoryRoot::new(root).unwrap(),
130            file.parse().unwrap(),
131            "CHANGELOG.md".parse().unwrap(),
132            segments.try_into().unwrap(),
133        )
134    }
135
136    fn plan(root: &Path, file: &str, key: &[&str], target: &Version) -> Result<Plan, HolderError> {
137        let (root, file, changelog, key) = inputs(root, file, key);
138        Plan::new(
139            &root,
140            Holder::Keyed {
141                path: &file,
142                key: &key,
143            },
144            Some(&changelog),
145            target,
146            "2026-10-21",
147            &Entry::default(),
148        )
149    }
150
151    fn apply(root: &Path, plan: &Plan) -> Result<(), HolderError> {
152        let mut edits = PlannedEdits::new(&RepositoryRoot::new(root)?);
153        plan.contribute_edits(&mut edits)?;
154        Ok(edits.apply()?)
155    }
156
157    fn version(text: &str) -> Version {
158        text.parse().unwrap()
159    }
160
161    fn fixture(file: &str, text: &str) -> tempfile::TempDir {
162        let root = tempfile::tempdir().unwrap();
163        fs::write(root.path().join(file), text).unwrap();
164        fs::write(root.path().join("CHANGELOG.md"), "## [Unreleased]\n").unwrap();
165        let repository = git2::Repository::init(root.path()).unwrap();
166        let mut index = repository.index().unwrap();
167        for path in [file, "CHANGELOG.md"] {
168            index.add_path(Path::new(path)).unwrap();
169        }
170        index.write().unwrap();
171        root
172    }
173
174    #[test]
175    fn yaml_version_is_replaced_and_the_rest_survives() {
176        let root = fixture("Chart.yaml", CHART);
177        let plan = plan(root.path(), "Chart.yaml", &["version"], &version("1.3.0")).unwrap();
178        apply(root.path(), &plan).unwrap();
179        assert_eq!(
180            fs::read_to_string(root.path().join("Chart.yaml")).unwrap(),
181            CHART.replace("version: 1.2.3 # current", "version: 1.3.0 # current")
182        );
183        assert!(
184            fs::read_to_string(root.path().join("CHANGELOG.md"))
185                .unwrap()
186                .contains("## [1.3.0] - 2026-10-21")
187        );
188    }
189
190    #[test]
191    fn toml_nested_version_is_replaced_and_the_rest_survives() {
192        let root = fixture("pyproject.toml", PYPROJECT);
193        let plan = plan(
194            root.path(),
195            "pyproject.toml",
196            &["package", "version"],
197            &version("0.5.0"),
198        )
199        .unwrap();
200        apply(root.path(), &plan).unwrap();
201        assert_eq!(
202            fs::read_to_string(root.path().join("pyproject.toml")).unwrap(),
203            PYPROJECT.replace("\"0.4.0\"", "\"0.5.0\"")
204        );
205    }
206
207    #[test]
208    fn json_version_is_replaced() {
209        let root = fixture(
210            "package.json",
211            "{\n  \"name\": \"x\",\n  \"version\": \"1.0.0\"\n}\n",
212        );
213        let plan = plan(root.path(), "package.json", &["version"], &version("1.0.1")).unwrap();
214        apply(root.path(), &plan).unwrap();
215        assert_eq!(
216            fs::read_to_string(root.path().join("package.json")).unwrap(),
217            "{\n  \"name\": \"x\",\n  \"version\": \"1.0.1\"\n}\n"
218        );
219    }
220
221    #[test]
222    fn stale_document_is_refused() {
223        let root = fixture("Chart.yaml", CHART);
224        let plan = plan(root.path(), "Chart.yaml", &["version"], &version("1.3.0")).unwrap();
225        fs::write(root.path().join("Chart.yaml"), "version: 1.2.9\n").unwrap();
226        assert!(apply(root.path(), &plan).is_err());
227    }
228
229    #[test]
230    fn unknown_extension_is_rejected() {
231        let root = fixture("version.txt", "version = 1.2.3\n");
232        assert!(matches!(
233            plan(root.path(), "version.txt", &["version"], &version("1.3.0")),
234            Err(HolderError::Document(DocumentTargetError::UnknownFormat {
235                path: _
236            }))
237        ));
238    }
239
240    #[test]
241    fn missing_key_is_rejected() {
242        let root = fixture("Chart.yaml", CHART);
243        assert!(matches!(
244            plan(
245                root.path(),
246                "Chart.yaml",
247                &["package", "version"],
248                &version("1.3.0")
249            ),
250            Err(HolderError::Document(DocumentTargetError::MissingKey {
251                path: _,
252                key: _
253            }))
254        ));
255    }
256
257    #[test]
258    fn non_string_value_is_rejected() {
259        let root = fixture("pyproject.toml", "version = 3\n");
260        assert!(matches!(
261            plan(
262                root.path(),
263                "pyproject.toml",
264                &["version"],
265                &version("1.3.0")
266            ),
267            Err(HolderError::Document(DocumentTargetError::NotString {
268                path: _,
269                key: _
270            }))
271        ));
272    }
273
274    #[test]
275    fn invalid_version_is_rejected() {
276        let root = fixture("Chart.yaml", "version: latest\n");
277        assert!(matches!(
278            plan(root.path(), "Chart.yaml", &["version"], &version("1.3.0")),
279            Err(HolderError::Parse { path: _, source: _ })
280        ));
281    }
282}