1use std::collections::{BTreeMap, BTreeSet};
2
3use camino::Utf8PathBuf;
4use serde::Serialize;
5
6use crate::{ChangesetId, EcosystemId, PackageId};
7
8#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
10pub struct ConfiguredPackage {
11 pub id: PackageId,
12 pub ecosystem: EcosystemId,
13 pub path: Utf8PathBuf,
14}
15
16#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
18pub struct DiscoveredPackage {
19 pub id: PackageId,
20 pub ecosystem: EcosystemId,
21 pub path: Utf8PathBuf,
22}
23
24#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
25pub struct PackageRename {
26 pub from: PackageId,
27 pub to: PackageId,
28 pub ecosystem: EcosystemId,
29 pub path: Utf8PathBuf,
30}
31
32#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
33pub struct PackageMove {
34 pub package: PackageId,
35 pub ecosystem: EcosystemId,
36 pub from: Utf8PathBuf,
37 pub to: Utf8PathBuf,
38}
39
40#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
41#[serde(tag = "kind", rename_all = "snake_case")]
42pub enum ConfigConflict {
43 AmbiguousMatch {
44 configured: Vec<ConfiguredPackage>,
45 discovered: Vec<DiscoveredPackage>,
46 },
47 ResolverChanged {
48 configured: ConfiguredPackage,
49 discovered: DiscoveredPackage,
50 },
51}
52
53#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
54pub struct ChangesetReference {
55 pub changeset: ChangesetId,
56 pub packages: BTreeSet<PackageId>,
57}
58
59#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
60#[serde(tag = "kind", rename_all = "snake_case")]
61pub enum ConfigSyncWarning {
62 ChangesetReferencesRenamedPackage {
63 changeset: ChangesetId,
64 from: PackageId,
65 to: PackageId,
66 },
67}
68
69#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
71pub struct ConfigSyncPlan {
72 pub config_path: Utf8PathBuf,
73 pub prune_missing: bool,
74 pub added: Vec<DiscoveredPackage>,
75 pub missing: Vec<ConfiguredPackage>,
76 pub renamed: Vec<PackageRename>,
77 pub moved: Vec<PackageMove>,
78 pub conflicts: Vec<ConfigConflict>,
79 pub warnings: Vec<ConfigSyncWarning>,
80}
81
82impl ConfigSyncPlan {
83 #[must_use]
84 pub fn has_drift(&self) -> bool {
85 !self.added.is_empty()
86 || !self.missing.is_empty()
87 || !self.renamed.is_empty()
88 || !self.moved.is_empty()
89 || !self.conflicts.is_empty()
90 }
91}
92
93pub struct ConfigSyncPlanner;
95
96impl ConfigSyncPlanner {
97 #[must_use]
98 pub fn plan(
99 config_path: Utf8PathBuf,
100 configured: &[ConfiguredPackage],
101 discovered: &[DiscoveredPackage],
102 changesets: &[ChangesetReference],
103 ) -> ConfigSyncPlan {
104 let mut configured = configured.to_vec();
105 let mut discovered = discovered.to_vec();
106 configured.sort();
107 discovered.sort();
108
109 let mut matches = MatchTracker::default();
110 let mut renamed = Vec::new();
111 let mut moved = Vec::new();
112
113 let mut discovered_by_id = BTreeMap::<PackageId, Vec<_>>::new();
114 for (index, package) in discovered.iter().enumerate() {
115 discovered_by_id
116 .entry(package.id.clone())
117 .or_default()
118 .push(index);
119 }
120 let colliding_discovered_ids = discovered_by_id
121 .iter()
122 .filter_map(|(id, indexes)| (indexes.len() > 1).then_some(id.clone()))
123 .collect::<BTreeSet<_>>();
124 for (id, discovered_indexes) in &discovered_by_id {
125 if discovered_indexes.len() < 2 {
126 continue;
127 }
128 let mut ecosystems = BTreeMap::<EcosystemId, usize>::new();
129 for discovered_index in discovered_indexes {
130 if let Some(package) = discovered.get(*discovered_index) {
131 *ecosystems.entry(package.ecosystem.clone()).or_default() += 1;
132 }
133 }
134 if ecosystems.values().all(|count| *count == 1) {
135 continue;
136 }
137 let configured_indexes = configured
138 .iter()
139 .enumerate()
140 .filter_map(|(index, package)| {
141 (package.id == *id
142 || discovered_indexes.iter().any(|discovered_index| {
143 discovered.get(*discovered_index).is_some_and(|found| {
144 package.ecosystem == found.ecosystem && package.path == found.path
145 })
146 }))
147 .then_some(index)
148 })
149 .collect::<Vec<_>>();
150 matches.push_ambiguous(
151 &configured_indexes,
152 discovered_indexes,
153 &configured,
154 &discovered,
155 );
156 }
157
158 let mut configured_by_location = BTreeMap::<(EcosystemId, Utf8PathBuf), Vec<_>>::new();
159 let mut discovered_by_location = BTreeMap::<(EcosystemId, Utf8PathBuf), Vec<_>>::new();
160 for (index, package) in configured.iter().enumerate() {
161 if matches.configured.contains(&index) {
162 continue;
163 }
164 configured_by_location
165 .entry((package.ecosystem.clone(), package.path.clone()))
166 .or_default()
167 .push(index);
168 }
169 for (index, package) in discovered.iter().enumerate() {
170 if matches.discovered.contains(&index) {
171 continue;
172 }
173 discovered_by_location
174 .entry((package.ecosystem.clone(), package.path.clone()))
175 .or_default()
176 .push(index);
177 }
178 for (location, configured_indexes) in configured_by_location {
179 let Some(discovered_indexes) = discovered_by_location.get(&location) else {
180 continue;
181 };
182 if let ([configured_index], [discovered_index]) =
183 (configured_indexes.as_slice(), discovered_indexes.as_slice())
184 && let (Some(current), Some(found)) = (
185 configured.get(*configured_index),
186 discovered.get(*discovered_index),
187 )
188 {
189 if current.id != found.id && !colliding_discovered_ids.contains(&found.id) {
190 renamed.push(PackageRename {
191 from: current.id.clone(),
192 to: found.id.clone(),
193 ecosystem: found.ecosystem.clone(),
194 path: found.path.clone(),
195 });
196 }
197 matches.configured.insert(*configured_index);
198 matches.discovered.insert(*discovered_index);
199 } else {
200 matches.push_ambiguous(
201 &configured_indexes,
202 discovered_indexes,
203 &configured,
204 &discovered,
205 );
206 }
207 }
208
209 for (id, discovered_indexes) in &discovered_by_id {
210 if discovered_indexes.len() < 2 {
211 continue;
212 }
213 let remaining_discovered_indexes = discovered_indexes
214 .iter()
215 .copied()
216 .filter(|index| !matches.discovered.contains(index))
217 .collect::<Vec<_>>();
218 if remaining_discovered_indexes.is_empty() {
219 continue;
220 }
221 let configured_indexes = configured
222 .iter()
223 .enumerate()
224 .filter_map(|(index, package)| (package.id == *id).then_some(index))
225 .collect::<Vec<_>>();
226 let suggestion_is_already_bound = configured_indexes
227 .iter()
228 .any(|index| matches.configured.contains(index));
229 if remaining_discovered_indexes.len() > 1 || suggestion_is_already_bound {
230 matches.push_ambiguous(
231 &configured_indexes,
232 &remaining_discovered_indexes,
233 &configured,
234 &discovered,
235 );
236 }
237 }
238
239 let mut configured_by_id = BTreeMap::<PackageId, Vec<_>>::new();
240 let mut discovered_by_id = BTreeMap::<PackageId, Vec<_>>::new();
241 for (index, package) in configured.iter().enumerate() {
242 if !matches.configured.contains(&index) {
243 configured_by_id
244 .entry(package.id.clone())
245 .or_default()
246 .push(index);
247 }
248 }
249 for (index, package) in discovered.iter().enumerate() {
250 if !matches.discovered.contains(&index) {
251 discovered_by_id
252 .entry(package.id.clone())
253 .or_default()
254 .push(index);
255 }
256 }
257 for (id, configured_indexes) in configured_by_id {
258 let Some(discovered_indexes) = discovered_by_id.get(&id) else {
259 continue;
260 };
261 if let ([configured_index], [discovered_index]) =
262 (configured_indexes.as_slice(), discovered_indexes.as_slice())
263 && let (Some(current), Some(found)) = (
264 configured.get(*configured_index),
265 discovered.get(*discovered_index),
266 )
267 {
268 if current.ecosystem == found.ecosystem {
269 moved.push(PackageMove {
270 package: found.id.clone(),
271 ecosystem: found.ecosystem.clone(),
272 from: current.path.clone(),
273 to: found.path.clone(),
274 });
275 } else {
276 matches.conflicts.push(ConfigConflict::ResolverChanged {
277 configured: current.clone(),
278 discovered: found.clone(),
279 });
280 }
281 matches.configured.insert(*configured_index);
282 matches.discovered.insert(*discovered_index);
283 } else {
284 matches.push_ambiguous(
285 &configured_indexes,
286 discovered_indexes,
287 &configured,
288 &discovered,
289 );
290 }
291 }
292
293 let mut configured_by_path = BTreeMap::<Utf8PathBuf, Vec<_>>::new();
294 let mut discovered_by_path = BTreeMap::<Utf8PathBuf, Vec<_>>::new();
295 for (index, package) in configured.iter().enumerate() {
296 if !matches.configured.contains(&index) {
297 configured_by_path
298 .entry(package.path.clone())
299 .or_default()
300 .push(index);
301 }
302 }
303 for (index, package) in discovered.iter().enumerate() {
304 if !matches.discovered.contains(&index) {
305 discovered_by_path
306 .entry(package.path.clone())
307 .or_default()
308 .push(index);
309 }
310 }
311 for (path, configured_indexes) in configured_by_path {
312 let Some(discovered_indexes) = discovered_by_path.get(&path) else {
313 continue;
314 };
315 if let ([configured_index], [discovered_index]) =
316 (configured_indexes.as_slice(), discovered_indexes.as_slice())
317 && let (Some(configured_package), Some(discovered_package)) = (
318 configured.get(*configured_index),
319 discovered.get(*discovered_index),
320 )
321 {
322 matches.conflicts.push(ConfigConflict::ResolverChanged {
323 configured: configured_package.clone(),
324 discovered: discovered_package.clone(),
325 });
326 matches.configured.insert(*configured_index);
327 matches.discovered.insert(*discovered_index);
328 } else {
329 matches.push_ambiguous(
330 &configured_indexes,
331 discovered_indexes,
332 &configured,
333 &discovered,
334 );
335 }
336 }
337
338 let added = discovered
339 .into_iter()
340 .enumerate()
341 .filter_map(|(index, package)| {
342 (!matches.discovered.contains(&index)).then_some(package)
343 })
344 .collect();
345 let missing = configured
346 .into_iter()
347 .enumerate()
348 .filter_map(|(index, package)| {
349 (!matches.configured.contains(&index)).then_some(package)
350 })
351 .collect();
352 renamed.sort();
353 moved.sort();
354 matches.conflicts.sort();
355 let warnings = renamed
356 .iter()
357 .flat_map(|rename| {
358 changesets
359 .iter()
360 .filter(|changeset| changeset.packages.contains(&rename.from))
361 .map(
362 |changeset| ConfigSyncWarning::ChangesetReferencesRenamedPackage {
363 changeset: changeset.changeset.clone(),
364 from: rename.from.clone(),
365 to: rename.to.clone(),
366 },
367 )
368 })
369 .collect::<BTreeSet<_>>()
370 .into_iter()
371 .collect();
372
373 ConfigSyncPlan {
374 config_path,
375 prune_missing: false,
376 added,
377 missing,
378 renamed,
379 moved,
380 conflicts: matches.conflicts,
381 warnings,
382 }
383 }
384}
385
386#[derive(Default)]
387struct MatchTracker {
388 configured: BTreeSet<usize>,
389 discovered: BTreeSet<usize>,
390 conflicts: Vec<ConfigConflict>,
391}
392
393impl MatchTracker {
394 fn push_ambiguous(
395 &mut self,
396 configured_indexes: &[usize],
397 discovered_indexes: &[usize],
398 configured: &[ConfiguredPackage],
399 discovered: &[DiscoveredPackage],
400 ) {
401 self.conflicts.push(ConfigConflict::AmbiguousMatch {
402 configured: configured_indexes
403 .iter()
404 .filter_map(|index| configured.get(*index).cloned())
405 .collect(),
406 discovered: discovered_indexes
407 .iter()
408 .filter_map(|index| discovered.get(*index).cloned())
409 .collect(),
410 });
411 self.configured.extend(configured_indexes);
412 self.discovered.extend(discovered_indexes);
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 fn configured(id: &str, ecosystem: EcosystemId, path: &str) -> ConfiguredPackage {
421 ConfiguredPackage {
422 id: PackageId::new(id),
423 ecosystem,
424 path: Utf8PathBuf::from(path),
425 }
426 }
427
428 fn discovered(id: &str, ecosystem: EcosystemId, path: &str) -> DiscoveredPackage {
429 DiscoveredPackage {
430 id: PackageId::new(id),
431 ecosystem,
432 path: Utf8PathBuf::from(path),
433 }
434 }
435
436 fn plan(configured: &[ConfiguredPackage], discovered: &[DiscoveredPackage]) -> ConfigSyncPlan {
437 ConfigSyncPlanner::plan(
438 Utf8PathBuf::from(".changes/config.toml"),
439 configured,
440 discovered,
441 &[],
442 )
443 }
444
445 #[test]
446 fn classifies_added_missing_renamed_and_moved_packages() {
447 let plan = plan(
448 &[
449 configured("missing", EcosystemId::RUST, "crates/missing"),
450 configured("moved", EcosystemId::NODE, "packages/old"),
451 configured("old-name", EcosystemId::PYTHON, "python/pkg"),
452 configured("same", EcosystemId::CPP, "cpp/same"),
453 ],
454 &[
455 discovered("added", EcosystemId::RUST, "crates/added"),
456 discovered("moved", EcosystemId::NODE, "packages/new"),
457 discovered("new-name", EcosystemId::PYTHON, "python/pkg"),
458 discovered("same", EcosystemId::CPP, "cpp/same"),
459 ],
460 );
461
462 assert_eq!(
463 plan.added,
464 [discovered("added", EcosystemId::RUST, "crates/added")]
465 );
466 assert_eq!(
467 plan.missing,
468 [configured("missing", EcosystemId::RUST, "crates/missing")]
469 );
470 assert_eq!(
471 plan.renamed,
472 [PackageRename {
473 from: PackageId::new("old-name"),
474 to: PackageId::new("new-name"),
475 ecosystem: EcosystemId::PYTHON,
476 path: Utf8PathBuf::from("python/pkg"),
477 }]
478 );
479 assert_eq!(
480 plan.moved,
481 [PackageMove {
482 package: PackageId::new("moved"),
483 ecosystem: EcosystemId::NODE,
484 from: Utf8PathBuf::from("packages/old"),
485 to: Utf8PathBuf::from("packages/new"),
486 }]
487 );
488 assert!(plan.conflicts.is_empty());
489 assert!(plan.has_drift());
490 }
491
492 #[test]
493 fn reports_resolver_changes_without_adding_or_removing_the_package() {
494 let current = configured("app", EcosystemId::RUST, "app");
495 let found = discovered("app", EcosystemId::NODE, "app");
496
497 let plan = plan(std::slice::from_ref(¤t), std::slice::from_ref(&found));
498
499 assert_eq!(
500 plan.conflicts,
501 [ConfigConflict::ResolverChanged {
502 configured: current,
503 discovered: found,
504 }]
505 );
506 assert!(plan.added.is_empty());
507 assert!(plan.missing.is_empty());
508 }
509
510 #[test]
511 fn reports_ambiguous_path_matches_only_as_conflicts() {
512 let plan = plan(
513 &[
514 configured("first", EcosystemId::RUST, "crates/shared"),
515 configured("second", EcosystemId::RUST, "crates/shared"),
516 ],
517 &[discovered("found", EcosystemId::RUST, "crates/shared")],
518 );
519
520 assert_eq!(
521 plan.conflicts,
522 [ConfigConflict::AmbiguousMatch {
523 configured: vec![
524 configured("first", EcosystemId::RUST, "crates/shared"),
525 configured("second", EcosystemId::RUST, "crates/shared"),
526 ],
527 discovered: vec![discovered("found", EcosystemId::RUST, "crates/shared")],
528 }]
529 );
530 assert!(plan.added.is_empty());
531 assert!(plan.missing.is_empty());
532 }
533
534 #[test]
535 fn rejects_duplicate_discovered_package_ids() {
536 let first = discovered("shared", EcosystemId::RUST, "crates/shared");
537 let second = discovered("shared", EcosystemId::NODE, "packages/shared");
538
539 let plan = plan(&[], &[second.clone(), first.clone()]);
540
541 assert_eq!(
542 plan.conflicts,
543 [ConfigConflict::AmbiguousMatch {
544 configured: vec![],
545 discovered: vec![first, second],
546 }]
547 );
548 assert!(plan.added.is_empty());
549 }
550
551 #[test]
552 fn preserves_configured_ids_for_cross_ecosystem_manifest_name_collisions() {
553 let plan = plan(
554 &[
555 configured("rust-shared", EcosystemId::RUST, "crates/shared"),
556 configured("node-shared", EcosystemId::NODE, "packages/shared"),
557 ],
558 &[
559 discovered("shared", EcosystemId::NODE, "packages/shared"),
560 discovered("shared", EcosystemId::RUST, "crates/shared"),
561 ],
562 );
563
564 assert!(!plan.has_drift());
565 assert!(plan.renamed.is_empty());
566 assert!(plan.conflicts.is_empty());
567 }
568
569 #[test]
570 fn rejects_an_unconfigured_collision_with_an_already_bound_package_id() {
571 let rust = configured("shared", EcosystemId::RUST, "crates/shared");
572 let node = discovered("shared", EcosystemId::NODE, "packages/shared");
573 let plan = plan(
574 std::slice::from_ref(&rust),
575 &[
576 discovered("shared", EcosystemId::RUST, "crates/shared"),
577 node.clone(),
578 ],
579 );
580
581 assert_eq!(
582 plan.conflicts,
583 [ConfigConflict::AmbiguousMatch {
584 configured: vec![rust],
585 discovered: vec![node],
586 }]
587 );
588 assert!(plan.added.is_empty());
589 assert!(plan.renamed.is_empty());
590 }
591
592 #[test]
593 fn rejects_same_ecosystem_manifest_name_collisions_even_when_ids_are_configured() {
594 let first_configured = configured("first", EcosystemId::RUST, "crates/first");
595 let second_configured = configured("second", EcosystemId::RUST, "crates/second");
596 let first_discovered = discovered("shared", EcosystemId::RUST, "crates/first");
597 let second_discovered = discovered("shared", EcosystemId::RUST, "crates/second");
598
599 let plan = plan(
600 &[first_configured.clone(), second_configured.clone()],
601 &[second_discovered.clone(), first_discovered.clone()],
602 );
603
604 assert_eq!(
605 plan.conflicts,
606 [ConfigConflict::AmbiguousMatch {
607 configured: vec![first_configured, second_configured],
608 discovered: vec![first_discovered, second_discovered],
609 }]
610 );
611 assert!(plan.added.is_empty());
612 assert!(plan.missing.is_empty());
613 }
614
615 #[test]
616 fn produces_the_same_plan_for_any_input_order() {
617 let mut configured = vec![
618 configured("missing", EcosystemId::RUST, "crates/missing"),
619 configured("moved", EcosystemId::RUST, "crates/old"),
620 ];
621 let mut discovered = vec![
622 discovered("added", EcosystemId::RUST, "crates/added"),
623 discovered("moved", EcosystemId::RUST, "crates/new"),
624 ];
625 let first = plan(&configured, &discovered);
626 configured.reverse();
627 discovered.reverse();
628
629 assert_eq!(first, plan(&configured, &discovered));
630 }
631
632 #[test]
633 fn reports_no_drift_for_identical_snapshots() {
634 let current = configured("app", EcosystemId::RUST, "crates/app");
635 let found = discovered("app", EcosystemId::RUST, "crates/app");
636
637 assert!(!plan(&[current], &[found]).has_drift());
638 }
639
640 #[test]
641 fn warns_when_pending_changesets_reference_a_renamed_package() {
642 let changesets = [ChangesetReference {
643 changeset: ChangesetId::new("pending"),
644 packages: BTreeSet::from([PackageId::new("old-name")]),
645 }];
646
647 let plan = ConfigSyncPlanner::plan(
648 Utf8PathBuf::from(".changes/config.toml"),
649 &[configured("old-name", EcosystemId::RUST, "crates/app")],
650 &[discovered("new-name", EcosystemId::RUST, "crates/app")],
651 &changesets,
652 );
653
654 assert_eq!(
655 plan.warnings,
656 [ConfigSyncWarning::ChangesetReferencesRenamedPackage {
657 changeset: ChangesetId::new("pending"),
658 from: PackageId::new("old-name"),
659 to: PackageId::new("new-name"),
660 }]
661 );
662 }
663}