Skip to main content

callisto_graph/
groups.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use crate::config::groups::{GroupDef, GroupMember, GroupMemberKind, GroupTable};
5use crate::error::GraphError;
6use crate::napi::{napi_drift, NapiTargetsIndex};
7use crate::resolver::DependencyResolver;
8use crate::tags::TagIndex;
9use callisto_format::Versioning;
10use callisto_model::{Diagnostic, GroupName, PackageId, Severity, Version};
11
12#[derive(Clone, Debug, Default)]
13pub struct GroupCheckOutcome {
14    pub new_members: BTreeMap<GroupName, Vec<PackageId>>,
15    pub diagnostics: Vec<Diagnostic>,
16}
17
18pub fn pre_mutation_checks<D: DependencyResolver>(
19    _graph: &D,
20    groups: &GroupTable,
21    base: &BTreeMap<PackageId, Version>,
22    tags: &TagIndex,
23    napi: &NapiTargetsIndex,
24    root: &Path,
25) -> Result<GroupCheckOutcome, GraphError> {
26    let mut outcome = GroupCheckOutcome::default();
27
28    for g in groups.fixed.values() {
29        let released: Vec<PackageId> = g
30            .members(GroupMemberKind::Package)
31            .filter_map(|m| match m {
32                GroupMember::Package(ref id) => {
33                    if tags.last_tag(id).is_some() {
34                        Some(id.clone())
35                    } else {
36                        None
37                    }
38                }
39                _ => None,
40            })
41            .collect();
42
43        let fresh: Vec<PackageId> = g
44            .members(GroupMemberKind::Package)
45            .filter_map(|m| match m {
46                GroupMember::Package(ref id) => {
47                    if tags.last_tag(id).is_none() {
48                        Some(id.clone())
49                    } else {
50                        None
51                    }
52                }
53                _ => None,
54            })
55            .collect();
56
57        let pairs: Vec<(PackageId, Version)> = released
58            .iter()
59            .filter_map(|id| base.get(id).map(|v| (id.clone(), v.clone())))
60            .collect();
61
62        if pairs.len() > 1 {
63            let first_v = &pairs[0].1;
64            let mut divergent = false;
65            for (_id, v) in &pairs[1..] {
66                if v.grammar() != first_v.grammar() {
67                    return Err(GraphError::GroupGrammarMismatch {
68                        group: g.name.clone(),
69                        members: pairs,
70                    });
71                }
72                if Version::compare(v, first_v).ok() != Some(std::cmp::Ordering::Equal) {
73                    divergent = true;
74                }
75            }
76            if divergent {
77                return Err(GraphError::FixedGroupDivergent {
78                    group: g.name.clone(),
79                    members: pairs,
80                });
81            }
82        }
83
84        if !fresh.is_empty() {
85            outcome.new_members.insert(g.name.clone(), fresh);
86        }
87
88        // napi.targets drift cross-check (§G.8.4).
89        if let Some(declared) = napi.declared_for(&g.name) {
90            outcome.diagnostics.extend(napi_drift(g, declared, root));
91        }
92    }
93
94    Ok(outcome)
95}
96
97pub fn fixed_group_target(
98    g: &GroupDef,
99    base: &BTreeMap<PackageId, Version>,
100    severities: &BTreeMap<PackageId, Severity>,
101    tags: &TagIndex,
102    _pre: Option<&callisto_format::PreState>,
103) -> Result<Version, GraphError> {
104    let released: Vec<PackageId> = g
105        .members(GroupMemberKind::Package)
106        .filter_map(|m| match m {
107            GroupMember::Package(ref id) => {
108                if tags.last_tag(id).is_some() {
109                    Some(id.clone())
110                } else {
111                    None
112                }
113            }
114            _ => None,
115        })
116        .collect();
117
118    let mut max_sev = Severity::None;
119    for m in g.members(GroupMemberKind::Package) {
120        if let GroupMember::Package(ref id) = m {
121            if let Some(&s) = severities.get(id) {
122                if s > max_sev {
123                    max_sev = s;
124                }
125            }
126        }
127    }
128
129    let aligned_base = if !released.is_empty() {
130        base.get(&released[0])
131            .cloned()
132            .unwrap_or_else(|| Version::semver(1, 0, 0))
133    } else {
134        Version::semver(0, 0, 0)
135    };
136
137    let versioning = callisto_format::SemVerVersioning;
138
139    versioning.bump(&aligned_base, max_sev).map_err(GraphError::Bump)
140}
141
142#[cfg(test)]
143mod tests {
144    use std::collections::BTreeMap;
145    use std::path::PathBuf;
146
147    use callisto_model::{GroupKind, GroupName, ManifestRole, PackageId, Version};
148
149    use super::*;
150    use crate::config::groups::{GroupDef, GroupMember, GroupTable};
151    use crate::napi::NapiTargetsIndex;
152    use crate::tags::TagIndex;
153
154    struct EmptyResolver;
155    impl crate::resolver::DependencyResolver for EmptyResolver {
156        fn packages(&self) -> impl Iterator<Item = &callisto_model::Package> {
157            std::iter::empty()
158        }
159        fn dependencies_of(&self, _id: &PackageId) -> impl Iterator<Item = &callisto_model::DepEdge> {
160            std::iter::empty()
161        }
162        fn dependents_of(&self, _id: &PackageId) -> impl Iterator<Item = &callisto_model::DepEdge> {
163            std::iter::empty()
164        }
165        fn diagnostics(&self) -> &[callisto_model::Diagnostic] {
166            &[]
167        }
168    }
169
170    #[test]
171    fn pre_mutation_checks_calls_napi_drift_for_napi_groups() {
172        let tmp = tempfile::tempdir().expect("tempdir");
173        let root = tmp.path();
174
175        let group_name = GroupName("my-lib".to_string());
176        let pkg_id = PackageId::Bare("my-lib".to_string());
177
178        // One platform member with a known triple (darwin/arm64 → aarch64-apple-darwin).
179        // We do NOT create a platform manifest on disk, so the only diagnostic emitted
180        // should be NapiTargetAddedNotInMembers (declared triple not in members).
181        let platform_role = ManifestRole::Platform {
182            platform: "linux".to_string(),
183            arch: "x64".to_string(),
184            abi: Some("gnu".to_string()),
185        };
186
187        let group = GroupDef {
188            name: group_name.clone(),
189            kind: GroupKind::Fixed,
190            members: vec![
191                GroupMember::Package(pkg_id.clone()),
192                GroupMember::PlatformManifest {
193                    owner: pkg_id.clone(),
194                    role: platform_role,
195                    path: PathBuf::from("platform/linux-x64-gnu/package.json"),
196                    name: "my-lib.linux-x64-gnu".to_string(),
197                },
198            ],
199        };
200
201        let groups = GroupTable::from_groups(vec![group], vec![]);
202
203        // Build the NapiTargetsIndex from a real package.json file —
204        let pkg_dir = root.join("my-lib");
205        std::fs::create_dir_all(&pkg_dir).unwrap();
206        std::fs::write(
207            pkg_dir.join("package.json"),
208            r#"{"name":"my-lib","napi":{"targets":["aarch64-apple-darwin"]}}"#,
209        )
210        .unwrap();
211
212        let napi = NapiTargetsIndex::load(&groups, root).expect("load");
213
214        let base: BTreeMap<PackageId, Version> = BTreeMap::new();
215        let tags = TagIndex::empty();
216        let resolver = EmptyResolver;
217
218        let outcome = pre_mutation_checks(&resolver, &groups, &base, &tags, &napi, root).expect("pre_mutation_checks");
219
220        assert!(
221            !outcome.diagnostics.is_empty(),
222            "expected at least one napi_drift diagnostic"
223        );
224    }
225}