Skip to main content

ephemeris_release/adapters/
holder.rs

1//! Release units whose version lives in a file of their own or at a key
2//! path inside a document: discovery, planning, and transactional
3//! application without any package registry.
4
5use 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/// Planning or application failure for a unit with a version holder.
27#[derive(Debug, thiserror::Error)]
28pub(crate) enum HolderError {
29    /// The version in the file is not strict semver.
30    #[error("invalid semantic version in {path}")]
31    Parse {
32        /// The version holder.
33        path: PathBuf,
34        /// The parse failure.
35        #[source]
36        source: semver::Error,
37    },
38    /// A version holder cannot be read.
39    #[error(transparent)]
40    File(#[from] RequiredFileError),
41    /// A version holder is not UTF-8 text.
42    #[error(transparent)]
43    NotUtf8(#[from] NotUtf8),
44    /// A changelog cannot be read.
45    #[error(transparent)]
46    Changelog(#[from] ReadError),
47    /// A changelog lacks the `## [Unreleased]` heading.
48    #[error(transparent)]
49    MissingUnreleased(#[from] MissingUnreleased),
50    /// The file holds something other than one version line.
51    #[error("version file {} must contain one semantic version", path.display())]
52    Malformed {
53        /// The version file.
54        path: PathBuf,
55    },
56    /// A document cannot hold or take the version.
57    #[error(transparent)]
58    Document(#[from] DocumentTargetError),
59    /// A configured path is unsafe or outside the repository.
60    #[error(transparent)]
61    Path(#[from] PathError),
62    /// The planned edits conflict or are stale.
63    #[error(transparent)]
64    Edit(#[from] EditError),
65}
66
67/// Where a unit keeps its version.
68#[derive(Clone, Copy, Debug)]
69pub(crate) enum Holder<'a> {
70    /// A file with nothing but the version.
71    Plain {
72        /// The version file.
73        path: &'a RepositoryPath,
74    },
75    /// A string at a key path inside a structured document.
76    Keyed {
77        /// The document.
78        path: &'a RepositoryPath,
79        /// The key path of the version.
80        key: &'a ManagedPath,
81    },
82}
83
84/// Where a target keeps its version: in a Cargo manifest, or in a holder.
85#[derive(Clone, Copy, Debug)]
86pub(super) enum Home<'a> {
87    /// A Cargo manifest, handled by the Cargo adapter.
88    Cargo(&'a RepositoryPath),
89    /// A file or document handled by the holder code.
90    Holder(Holder<'a>),
91}
92
93impl<'a> Home<'a> {
94    /// Where `target` keeps its version.
95    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/// A stale-safe edit of a version holder and its unit changelog.
140#[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    /// Capture and render a release of a version holder without Cargo
150    /// discovery.
151    ///
152    /// # Errors
153    /// Returns an error for unsafe paths, invalid versions, or unreadable
154    /// files.
155    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(&current).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
237/// Explicit targets win over automatic ones. Version holders have no
238/// grouping.
239pub(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    /// Initialize a repository at `root` that tracks `paths`.
316    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}