Skip to main content

ephemeris_release/adapters/
pins.rs

1//! Versions of dependencies written into files of the depending unit.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use ephemeris_documents::ManagedPath;
7use ephemeris_files::{PathError, RepositoryPath, RepositoryRoot};
8
9use super::document::{self, DocumentTargetError};
10use super::utf8::{self, NotUtf8};
11use crate::edits::{EditError, PlannedEdits};
12use crate::error::RequiredFileError;
13use crate::proposal::assets::{AssetBuilder, AssetError};
14use crate::state::DigestPin;
15use crate::unit::Unit;
16use crate::version::Proposed;
17
18/// A pin cannot be read or rewritten.
19#[derive(Debug, thiserror::Error)]
20pub(crate) enum PinError {
21    /// The pin key is absent from the file.
22    #[error("pin of {dependency:?} in {unit:?} has no value at its key in {}", path.display())]
23    MissingKey {
24        /// The unit holding the pin.
25        unit: String,
26        /// The pinned dependency.
27        dependency: String,
28        /// The pin file.
29        path: PathBuf,
30    },
31    /// The pin file is not a document that can take the version.
32    #[error("cannot pin {dependency:?} in {unit:?}")]
33    Document {
34        /// The unit holding the pin.
35        unit: String,
36        /// The pinned dependency.
37        dependency: String,
38        /// The failure.
39        #[source]
40        source: Box<DocumentTargetError>,
41    },
42    /// The pin file is not UTF-8 text.
43    #[error(transparent)]
44    NotUtf8(#[from] NotUtf8),
45    /// The pin file cannot be read.
46    #[error(transparent)]
47    File(#[from] RequiredFileError),
48    /// A configured path is unsafe or outside the repository.
49    #[error(transparent)]
50    Path(#[from] PathError),
51    /// The planned edits conflict or are stale.
52    #[error(transparent)]
53    Edit(#[from] EditError),
54    /// A digest pin holds no digest and its dependency is not released.
55    #[error(
56        "digest pin of {dependency} in {unit} for {artifact} is empty, release {dependency} first \
57         or fill in the digest"
58    )]
59    DigestEmpty {
60        /// The unit holding the pin.
61        unit: String,
62        /// The pinned dependency.
63        dependency: String,
64        /// The artifact of the dependency.
65        artifact: String,
66    },
67    /// The dependency asset cannot be built or read.
68    #[error(transparent)]
69    Asset(#[from] AssetError),
70}
71
72#[derive(Clone, Copy)]
73struct Target<'a> {
74    unit: &'a Unit,
75    dependency: &'a str,
76    path: &'a RepositoryPath,
77    key: &'a ManagedPath,
78}
79
80impl Target<'_> {
81    fn error(&self, source: DocumentTargetError) -> PinError {
82        match source {
83            DocumentTargetError::MissingKey { path, key: _ } => PinError::MissingKey {
84                unit: self.unit.name.clone(),
85                dependency: self.dependency.to_owned(),
86                path,
87            },
88            error @ (DocumentTargetError::UnknownFormat { path: _ }
89            | DocumentTargetError::NotString { path: _, key: _ }
90            | DocumentTargetError::Document { path: _, source: _ }) => PinError::Document {
91                unit: self.unit.name.clone(),
92                dependency: self.dependency.to_owned(),
93                source: Box::new(error),
94            },
95        }
96    }
97}
98
99/// Re-assert the pins of the released units whose dependency is not
100/// released in this proposal.
101pub(super) fn plan_edits(
102    root: &RepositoryRoot,
103    units: &[Unit],
104    versions: &BTreeMap<String, Proposed>,
105    released: &BTreeSet<String>,
106    edits: &mut PlannedEdits,
107) -> Result<(), PinError> {
108    for unit in units
109        .iter()
110        .filter(|unit| versions.contains_key(&unit.name))
111    {
112        for (dependency, pin) in &unit.pins {
113            if released.contains(dependency) {
114                continue;
115            }
116            let Some(current) = units.iter().find(|other| other.name == *dependency) else {
117                continue;
118            };
119            let version = Target {
120                unit,
121                dependency,
122                path: &pin.path,
123                key: &pin.key,
124            };
125            write(root, edits, version, &current.version.to_string())?;
126            for (artifact, digest) in &pin.digests {
127                if read_digest(root, unit, dependency, digest)?.is_empty() {
128                    return Err(PinError::DigestEmpty {
129                        unit: unit.name.clone(),
130                        dependency: dependency.clone(),
131                        artifact: artifact.clone(),
132                    });
133                }
134            }
135        }
136    }
137    Ok(())
138}
139
140/// Write the new version and the asset digests into every unit that pins a
141/// unit of `versions`. The assets are built from `worktree`, which holds the
142/// edits of the dependency.
143pub(super) fn plan_pin_edits<B: AssetBuilder>(
144    root: &RepositoryRoot,
145    units: &[Unit],
146    versions: &BTreeMap<String, Proposed>,
147    builder: &B,
148    worktree: &Path,
149    edits: &mut PlannedEdits,
150) -> Result<(), PinError> {
151    for (dependency, proposed) in versions {
152        for unit in units {
153            let Some(pin) = unit.pins.get(dependency) else {
154                continue;
155            };
156            let version = Target {
157                unit,
158                dependency,
159                path: &pin.path,
160                key: &pin.key,
161            };
162            write(root, edits, version, &proposed.version().to_string())?;
163            for digest in pin.digests.values() {
164                let target = Target {
165                    unit,
166                    dependency,
167                    path: &digest.path,
168                    key: &digest.key,
169                };
170                write(root, edits, target, &digest.digest(builder, worktree)?)?;
171            }
172        }
173    }
174    Ok(())
175}
176
177fn write(
178    root: &RepositoryRoot,
179    edits: &mut PlannedEdits,
180    target: Target<'_>,
181    value: &str,
182) -> Result<(), PinError> {
183    target.path.resolve(root)?;
184    let current = RequiredFileError::read(root, target.path)?;
185    edits.compose(target.path.clone(), &current, |before| {
186        rewrite(&target, before, value)
187    })?;
188    Ok(())
189}
190
191fn rewrite(target: &Target<'_>, before: &[u8], value: &str) -> Result<Vec<u8>, PinError> {
192    let file = target.path.as_path();
193    let text = utf8::decode(file, before)?;
194    document::read(file, target.key, text)
195        .and_then(|_| document::render(file, target.key, before, value))
196        .map_err(|source| target.error(source))
197}
198
199/// The digest that `pin` of `unit` holds in the repository at `root`.
200pub(crate) fn read_digest(
201    root: &RepositoryRoot,
202    unit: &Unit,
203    dependency: &str,
204    pin: &DigestPin,
205) -> Result<String, PinError> {
206    let target = Target {
207        unit,
208        dependency,
209        path: &pin.path,
210        key: &pin.key,
211    };
212    pin.path.resolve(root)?;
213    let bytes = RequiredFileError::read(root, &pin.path)?;
214    let file = pin.path.as_path();
215    let text = utf8::decode(file, &bytes)?;
216    document::read(file, &pin.key, text).map_err(|source| target.error(source))
217}
218
219#[cfg(test)]
220mod tests {
221    use std::collections::{BTreeMap, BTreeSet};
222    use std::fs;
223    use std::path::{Path, PathBuf};
224
225    use ephemeris_files::RepositoryRoot;
226
227    use super::PinError;
228    use crate::adapters::{Adapters, EditContext, NoRegistry, ResolveError};
229    use crate::edits::PlannedEdits;
230    use crate::git::{ProposalGit as _, SystemGit};
231    use crate::proposal::analysis::{self, History, Policy};
232    use crate::proposal::assets::{AssetBuilder, AssetError};
233    use crate::state::{ApplicationBump, ReleaseUnitState};
234    use crate::unit::Unit;
235    use crate::version::{Bump, Proposed, Version};
236
237    const CLIENT: &str = r#"{"target":{"ecosystem":"version-file","version_file":"client/VERSION"},"source":["client"],"changelog":"client/CHANGELOG.md","dependencies":[],"pins":{},"tag_prefix":"client-v","kind":"application","application_bump":"patch","revision_file":null}"#;
238    const ACTION_FILE: &str = "name: client\nversion: 1.0.0\ninputs:\n  client-version:\n    \
239                               default: 1.0.0\n    description: Client version\n";
240
241    struct NoBuilder;
242
243    impl AssetBuilder for NoBuilder {
244        fn build(&self, _: &Path, package: &str) -> Result<PathBuf, AssetError> {
245            Err(AssetError::NoOutputPath {
246                package: package.to_owned(),
247            })
248        }
249    }
250
251    fn action(target_key: &str, pin_key: &str) -> String {
252        format!(
253            r#"{{"target":{{"ecosystem":"document","path":"action.yml","key":{target_key}}},"source":["action"],"changelog":"action/CHANGELOG.md","dependencies":["client"],"pins":{{"client":{{"path":"action.yml","key":{pin_key},"digests":{{}}}}}},"tag_prefix":"action-v","kind":"application","application_bump":"patch","revision_file":null}}"#
254        )
255    }
256
257    fn fixture(root: &Path, action_file: &str) {
258        for (path, contents) in [
259            ("client/VERSION", "1.0.0\n"),
260            ("client/CHANGELOG.md", "## [Unreleased]\n"),
261            ("action/CHANGELOG.md", "## [Unreleased]\n"),
262            ("action.yml", action_file),
263        ] {
264            let path = root.join(path);
265            fs::create_dir_all(path.parent().unwrap()).unwrap();
266            fs::write(path, contents).unwrap();
267        }
268        let repository = git2::Repository::init(root).unwrap();
269        let mut index = repository.index().unwrap();
270        for path in [
271            "client/VERSION",
272            "client/CHANGELOG.md",
273            "action/CHANGELOG.md",
274            "action.yml",
275        ] {
276            index.add_path(Path::new(path)).unwrap();
277        }
278        index.write().unwrap();
279    }
280
281    fn configured(action: &str) -> BTreeMap<String, ReleaseUnitState> {
282        facet_json::from_str(&format!(r#"{{"client":{CLIENT},"action":{action}}}"#)).unwrap()
283    }
284
285    #[expect(
286        clippy::unwrap_in_result,
287        reason = "a staging failure is a broken fixture, not a result under test"
288    )]
289    fn plan_both(
290        root: &RepositoryRoot,
291        units: &[Unit],
292        versions: &BTreeMap<String, Proposed>,
293    ) -> Result<PlannedEdits, ResolveError> {
294        let adapters = Adapters::without_registry();
295        let mut edits = PlannedEdits::new(root);
296        adapters.plan_edits(
297            root,
298            units,
299            versions,
300            &EditContext {
301                date: "2026-10-21",
302                entries: &BTreeMap::new(),
303                released: &versions.keys().cloned().collect(),
304            },
305            &mut edits,
306        )?;
307        edits.apply().map_err(PinError::from)?;
308        SystemGit::default()
309            .stage_update(root.as_path(), &edits.changed_paths())
310            .expect("staging the first pass");
311        let mut pin_edits = PlannedEdits::new(root);
312        Adapters::<NoRegistry, NoRegistry>::plan_pin_edits(
313            root,
314            units,
315            versions,
316            &NoBuilder,
317            root.as_path(),
318            &mut pin_edits,
319        )?;
320        Ok(pin_edits)
321    }
322
323    fn plan(
324        root: &RepositoryRoot,
325        action: &str,
326        versions: &[(&str, &str)],
327    ) -> Result<PlannedEdits, ResolveError> {
328        let configured = configured(action);
329        let adapters = Adapters::without_registry();
330        let units = adapters.resolve(root, &configured)?;
331        let versions: BTreeMap<_, _> = versions
332            .iter()
333            .map(|(name, version)| {
334                (
335                    (*name).to_owned(),
336                    Proposed::Bump(Version::parse(version).unwrap()),
337                )
338            })
339            .collect();
340        plan_both(root, &units, &versions)
341    }
342
343    #[test]
344    fn pin_takes_the_new_version_of_its_dependency() {
345        let root = tempfile::tempdir().unwrap();
346        fixture(root.path(), ACTION_FILE);
347        let action = action(r#"["version"]"#, r#"["inputs","client-version","default"]"#);
348        let edits = plan(
349            &RepositoryRoot::new(root.path()).unwrap(),
350            &action,
351            &[("client", "1.1.0"), ("action", "1.0.1")],
352        )
353        .unwrap();
354        edits.apply().unwrap();
355        assert_eq!(
356            fs::read_to_string(root.path().join("action.yml")).unwrap(),
357            ACTION_FILE
358                .replace("version: 1.0.0", "version: 1.0.1")
359                .replace("default: 1.0.0", "default: 1.1.0")
360        );
361    }
362
363    #[test]
364    fn local_plan_of_the_pinning_unit_lists_the_pin_file() {
365        let root = tempfile::tempdir().unwrap();
366        fixture(root.path(), ACTION_FILE);
367        let action = action(r#"["version"]"#, r#"["inputs","client-version","default"]"#);
368        let root = RepositoryRoot::new(root.path()).unwrap();
369        let configured = configured(&action);
370        let adapters = Adapters::without_registry();
371        let units = adapters.resolve(&root, &configured).unwrap();
372        let pinning = units.iter().find(|unit| unit.name == "action").unwrap();
373        let plan = adapters
374            .local_plan(
375                &root,
376                &units,
377                pinning,
378                &Version::parse("1.0.1").unwrap(),
379                "2026-10-21",
380            )
381            .unwrap();
382        assert_eq!(
383            plan.files,
384            [Path::new("action/CHANGELOG.md"), Path::new("action.yml")]
385        );
386    }
387
388    #[test]
389    fn local_plan_of_a_dependency_leaves_the_pin_file_alone() {
390        let root = tempfile::tempdir().unwrap();
391        fixture(root.path(), ACTION_FILE);
392        let action = action(r#"["version"]"#, r#"["inputs","client-version","default"]"#);
393        let root = RepositoryRoot::new(root.path()).unwrap();
394        let configured = configured(&action);
395        let adapters = Adapters::without_registry();
396        let units = adapters.resolve(&root, &configured).unwrap();
397        let client = units.iter().find(|unit| unit.name == "client").unwrap();
398        let plan = adapters
399            .local_plan(
400                &root,
401                &units,
402                client,
403                &Version::parse("1.1.0").unwrap(),
404                "2026-10-21",
405            )
406            .unwrap();
407        assert_eq!(
408            plan.files,
409            [
410                Path::new("client/CHANGELOG.md"),
411                Path::new("client/VERSION")
412            ]
413        );
414    }
415
416    fn proposed(
417        root: &RepositoryRoot,
418        action: &str,
419        changed: &[&str],
420        overrides: &[(&str, &str)],
421    ) -> BTreeMap<String, Proposed> {
422        let configured = configured(action);
423        let units = Adapters::without_registry()
424            .resolve(root, &configured)
425            .unwrap();
426        let history = ["client", "action"]
427            .map(|name| {
428                (
429                    name.to_owned(),
430                    History {
431                        baseline_tag: Some(format!("{name}-v1.0.0")),
432                        conventional: true,
433                        changed: changed.contains(&name),
434                        commits: Vec::new(),
435                    },
436                )
437            })
438            .into();
439        let with_bump = |wanted| {
440            configured
441                .iter()
442                .filter(|(_, unit)| unit.application_bump == wanted)
443                .map(|(name, _)| name.clone())
444                .collect::<BTreeSet<_>>()
445        };
446        let overrides = overrides
447            .iter()
448            .map(|(name, version)| ((*name).to_owned(), Version::parse(version).unwrap()))
449            .collect();
450        let policy = Policy {
451            applications: &with_bump(ApplicationBump::Patch),
452            non_bumping: &with_bump(ApplicationBump::None),
453            overrides: &overrides,
454        };
455        analysis::analyze(&units, &history, policy, |_, _| Ok(Bump::Patch))
456            .unwrap()
457            .versions()
458    }
459
460    fn pinned_file(
461        action: &str,
462        client_version: &str,
463        changed: &[&str],
464        overrides: &[(&str, &str)],
465    ) -> String {
466        let root = tempfile::tempdir().unwrap();
467        fixture(root.path(), ACTION_FILE);
468        fs::write(
469            root.path().join("client/VERSION"),
470            format!("{client_version}\n"),
471        )
472        .unwrap();
473        let checkout = RepositoryRoot::new(root.path()).unwrap();
474        let versions = proposed(&checkout, action, changed, overrides);
475        let configured = configured(action);
476        let adapters = Adapters::without_registry();
477        let units = adapters.resolve(&checkout, &configured).unwrap();
478        plan_both(&checkout, &units, &versions)
479            .unwrap()
480            .apply()
481            .unwrap();
482        fs::read_to_string(root.path().join("action.yml")).unwrap()
483    }
484
485    #[test]
486    fn dependency_change_bumps_the_pinning_action_and_writes_the_pin() {
487        let action = action(r#"["version"]"#, r#"["inputs","client-version","default"]"#);
488        assert_eq!(
489            pinned_file(&action, "1.0.0", &["client"], &[]),
490            ACTION_FILE
491                .replace("version: 1.0.0", "version: 1.0.1")
492                .replace("default: 1.0.0", "default: 1.0.1")
493        );
494    }
495
496    #[test]
497    fn action_without_a_bump_still_takes_the_dependency_pin() {
498        let action = action(r#"["version"]"#, r#"["inputs","client-version","default"]"#).replace(
499            r#""application_bump":"patch""#,
500            r#""application_bump":"none""#,
501        );
502        assert_eq!(
503            pinned_file(&action, "1.0.0", &["client"], &[]),
504            ACTION_FILE.replace("default: 1.0.0", "default: 1.0.1")
505        );
506    }
507
508    #[test]
509    fn explicit_action_bump_writes_the_current_dependency_version() {
510        let action = action(r#"["version"]"#, r#"["inputs","client-version","default"]"#).replace(
511            r#""application_bump":"patch""#,
512            r#""application_bump":"none""#,
513        );
514        assert_eq!(
515            pinned_file(&action, "1.2.0", &["action"], &[("action", "1.0.1")]),
516            ACTION_FILE
517                .replace("version: 1.0.0", "version: 1.0.1")
518                .replace("default: 1.0.0", "default: 1.2.0")
519        );
520    }
521
522    #[test]
523    fn pin_without_a_dependency_edge_is_rejected() {
524        let root = tempfile::tempdir().unwrap();
525        fixture(root.path(), ACTION_FILE);
526        let action = action(r#"["version"]"#, r#"["inputs","client-version","default"]"#)
527            .replace(r#""dependencies":["client"]"#, r#""dependencies":[]"#);
528        let error = Adapters::without_registry()
529            .resolve(
530                &RepositoryRoot::new(root.path()).unwrap(),
531                &configured(&action),
532            )
533            .unwrap_err();
534        assert!(matches!(
535            error,
536            ResolveError::PinWithoutDependency { unit, dependency }
537                if unit == "action" && dependency == "client"
538        ));
539    }
540
541    #[test]
542    fn pin_is_left_alone_without_a_new_dependency_version() {
543        let root = tempfile::tempdir().unwrap();
544        fixture(root.path(), ACTION_FILE);
545        let action = action(r#"["version"]"#, r#"["inputs","client-version","default"]"#);
546        let edits = plan(
547            &RepositoryRoot::new(root.path()).unwrap(),
548            &action,
549            &[("action", "1.0.1")],
550        )
551        .unwrap();
552        edits.apply().unwrap();
553        assert_eq!(
554            fs::read_to_string(root.path().join("action.yml")).unwrap(),
555            ACTION_FILE.replace("version: 1.0.0", "version: 1.0.1")
556        );
557    }
558
559    #[test]
560    fn pin_without_its_key_names_unit_dependency_and_file() {
561        let root = tempfile::tempdir().unwrap();
562        fixture(root.path(), ACTION_FILE);
563        let action = action(r#"["version"]"#, r#"["inputs","other","default"]"#);
564        let error = plan(
565            &RepositoryRoot::new(root.path()).unwrap(),
566            &action,
567            &[("client", "1.1.0"), ("action", "1.0.1")],
568        )
569        .unwrap_err();
570        assert!(matches!(
571            error,
572            ResolveError::Pin(PinError::MissingKey { unit, dependency, path })
573                if unit == "action" && dependency == "client" && path == Path::new("action.yml")
574        ));
575    }
576}