1use 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#[derive(Debug, thiserror::Error)]
20pub(crate) enum PinError {
21 #[error("pin of {dependency:?} in {unit:?} has no value at its key in {}", path.display())]
23 MissingKey {
24 unit: String,
26 dependency: String,
28 path: PathBuf,
30 },
31 #[error("cannot pin {dependency:?} in {unit:?}")]
33 Document {
34 unit: String,
36 dependency: String,
38 #[source]
40 source: Box<DocumentTargetError>,
41 },
42 #[error(transparent)]
44 NotUtf8(#[from] NotUtf8),
45 #[error(transparent)]
47 File(#[from] RequiredFileError),
48 #[error(transparent)]
50 Path(#[from] PathError),
51 #[error(transparent)]
53 Edit(#[from] EditError),
54 #[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 unit: String,
62 dependency: String,
64 artifact: String,
66 },
67 #[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
99pub(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, ¤t.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
140pub(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(), ¤t, |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
199pub(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}