1use std::collections::BTreeMap;
4use std::path::PathBuf;
5
6use ephemeris_files::{RepositoryPath, RepositoryRoot};
7
8use super::CargoError;
9use super::workspace::{Member, Workspace, WorkspaceError};
10use crate::adapters::changelog::{ChangelogEdit, Entry};
11use crate::edits::PlannedEdits;
12use crate::version::Version;
13
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub(crate) struct Change {
16 pub(crate) unit: String,
17 pub(crate) from: String,
18 pub(crate) to: String,
19 pub(crate) manifest: PathBuf,
20 pub(crate) changelog: Option<ChangelogEdit>,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub(crate) struct Plan {
25 root: RepositoryRoot,
26 pub(crate) changes: Vec<Change>,
27 manifest_snapshots: BTreeMap<PathBuf, String>,
28 manifest_updates: BTreeMap<PathBuf, String>,
29}
30
31impl Workspace {
32 pub(crate) fn plan(
40 &self,
41 completed_proposals: &BTreeMap<String, Version>,
42 changelogs: &BTreeMap<String, Option<RepositoryPath>>,
43 ) -> Result<Plan, CargoError> {
44 self.check_consumers(completed_proposals)?;
45 self.plan_unchecked(completed_proposals, changelogs)
46 }
47
48 pub(crate) fn plan_unchecked(
51 &self,
52 completed_proposals: &BTreeMap<String, Version>,
53 changelogs: &BTreeMap<String, Option<RepositoryPath>>,
54 ) -> Result<Plan, CargoError> {
55 let units = self.units();
56 let mut changes = Vec::new();
57 for (name, target) in completed_proposals {
58 let unit = member(&units, name)?;
59 let changelog = match changelogs.get(name) {
60 Some(path) => path.clone(),
61 None => Some(self.root().relative(&unit.path.join("CHANGELOG.md"))?),
62 };
63 let changelog = changelog
64 .map(|path| ChangelogEdit::read(self.root(), &path))
65 .transpose()?;
66 changes.push(Change {
67 unit: name.clone(),
68 from: unit.version.clone(),
69 to: target.to_string(),
70 manifest: unit.path.join("Cargo.toml"),
71 changelog,
72 });
73 }
74 let manifest_updates = self.render_updates(completed_proposals)?;
75 Ok(Plan {
76 root: self.root().clone(),
77 changes,
78 manifest_snapshots: self.snapshots().clone(),
79 manifest_updates,
80 })
81 }
82}
83
84impl Plan {
85 pub(crate) fn contribute_edits(
88 &self,
89 edits: &mut PlannedEdits,
90 date: &str,
91 entries: &BTreeMap<String, Entry>,
92 ) -> Result<(), CargoError> {
93 for (path, original) in &self.manifest_snapshots {
94 let relative = self.root.relative(path)?;
95 let replacement = self
96 .manifest_updates
97 .get(path)
98 .map(|text| text.as_bytes().to_vec());
99 edits.add(relative, Some(original.as_bytes().to_vec()), replacement)?;
100 }
101 for change in &self.changes {
102 let entry = entries.get(&change.unit).cloned().unwrap_or_default();
103 if let Some(changelog) = &change.changelog {
104 changelog
105 .render(&change.to, date, &entry)?
106 .contribute(edits)?;
107 }
108 }
109 Ok(())
110 }
111}
112
113fn member<'a>(units: &'a [Member], name: &str) -> Result<&'a Member, WorkspaceError> {
114 units
115 .iter()
116 .find(|unit| unit.name == name)
117 .ok_or_else(|| WorkspaceError::UnknownUnit(name.to_owned()))
118}
119
120#[cfg(test)]
121mod tests {
122 use std::collections::BTreeSet;
123 use std::fs;
124 use std::iter::once;
125 use std::path::Path;
126
127 use git2::{IndexAddOption, Oid, Repository, RepositoryInitOptions, Signature};
128
129 use super::*;
130
131 struct Repo(Repository);
133
134 impl Repo {
135 fn init(path: &Path) -> Self {
137 let repository =
138 Repository::init_opts(path, RepositoryInitOptions::new().initial_head("main"))
139 .unwrap();
140 Self(repository)
141 }
142
143 fn add(&self, paths: &[&str]) {
145 let mut index = self.0.index().unwrap();
146 index.add_all(paths, IndexAddOption::DEFAULT, None).unwrap();
147 index.update_all(paths, None).unwrap();
148 index.write().unwrap();
149 }
150
151 fn commit_all(&self, message: &str) -> String {
153 self.add(&["*"]);
154 self.commit(message)
155 }
156
157 fn commit(&self, message: &str) -> String {
159 let tree = self
160 .0
161 .find_tree(self.0.index().unwrap().write_tree().unwrap())
162 .unwrap();
163 let signature = Signature::now("Release test", "release-test@example.invalid").unwrap();
164 let parent = self
165 .0
166 .head()
167 .ok()
168 .map(|head| head.peel_to_commit().unwrap());
169 let parents: Vec<_> = parent.iter().collect();
170 self.0
171 .commit(
172 Some("HEAD"),
173 &signature,
174 &signature,
175 message,
176 &tree,
177 &parents,
178 )
179 .unwrap()
180 .to_string()
181 }
182
183 fn staged(&self) -> Vec<(Vec<u8>, u32, Oid)> {
185 self.0
186 .index()
187 .unwrap()
188 .iter()
189 .map(|entry| (entry.path, entry.mode, entry.id))
190 .collect()
191 }
192 }
193
194 fn discover(root: &RepositoryRoot) -> Result<Vec<Member>, WorkspaceError> {
195 Ok(Workspace::load(root)?.units())
196 }
197
198 fn plan(
199 root: &RepositoryRoot,
200 proposals: &BTreeMap<String, Version>,
201 ) -> Result<Plan, CargoError> {
202 let workspace = Workspace::load(root)?;
203 workspace
204 .version_groups()
205 .validate_selection(&proposals.keys().cloned().collect())?;
206 let completed = workspace.version_groups().complete_explicit(proposals)?;
207 workspace.plan(&completed, &BTreeMap::new())
208 }
209
210 fn apply(plan: &Plan, date: &str) -> Result<(), CargoError> {
211 let mut edits = PlannedEdits::new(&plan.root);
212 plan.contribute_edits(&mut edits, date, &BTreeMap::new())?;
213 Ok(edits.apply()?)
214 }
215
216 #[test]
217 fn discovers_units_and_dependency_order() {
218 let root = tempfile::tempdir().unwrap();
219 fs::write(
220 root.path().join("Cargo.toml"),
221 "[workspace]\nmembers=[\"a\",\"b\"]\n",
222 )
223 .unwrap();
224 for (name, dependency) in &[("a", ""), ("b", "a")] {
225 let path = root.path().join(name);
226 fs::create_dir_all(&path).unwrap();
227 create_library_target(&path);
228 let dependency = if dependency.is_empty() {
229 String::new()
230 } else {
231 "a=\"1\"\n".to_owned()
232 };
233 fs::write(
234 path.join("Cargo.toml"),
235 format!(
236 "[package]\nname=\"{name}\"\nversion=\"1.0.0\"\n[dependencies]\n{dependency}"
237 ),
238 )
239 .unwrap();
240 fs::write(
241 path.join("CHANGELOG.md"),
242 "# Changelog\n\n## [Unreleased]\n",
243 )
244 .unwrap();
245 }
246 let proposals = [
247 ("a".to_owned(), "1.1.0".parse().unwrap()),
248 ("b".to_owned(), "1.1.0".parse().unwrap()),
249 ]
250 .into_iter()
251 .collect();
252 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
253 assert_eq!(
254 plan.changes
255 .iter()
256 .map(|change| change.unit.as_str())
257 .collect::<Vec<_>>(),
258 ["a", "b"]
259 );
260 }
261
262 #[test]
263 fn apply_updates_manifest_and_changelog_without_losing_unreleased_text() {
264 let root = tempfile::tempdir().unwrap();
265 fs::write(
266 root.path().join("Cargo.toml"),
267 "[workspace]\nmembers=[\"a\"]\n",
268 )
269 .unwrap();
270 let path = root.path().join("a");
271 fs::create_dir_all(&path).unwrap();
272 create_library_target(&path);
273 fs::write(
274 path.join("Cargo.toml"),
275 "[package]\nname=\"a\"\nversion=\"1.0.0\"\n",
276 )
277 .unwrap();
278 fs::write(
279 path.join("CHANGELOG.md"),
280 "# Changelog\n\n## [Unreleased]\n\n- keep this\n",
281 )
282 .unwrap();
283 initialize_repository(root.path());
284 let proposals = once(("a".to_owned(), "1.1.0".parse().unwrap())).collect();
285 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
286 apply(&plan, "2026-09-23").unwrap();
287 assert!(
288 fs::read_to_string(path.join("Cargo.toml"))
289 .unwrap()
290 .contains("version=\"1.1.0\"")
291 );
292 let changelog = fs::read_to_string(path.join("CHANGELOG.md")).unwrap();
293 assert!(changelog.contains("keep this"));
294 assert!(changelog.contains("## [1.1.0] - 2026-09-23"));
295 }
296
297 #[test]
298 fn configured_changelog_is_used_and_created_when_absent() {
299 let root = tempfile::tempdir().unwrap();
300 fs::write(
301 root.path().join("Cargo.toml"),
302 "[workspace]\nmembers=[\"a\"]\n",
303 )
304 .unwrap();
305 let package = root.path().join("a");
306 fs::create_dir_all(&package).unwrap();
307 create_library_target(&package);
308 fs::write(
309 package.join("Cargo.toml"),
310 "[package]\nname=\"a\"\nversion=\"1.0.0\"\n",
311 )
312 .unwrap();
313 initialize_repository(root.path());
314 let proposals = BTreeMap::from([("a".to_owned(), "1.1.0".parse().unwrap())]);
315 let changelogs =
316 BTreeMap::from([("a".to_owned(), Some("docs/RELEASES.md".parse().unwrap()))]);
317 let workspace = Workspace::load(&RepositoryRoot::new(root.path()).unwrap()).unwrap();
318 let plan = workspace.plan(&proposals, &changelogs).unwrap();
319 apply(&plan, "2026-09-30").unwrap();
320 assert!(!package.join("CHANGELOG.md").exists());
321 assert!(
322 fs::read_to_string(root.path().join("docs/RELEASES.md"))
323 .unwrap()
324 .contains("## [1.1.0] - 2026-09-30")
325 );
326 }
327
328 #[test]
329 fn a_unit_without_a_changelog_is_planned_and_applied_without_creating_one() {
330 let root = tempfile::tempdir().unwrap();
331 fs::write(
332 root.path().join("Cargo.toml"),
333 "[workspace]\nmembers=[\"a\"]\n",
334 )
335 .unwrap();
336 let package = root.path().join("a");
337 fs::create_dir_all(&package).unwrap();
338 create_library_target(&package);
339 fs::write(
340 package.join("Cargo.toml"),
341 "[package]\nname=\"a\"\nversion=\"1.0.0\"\n",
342 )
343 .unwrap();
344 initialize_repository(root.path());
345 let proposals = BTreeMap::from([("a".to_owned(), "1.1.0".parse().unwrap())]);
346 let changelogs = BTreeMap::from([("a".to_owned(), None)]);
347 let workspace = Workspace::load(&RepositoryRoot::new(root.path()).unwrap()).unwrap();
348 let plan = workspace.plan(&proposals, &changelogs).unwrap();
349 assert!(plan.changes[0].changelog.is_none());
350 apply(&plan, "2026-09-30").unwrap();
351 assert!(!package.join("CHANGELOG.md").exists());
352 assert!(
353 fs::read_to_string(package.join("Cargo.toml"))
354 .unwrap()
355 .contains("1.1.0")
356 );
357 }
358
359 #[test]
360 fn apply_refuses_unrecoverable_file_before_any_write() {
361 let root = release_workspace();
362 initialize_repository(root.path());
363 let changelog = root.path().join("a/CHANGELOG.md");
364 fs::write(
365 &changelog,
366 "# Changelog\n\n## [Unreleased]\n\nlocal-only edit\n",
367 )
368 .unwrap();
369 let proposals = once(("a".to_owned(), "1.1.0".parse().unwrap())).collect();
370 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
371 let manifest = root.path().join("a/Cargo.toml");
372 let manifest_before = fs::read(&manifest).unwrap();
373 let changelog_before = fs::read(&changelog).unwrap();
374 assert!(matches!(
375 apply(&plan, "2026-09-23"),
376 Err(CargoError::Edit(_))
377 ));
378 assert_eq!(fs::read(manifest).unwrap(), manifest_before);
379 assert_eq!(fs::read(changelog).unwrap(), changelog_before);
380 }
381
382 #[test]
383 fn apply_refuses_untracked_changelog_and_preserves_index() {
384 let root = release_workspace();
385 fs::remove_file(root.path().join("a/CHANGELOG.md")).unwrap();
386 let repository = initialize_repository(root.path());
387 let changelog = root.path().join("a/CHANGELOG.md");
388 fs::write(&changelog, "# Changelog\n\n## [Unreleased]\n").unwrap();
389 let index_before = repository.staged();
390 let proposals = once(("a".to_owned(), "1.1.0".parse().unwrap())).collect();
391 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
392 assert!(matches!(
393 apply(&plan, "2026-09-23"),
394 Err(CargoError::Edit(_))
395 ));
396 assert_eq!(repository.staged(), index_before);
397 assert_eq!(
398 fs::read_to_string(changelog).unwrap(),
399 "# Changelog\n\n## [Unreleased]\n"
400 );
401 }
402
403 #[test]
404 fn apply_accepts_staged_recoverable_content_without_mutating_index() {
405 let root = release_workspace();
406 let repository = initialize_repository(root.path());
407 let changelog = root.path().join("a/CHANGELOG.md");
408 fs::write(
409 &changelog,
410 "# Changelog\n\n## [Unreleased]\n\nstaged note\n",
411 )
412 .unwrap();
413 repository.add(&["a/CHANGELOG.md"]);
414 let index_before = repository.staged();
415 let proposals = once(("a".to_owned(), "1.1.0".parse().unwrap())).collect();
416 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
417 apply(&plan, "2026-09-23").unwrap();
418 assert_eq!(repository.staged(), index_before);
419 assert!(
420 fs::read_to_string(changelog)
421 .unwrap()
422 .contains("staged note")
423 );
424 }
425
426 #[test]
427 fn inherited_aliased_requirement_allows_compatible_release() {
428 let root = dependency_workspace("^1");
429 let units = discover(&RepositoryRoot::new(root.path()).unwrap()).unwrap();
430 assert_eq!(
431 units
432 .iter()
433 .find(|unit| unit.name == "app")
434 .unwrap()
435 .dependencies,
436 ["core"]
437 );
438 let proposals = once(("core".to_owned(), "1.5.0".parse().unwrap())).collect();
439 assert!(plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).is_ok());
440 }
441
442 #[test]
443 fn incompatible_internal_requirement_fails_before_writes() {
444 let root = dependency_workspace("^1");
445 let proposals = [
446 ("core".to_owned(), "2.0.0".parse().unwrap()),
447 ("app".to_owned(), "1.1.0".parse().unwrap()),
448 ]
449 .into_iter()
450 .collect();
451 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
452 let root_update = plan
453 .manifest_updates
454 .values()
455 .find(|manifest| manifest.contains("^2.0.0"))
456 .expect("workspace dependency requirement update");
457 assert!(root_update.contains("^2.0.0"));
458 }
459
460 #[test]
461 fn shared_workspace_version_requires_complete_equal_group_proposals() {
462 let root = shared_version_workspace();
463 let workspace = Workspace::load(&RepositoryRoot::new(root.path()).unwrap()).unwrap();
464 assert!(matches!(
465 workspace.version_groups().validate_selection(&BTreeSet::from(["a".to_owned()])),
466 Err(WorkspaceError::IncompleteVersionGroup { missing, version_source: _ }) if missing == ["b"]
467 ));
468 workspace
469 .version_groups()
470 .validate_selection(&BTreeSet::from(["a".to_owned(), "b".to_owned()]))
471 .unwrap();
472 let incomplete = once(("a".to_owned(), "1.1.0".parse().unwrap())).collect();
473 assert!(matches!(
474 plan(&RepositoryRoot::new(root.path()).unwrap(), &incomplete),
475 Err(CargoError::Workspace(WorkspaceError::IncompleteVersionGroup { missing, version_source: _ })) if missing == ["b"]
476 ));
477 let conflicting = [
478 ("a".to_owned(), "1.1.0".parse().unwrap()),
479 ("b".to_owned(), "1.2.0".parse().unwrap()),
480 ]
481 .into_iter()
482 .collect();
483 assert!(matches!(
484 plan(&RepositoryRoot::new(root.path()).unwrap(), &conflicting),
485 Err(CargoError::Workspace(
486 WorkspaceError::ConflictingVersionGroup {
487 version_source: _,
488 members: _
489 }
490 ))
491 ));
492 }
493
494 #[test]
495 fn automatic_proposals_fan_out_shared_version_at_highest_target() {
496 let root = shared_version_workspace();
497 let workspace = Workspace::load(&RepositoryRoot::new(root.path()).unwrap()).unwrap();
498 let version = |value: &str| Version::parse(value).unwrap();
499 let proposals = [
500 ("a".to_owned(), version("1.1.0")),
501 ("independent".to_owned(), version("7.1.0")),
502 ("b".to_owned(), version("1.2.0")),
503 ]
504 .into_iter()
505 .collect();
506 let completed = workspace.version_groups().complete_automatic(&proposals);
507 assert_eq!(completed["a"], version("1.2.0"));
508 assert_eq!(completed["b"], version("1.2.0"));
509 assert_eq!(completed["independent"], version("7.1.0"));
510 }
511
512 #[test]
513 fn shared_workspace_version_is_written_once_and_independent_members_stay_local() {
514 let root = shared_version_workspace();
515 initialize_repository(root.path());
516 let proposals = [
517 ("a".to_owned(), "1.1.0".parse().unwrap()),
518 ("b".to_owned(), "1.1.0".parse().unwrap()),
519 ]
520 .into_iter()
521 .collect();
522 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
523 assert_eq!(plan.changes.len(), 2);
524 assert_eq!(plan.manifest_updates.len(), 1);
525 apply(&plan, "2026-09-30").unwrap();
526 let root_manifest = fs::read_to_string(root.path().join("Cargo.toml")).unwrap();
527 assert!(root_manifest.contains("1.1.0"));
528 assert!(
529 fs::read_to_string(root.path().join("a/Cargo.toml"))
530 .unwrap()
531 .contains("version.workspace=true")
532 );
533 assert!(
534 fs::read_to_string(root.path().join("independent/Cargo.toml"))
535 .unwrap()
536 .contains("version=\"7.0.0\"")
537 );
538 assert!(
539 fs::read_to_string(root.path().join("a/CHANGELOG.md"))
540 .unwrap()
541 .contains("## [1.1.0]")
542 );
543 assert!(
544 fs::read_to_string(root.path().join("b/CHANGELOG.md"))
545 .unwrap()
546 .contains("## [1.1.0]")
547 );
548 }
549
550 #[test]
551 fn stale_workspace_source_refuses_to_apply_member_updates() {
552 let root = shared_version_workspace();
553 initialize_repository(root.path());
554 let proposals = [
555 ("a".to_owned(), "1.1.0".parse().unwrap()),
556 ("b".to_owned(), "1.1.0".parse().unwrap()),
557 ]
558 .into_iter()
559 .collect();
560 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
561 let root_manifest = root.path().join("Cargo.toml");
562 fs::write(&root_manifest, "[workspace]\n# concurrent edit\n").unwrap();
563 let member_before = fs::read(root.path().join("a/Cargo.toml")).unwrap();
564 assert!(matches!(
565 apply(&plan, "2026-09-30"),
566 Err(CargoError::Edit(_))
567 ));
568 assert_eq!(
569 fs::read(root.path().join("a/Cargo.toml")).unwrap(),
570 member_before
571 );
572 }
573
574 #[test]
575 fn version_added_to_an_inline_dependency_keeps_the_spacing() {
576 let root = dependency_kind_workspace();
577 fs::write(
578 root.path().join("Cargo.toml"),
579 concat!(
580 "[workspace]\nresolver=\"3\"\nmembers=[\"core\",\"app\"]\n",
581 "[workspace.dependencies]\n",
582 "core_alias = { package = \"core\", path = \"core\", default-features = false }\n",
583 "app_alias={package=\"app\",path=\"app\"}\n"
584 ),
585 )
586 .unwrap();
587 let proposals = [
588 ("core".to_owned(), "0.1.0".parse().unwrap()),
589 ("app".to_owned(), "1.1.0".parse().unwrap()),
590 ]
591 .into_iter()
592 .collect();
593 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
594 assert!(
595 plan.manifest_updates
596 .values()
597 .any(|manifest| manifest.contains(
598 "core_alias = { package = \"core\", path = \"core\", default-features = \
599 false, version = \"0.1.0\" }"
600 ))
601 );
602 }
603
604 #[test]
605 fn cargo_dependency_kinds_and_targets_are_resolved_by_exact_source() {
606 let root = dependency_kind_workspace();
607 let units = discover(&RepositoryRoot::new(root.path()).unwrap()).unwrap();
608 let app = units.iter().find(|unit| unit.name == "app").unwrap();
609 let core = units.iter().find(|unit| unit.name == "core").unwrap();
610 assert_eq!(app.dependencies, ["core"]);
611 assert_eq!(core.dependencies, Vec::<String>::new());
612 let proposals = [
613 ("core".to_owned(), "1.1.0".parse().unwrap()),
614 ("app".to_owned(), "1.1.0".parse().unwrap()),
615 ]
616 .into_iter()
617 .collect();
618 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
619 assert_eq!(
620 plan.changes
621 .iter()
622 .map(|change| change.unit.as_str())
623 .collect::<Vec<_>>(),
624 ["app", "core"]
625 );
626 let root_update = plan
627 .manifest_updates
628 .values()
629 .find(|manifest| manifest.contains("core_alias"))
630 .expect("workspace dependency sources were updated");
631 assert!(root_update.contains("core_alias"));
632 assert!(root_update.contains("app_alias"));
633 assert!(root_update.contains("1.1.0"));
634 }
635
636 #[test]
637 fn shared_dependency_slot_cannot_change_for_unproposed_consumers() {
638 let root = dependency_workspace("^1");
639 let root_manifest = root.path().join("Cargo.toml");
640 let root_source = fs::read_to_string(&root_manifest).unwrap();
641 fs::write(
642 &root_manifest,
643 root_source.replace(
644 "members=[\"core\",\"app\"]",
645 "members=[\"core\",\"app\",\"other\"]",
646 ),
647 )
648 .unwrap();
649 let other = root.path().join("other");
650 fs::create_dir_all(other.join("src")).unwrap();
651 fs::write(other.join("src/lib.rs"), "pub fn fixture() {}\n").unwrap();
652 fs::write(
653 other.join("Cargo.toml"),
654 concat!(
655 "[package]\nname=\"other\"\nversion=\"1.0.0\"\nedition=\"2024\"\n",
656 "[dependencies]\nrenamed.workspace=true\n"
657 ),
658 )
659 .unwrap();
660 fs::write(
661 other.join("CHANGELOG.md"),
662 "# Changelog\n\n## [Unreleased]\n",
663 )
664 .unwrap();
665 let proposals = [
666 ("core".to_owned(), "2.0.0".parse().unwrap()),
667 ("app".to_owned(), "1.1.0".parse().unwrap()),
668 ]
669 .into_iter()
670 .collect();
671 assert!(matches!(
672 plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals),
673 Err(CargoError::Workspace(WorkspaceError::UnselectedDependencyConsumers { missing, dependency: _ }))
674 if missing == ["other"]
675 ));
676 }
677
678 #[test]
679 fn member_local_path_requirement_is_updated_in_its_own_manifest() {
680 let root = dependency_workspace("^1");
681 let root_manifest = root.path().join("Cargo.toml");
682 let source = fs::read_to_string(&root_manifest).unwrap();
683 fs::write(
684 &root_manifest,
685 source.replace(
686 "[workspace.dependencies]\nrenamed={package=\"core\",version=\"^1\",path=\"core\"\
687 }\n",
688 "",
689 ),
690 )
691 .unwrap();
692 let app_manifest = root.path().join("app/Cargo.toml");
693 fs::write(
694 &app_manifest,
695 concat!(
696 "[package]\nname=\"app\"\nversion=\"1.0.0\"\nedition=\"2024\"\n",
697 "[dependencies]\ncore={path=\"../core\",version=\"^1\"}\n"
698 ),
699 )
700 .unwrap();
701 let proposals = [
702 ("core".to_owned(), "2.0.0".parse().unwrap()),
703 ("app".to_owned(), "1.1.0".parse().unwrap()),
704 ]
705 .into_iter()
706 .collect();
707 let plan = plan(&RepositoryRoot::new(root.path()).unwrap(), &proposals).unwrap();
708 let app_update = plan
709 .manifest_updates
710 .values()
711 .find(|manifest| manifest.contains("^2.0.0"))
712 .expect("member dependency requirement update");
713 assert!(app_update.contains("^2.0.0"));
714 }
715
716 fn dependency_workspace(requirement: &str) -> tempfile::TempDir {
717 let root = tempfile::tempdir().unwrap();
718 fs::write(
719 root.path().join("Cargo.toml"),
720 format!(
721 concat!(
722 "[workspace]\nmembers=[\"core\",\"app\"]\n",
723 "[workspace.dependencies]\n",
724 "renamed={{package=\"core\",version=\"{}\",path=\"core\"}}\n"
725 ),
726 requirement
727 ),
728 )
729 .unwrap();
730 for (directory, name, dependencies) in [
731 ("core", "core", ""),
732 ("app", "app", "[dependencies]\nrenamed.workspace=true\n"),
733 ] {
734 let path = root.path().join(directory);
735 fs::create_dir_all(&path).unwrap();
736 create_library_target(&path);
737 fs::write(
738 path.join("Cargo.toml"),
739 format!("[package]\nname=\"{name}\"\nversion=\"1.0.0\"\n{dependencies}"),
740 )
741 .unwrap();
742 fs::write(
743 path.join("CHANGELOG.md"),
744 "# Changelog\n\n## [Unreleased]\n",
745 )
746 .unwrap();
747 }
748 root
749 }
750
751 fn shared_version_workspace() -> tempfile::TempDir {
752 let root = tempfile::tempdir().unwrap();
753 fs::write(
754 root.path().join("Cargo.toml"),
755 concat!(
756 "[workspace]\nresolver=\"3\"\nmembers=[\"a\",\"b\",\"independent\"]\n",
757 "[workspace.package]\nversion=\"1.0.0\"\n"
758 ),
759 )
760 .unwrap();
761 for (name, version) in [
762 ("a", "version.workspace=true\n"),
763 ("b", "version.workspace=true\n"),
764 ("independent", "version=\"7.0.0\"\n"),
765 ] {
766 let package = root.path().join(name);
767 fs::create_dir_all(package.join("src")).unwrap();
768 fs::write(
769 package.join("Cargo.toml"),
770 format!("[package]\nname=\"{name}\"\n{version}edition=\"2024\"\n"),
771 )
772 .unwrap();
773 fs::write(package.join("src/lib.rs"), "pub fn fixture() {}\n").unwrap();
774 fs::write(
775 package.join("CHANGELOG.md"),
776 "# Changelog\n\n## [Unreleased]\n",
777 )
778 .unwrap();
779 }
780 root
781 }
782
783 fn dependency_kind_workspace() -> tempfile::TempDir {
784 let root = tempfile::tempdir().unwrap();
785 fs::write(
786 root.path().join("Cargo.toml"),
787 concat!(
788 "[workspace]\nresolver=\"3\"\nmembers=[\"core\",\"app\"]\n",
789 "[workspace.dependencies]\n",
790 "core_alias={package=\"core\",path=\"core\"}\n",
791 "app_alias={package=\"app\",path=\"app\"}\n"
792 ),
793 )
794 .unwrap();
795 for (name, extra) in [
796 ("core", "[dev-dependencies]\napp_alias.workspace=true\n"),
797 (
798 "app",
799 concat!(
800 "[dependencies]\ncore_alias.workspace=true\n",
801 "[dev-dependencies]\ncore_alias.workspace=true\n",
802 "[build-dependencies]\ncore_alias.workspace=true\n",
803 "[target.'cfg( target_os=\"linux\" )'.dependencies]\n",
804 "core_alias.workspace=true\n"
805 ),
806 ),
807 ] {
808 let package = root.path().join(name);
809 fs::create_dir_all(package.join("src")).unwrap();
810 fs::write(
811 package.join("Cargo.toml"),
812 format!("[package]\nname=\"{name}\"\nversion=\"1.0.0\"\nedition=\"2024\"\n{extra}"),
813 )
814 .unwrap();
815 fs::write(package.join("src/lib.rs"), "pub fn fixture() {}\n").unwrap();
816 fs::write(package.join("build.rs"), "fn main() {}\n").unwrap();
817 fs::write(
818 package.join("CHANGELOG.md"),
819 "# Changelog\n\n## [Unreleased]\n",
820 )
821 .unwrap();
822 }
823 root
824 }
825
826 fn release_workspace() -> tempfile::TempDir {
827 let root = tempfile::tempdir().unwrap();
828 fs::write(
829 root.path().join("Cargo.toml"),
830 "[workspace]\nmembers=[\"a\"]\n",
831 )
832 .unwrap();
833 let path = root.path().join("a");
834 fs::create_dir_all(&path).unwrap();
835 create_library_target(&path);
836 fs::write(
837 path.join("Cargo.toml"),
838 "[package]\nname=\"a\"\nversion=\"1.0.0\"\n",
839 )
840 .unwrap();
841 fs::write(
842 path.join("CHANGELOG.md"),
843 "# Changelog\n\n## [Unreleased]\n",
844 )
845 .unwrap();
846 root
847 }
848
849 fn create_library_target(package: &Path) {
850 fs::create_dir_all(package.join("src")).unwrap();
851 fs::write(package.join("src/lib.rs"), "pub fn fixture() {}\n").unwrap();
852 }
853
854 fn initialize_repository(root: &Path) -> Repo {
855 let repository = Repo::init(root);
856 repository.commit_all("Initial");
857 repository
858 }
859}