1use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7use std::sync::LazyLock;
8
9use ephemeris_documents::ManagedPath;
10use ephemeris_files::{PathError, RepositoryPath, RepositoryRoot};
11
12use super::changelog::{ChangelogEdit, Entry, MissingUnreleased, ReadError};
13use super::document::{self, DocumentTargetError};
14use super::utf8::{self, NotUtf8};
15use super::{Configured, HolderUnit, LocalChange, LocalPlan, version_file};
16use crate::edits::{EditError, PlannedEdits};
17use crate::error::RequiredFileError;
18use crate::state::ReleaseTarget;
19use crate::unit::Unit;
20use crate::version::{Proposed, Version};
21
22static VERSION_KEY: LazyLock<ManagedPath> =
23 LazyLock::new(|| ManagedPath::single("version".to_owned()));
24static NAME_KEY: LazyLock<ManagedPath> = LazyLock::new(|| ManagedPath::single("name".to_owned()));
25
26#[derive(Debug, thiserror::Error)]
28pub(crate) enum HolderError {
29 #[error("invalid semantic version in {path}")]
31 Parse {
32 path: PathBuf,
34 #[source]
36 source: semver::Error,
37 },
38 #[error(transparent)]
40 File(#[from] RequiredFileError),
41 #[error(transparent)]
43 NotUtf8(#[from] NotUtf8),
44 #[error(transparent)]
46 Changelog(#[from] ReadError),
47 #[error(transparent)]
49 MissingUnreleased(#[from] MissingUnreleased),
50 #[error("version file {} must contain one semantic version", path.display())]
52 Malformed {
53 path: PathBuf,
55 },
56 #[error(transparent)]
58 Document(#[from] DocumentTargetError),
59 #[error(transparent)]
61 Path(#[from] PathError),
62 #[error(transparent)]
64 Edit(#[from] EditError),
65}
66
67#[derive(Clone, Copy, Debug)]
69pub(crate) enum Holder<'a> {
70 Plain {
72 path: &'a RepositoryPath,
74 },
75 Keyed {
77 path: &'a RepositoryPath,
79 key: &'a ManagedPath,
81 },
82}
83
84#[derive(Clone, Copy, Debug)]
86pub(super) enum Home<'a> {
87 Cargo(&'a RepositoryPath),
89 Holder(Holder<'a>),
91}
92
93impl<'a> Home<'a> {
94 pub(super) fn of(target: &'a ReleaseTarget) -> Self {
96 match target {
97 ReleaseTarget::Cargo {
98 manifest,
99 publish: _,
100 } => Self::Cargo(manifest),
101 ReleaseTarget::Npm {
102 manifest,
103 publish: _,
104 } => Self::Holder(Holder::Keyed {
105 path: manifest,
106 key: &VERSION_KEY,
107 }),
108 ReleaseTarget::VersionFile { version_file } => {
109 Self::Holder(Holder::Plain { path: version_file })
110 }
111 ReleaseTarget::Document { path, key } => Self::Holder(Holder::Keyed { path, key }),
112 }
113 }
114}
115
116impl<'a> Holder<'a> {
117 const fn path(self) -> &'a RepositoryPath {
118 match self {
119 Self::Plain { path } | Self::Keyed { path, key: _ } => path,
120 }
121 }
122
123 fn read(self, path: &Path, bytes: &[u8]) -> Result<String, HolderError> {
124 let text = utf8::decode(path, bytes)?;
125 match self {
126 Self::Plain { path: _ } => version_file::read(path, text),
127 Self::Keyed { path: _, key } => Ok(document::read(path, key, text)?),
128 }
129 }
130
131 fn render(self, path: &Path, before: &[u8], target: &str) -> Result<Vec<u8>, HolderError> {
132 match self {
133 Self::Plain { path: _ } => Ok(version_file::render(target)),
134 Self::Keyed { path: _, key } => Ok(document::render(path, key, before, target)?),
135 }
136 }
137}
138
139#[derive(Clone, Debug, Eq, PartialEq)]
141pub(crate) struct Plan {
142 holder: RepositoryPath,
143 before_holder: Vec<u8>,
144 after_holder: Vec<u8>,
145 changelog: Option<ChangelogEdit>,
146}
147
148impl Plan {
149 pub(crate) fn new(
156 root: &RepositoryRoot,
157 holder: Holder<'_>,
158 changelog: Option<&RepositoryPath>,
159 target: &Version,
160 date: &str,
161 entry: &Entry,
162 ) -> Result<Self, HolderError> {
163 let holder_path = holder.path().resolve(root)?;
164 let before_holder = RequiredFileError::read(root, holder.path())?;
165 let current = holder.read(&holder_path, &before_holder)?;
166 Version::parse(¤t).map_err(|source| HolderError::Parse {
167 path: holder_path.clone(),
168 source,
169 })?;
170 let target = target.to_string();
171 let after_holder = holder.render(&holder_path, &before_holder, &target)?;
172 let changelog = match changelog {
173 Some(path) => Some(ChangelogEdit::read(root, path)?.render(&target, date, entry)?),
174 None => None,
175 };
176 Ok(Self {
177 holder: holder.path().clone(),
178 before_holder,
179 after_holder,
180 changelog,
181 })
182 }
183
184 pub(super) fn contribute_edits(&self, edits: &mut PlannedEdits) -> Result<(), HolderError> {
185 edits.add(
186 self.holder.clone(),
187 Some(self.before_holder.clone()),
188 Some(self.after_holder.clone()),
189 )?;
190 if let Some(changelog) = &self.changelog {
191 changelog.contribute(edits)?;
192 }
193 Ok(())
194 }
195}
196
197pub(super) fn resolve(
198 root: &RepositoryRoot,
199 configured: &[Configured<'_, Holder<'_>>],
200) -> Result<Vec<Unit>, HolderError> {
201 configured
202 .iter()
203 .map(|configured| {
204 let path = configured.place.path().resolve(root)?;
205 let bytes = RequiredFileError::read(root, configured.place.path())?;
206 let text = configured.place.read(&path, &bytes)?;
207 let version = Version::parse(&text).map_err(|source| HolderError::Parse {
208 path: path.clone(),
209 source,
210 })?;
211 let package_name = match &configured.unit.target {
212 ReleaseTarget::Npm {
213 manifest: _,
214 publish: _,
215 } => Some(document::read(
216 &path,
217 &NAME_KEY,
218 utf8::decode(&path, &bytes)?,
219 )?),
220 ReleaseTarget::Cargo {
221 manifest: _,
222 publish: _,
223 }
224 | ReleaseTarget::VersionFile { version_file: _ }
225 | ReleaseTarget::Document { path: _, key: _ } => None,
226 };
227 Ok(Unit::configured(
228 configured.name,
229 package_name,
230 version,
231 configured.unit,
232 ))
233 })
234 .collect()
235}
236
237pub(super) fn complete_versions(
240 explicit: &BTreeMap<String, Version>,
241 automatic: &BTreeMap<String, Version>,
242) -> BTreeMap<String, Version> {
243 let mut versions = automatic.clone();
244 versions.extend(
245 explicit
246 .iter()
247 .map(|(name, version)| (name.clone(), version.clone())),
248 );
249 versions
250}
251
252pub(super) fn plan_edits(
253 root: &RepositoryRoot,
254 units: &[HolderUnit<'_>],
255 versions: &BTreeMap<String, Proposed>,
256 date: &str,
257 entries: &BTreeMap<String, Entry>,
258 edits: &mut PlannedEdits,
259) -> Result<(), HolderError> {
260 for unit in units {
261 if let Some(version) = versions.get(&unit.unit.name).map(Proposed::version) {
262 let entry = entries.get(&unit.unit.name).cloned().unwrap_or_default();
263 plan(root, *unit, version, date, &entry)?.contribute_edits(edits)?;
264 }
265 }
266 Ok(())
267}
268
269pub(super) fn local_plan(
270 root: &RepositoryRoot,
271 unit: HolderUnit<'_>,
272 target_version: &Version,
273 date: &str,
274) -> Result<LocalPlan, HolderError> {
275 let plan = plan(root, unit, target_version, date, &Entry::default())?;
276 let mut edits = PlannedEdits::new(root);
277 plan.contribute_edits(&mut edits)?;
278 let change = LocalChange {
279 unit: unit.unit.name.clone(),
280 from: unit.unit.version.to_string(),
281 to: target_version.to_string(),
282 };
283 Ok(LocalPlan::new(vec![change], edits))
284}
285
286fn plan(
287 root: &RepositoryRoot,
288 unit: HolderUnit<'_>,
289 version: &Version,
290 date: &str,
291 entry: &Entry,
292) -> Result<Plan, HolderError> {
293 Plan::new(
294 root,
295 unit.place,
296 unit.unit.changelog.as_ref(),
297 version,
298 date,
299 entry,
300 )
301}
302
303#[cfg(test)]
304mod tests {
305 use std::fs;
306 use std::path::Path;
307
308 use ephemeris_files::RepositoryRoot;
309
310 use super::{Entry, Holder, HolderError, Plan};
311 use crate::adapters::Adapters;
312 use crate::edits::PlannedEdits;
313 use crate::state::ReleaseUnitState;
314
315 fn track(root: &Path, paths: &[&str]) {
317 let repository = git2::Repository::init(root).unwrap();
318 let mut index = repository.index().unwrap();
319 for path in paths {
320 index.add_path(Path::new(path)).unwrap();
321 }
322 index.write().unwrap();
323 }
324
325 fn apply(root: &RepositoryRoot, plan: &Plan) -> Result<(), HolderError> {
326 let mut edits = PlannedEdits::new(root);
327 plan.contribute_edits(&mut edits)?;
328 Ok(edits.apply()?)
329 }
330
331 #[test]
332 fn updates_non_cargo_version_file_and_changelog_transactionally() {
333 let root = tempfile::tempdir().unwrap();
334 fs::write(root.path().join("VERSION"), "1.2.3\n").unwrap();
335 fs::write(
336 root.path().join("CHANGELOG.md"),
337 "# Changes\n\n## [Unreleased]\n",
338 )
339 .unwrap();
340 track(root.path(), &["VERSION", "CHANGELOG.md"]);
341 let repository_root = RepositoryRoot::new(root.path()).unwrap();
342 let plan = Plan::new(
343 &repository_root,
344 Holder::Plain {
345 path: &"VERSION".parse().unwrap(),
346 },
347 Some(&"CHANGELOG.md".parse().unwrap()),
348 &"1.2.4".parse().unwrap(),
349 "2026-09-30",
350 &Entry::default(),
351 )
352 .unwrap();
353 apply(&repository_root, &plan).unwrap();
354 assert_eq!(
355 fs::read_to_string(root.path().join("VERSION")).unwrap(),
356 "1.2.4\n"
357 );
358 assert!(
359 fs::read_to_string(root.path().join("CHANGELOG.md"))
360 .unwrap()
361 .contains("## [1.2.4] - 2026-09-30")
362 );
363 }
364
365 #[test]
366 fn a_version_file_without_a_changelog_creates_none() {
367 let root = tempfile::tempdir().unwrap();
368 fs::write(root.path().join("VERSION"), "1.2.3\n").unwrap();
369 track(root.path(), &["VERSION"]);
370 let repository_root = RepositoryRoot::new(root.path()).unwrap();
371 let plan = Plan::new(
372 &repository_root,
373 Holder::Plain {
374 path: &"VERSION".parse().unwrap(),
375 },
376 None,
377 &"1.2.4".parse().unwrap(),
378 "2026-09-30",
379 &Entry::default(),
380 )
381 .unwrap();
382 apply(&repository_root, &plan).unwrap();
383 assert_eq!(
384 fs::read_to_string(root.path().join("VERSION")).unwrap(),
385 "1.2.4\n"
386 );
387 assert!(!root.path().join("CHANGELOG.md").exists());
388 }
389
390 #[test]
391 fn refuses_stale_version_file_edits() {
392 let root = tempfile::tempdir().unwrap();
393 fs::write(root.path().join("VERSION"), "1.2.3\n").unwrap();
394 fs::write(root.path().join("CHANGELOG.md"), "## [Unreleased]\n").unwrap();
395 track(root.path(), &["VERSION", "CHANGELOG.md"]);
396 let repository_root = RepositoryRoot::new(root.path()).unwrap();
397 let plan = Plan::new(
398 &repository_root,
399 Holder::Plain {
400 path: &"VERSION".parse().unwrap(),
401 },
402 Some(&"CHANGELOG.md".parse().unwrap()),
403 &"1.2.4".parse().unwrap(),
404 "2026-09-30",
405 &Entry::default(),
406 )
407 .unwrap();
408 fs::write(root.path().join("VERSION"), "1.2.5\n").unwrap();
409 assert!(apply(&repository_root, &plan).is_err());
410 }
411
412 #[test]
413 fn an_npm_unit_reads_its_version_and_package_name_from_the_manifest() {
414 let root = tempfile::tempdir().unwrap();
415 fs::create_dir_all(root.path().join("web")).unwrap();
416 fs::write(
417 root.path().join("web/package.json"),
418 r#"{"name":"@acme/web","version":"1.2.3"}"#,
419 )
420 .unwrap();
421 let state: ReleaseUnitState = facet_json::from_str(
422 r#"{"target":{"ecosystem":"npm","manifest":"web/package.json","publish":true},"source":["web"],"changelog":"web/CHANGELOG.md","dependencies":[],"pins":{},"tag_prefix":"web-v","revision_file":null,"kind":"application","application_bump":"patch"}"#,
423 )
424 .unwrap();
425 let units = Adapters::without_registry()
426 .resolve(
427 &RepositoryRoot::new(root.path()).unwrap(),
428 &[("web".to_owned(), state)].into(),
429 )
430 .unwrap();
431 assert_eq!(units[0].package_name.as_deref(), Some("@acme/web"));
432 assert_eq!(units[0].version, "1.2.3".parse().unwrap());
433 }
434}