Skip to main content

ephemeris_release/adapters/cargo/
planning.rs

1//! Manifest and changelog edits for Cargo release units.
2
3use 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    /// Plan the release of already completed proposals. Units without an
33    /// entry in `changelogs` use `CHANGELOG.md` in their directory. A `None`
34    /// entry keeps no changelog.
35    ///
36    /// # Errors
37    /// Returns an error for unknown units, incompatible dependency
38    /// requirements, or unsafe manifest and changelog files.
39    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    /// Like [`Workspace::plan`], without the check that every consumer of a
49    /// changed dependency entry has a proposal.
50    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    /// Contribute the manifest and changelog edits. `entries` holds the
86    /// changelog entries by package.
87    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    /// A repository without any user configuration.
132    struct Repo(Repository);
133
134    impl Repo {
135        /// Initialize a repository on `main` at `path`.
136        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        /// Stage `paths` as `git add` does, including deletions.
144        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        /// Stage every change, as `git add --all` does, and commit it.
152        fn commit_all(&self, message: &str) -> String {
153            self.add(&["*"]);
154            self.commit(message)
155        }
156
157        /// Commit the index on `HEAD`.
158        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        /// lists them.
184        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}