ephemeris_release/adapters/
document.rs1use 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#[derive(Debug, thiserror::Error)]
13pub(crate) enum DocumentTargetError {
14 #[error("{} is not a .toml, .yaml, .yml, .json, or .json5 file", path.display())]
16 UnknownFormat {
17 path: PathBuf,
19 },
20 #[error("{} has no value at {key}", path.display())]
22 MissingKey {
23 path: PathBuf,
25 key: ManagedPath,
27 },
28 #[error("the value at {key} in {} is not a string", path.display())]
30 NotString {
31 path: PathBuf,
33 key: ManagedPath,
35 },
36 #[error("cannot process {}", path.display())]
38 Document {
39 path: PathBuf,
41 #[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
62pub(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
88pub(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}