Skip to main content

semifold_core/
config_sync.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use camino::Utf8PathBuf;
4use serde::Serialize;
5
6use crate::{ChangesetId, EcosystemId, PackageId};
7
8/// One package table read from the current Semifold configuration.
9#[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/// One package returned by a complete ecosystem discovery pass.
17#[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/// Deterministic, side-effect-free description of configuration drift.
70#[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
93/// Matches normalized configuration and discovery snapshots without I/O.
94pub 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(&current), 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}