Skip to main content

callisto_graph/
aggregate.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use callisto_changelog::{ChangeSource, ChangelogEntry, ChangelogInput};
6use callisto_format::{parse_changeset, Changeset};
7use callisto_model::{BumpReason, CommandRunner, Diagnostic, PackageId, Severity, Version};
8
9use crate::config::GroupTable;
10use crate::config::{PreMajorInferencePolicy, ResolvedConfig};
11use crate::error::GraphError;
12use crate::infer::SeverityInference;
13use crate::resolver::DependencyResolver;
14use crate::tags::TagIndex;
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct LoadedChangeset {
18    pub path: PathBuf,
19    pub id: String,
20    pub changeset: Changeset,
21}
22
23#[derive(Clone, Debug, Default)]
24pub struct Aggregation {
25    pub severities: BTreeMap<PackageId, Severity>,
26    pub reasons: BTreeMap<PackageId, BumpReason>,
27    pub named_by: BTreeMap<PackageId, NamedBy>,
28    pub consumed: Vec<PathBuf>,
29    pub changelog_inputs: BTreeMap<PackageId, ChangelogInput>,
30    pub diagnostics: Vec<Diagnostic>,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum NamedBy {
35    Changeset,
36    Inference,
37}
38
39pub fn load_changesets(
40    root: &Path,
41    cfg: &ResolvedConfig,
42) -> Result<Vec<LoadedChangeset>, GraphError> {
43    let dir = root.join(&cfg.changesets_dir);
44    if !dir.exists() {
45        return Ok(Vec::new());
46    }
47
48    let entries = fs::read_dir(&dir).map_err(|e| callisto_model::ManifestError::Read {
49        path: dir.clone(),
50        message: e.to_string(),
51    })?;
52
53    let mut files = Vec::new();
54    for entry in entries.flatten() {
55        let path = entry.path();
56        if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
57            if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
58                if file_name != "README.md" && file_name != "config.json" && file_name != "pre.json"
59                {
60                    files.push(path);
61                }
62            }
63        }
64    }
65
66    files.sort();
67
68    let mut loaded = Vec::new();
69    for path in files {
70        let content =
71            fs::read_to_string(&path).map_err(|e| callisto_model::ManifestError::Read {
72                path: path.clone(),
73                message: e.to_string(),
74            })?;
75        let changeset = parse_changeset(&content)?;
76        let stem = path
77            .file_stem()
78            .and_then(|s| s.to_str())
79            .unwrap_or("")
80            .to_string();
81        let rel_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
82        loaded.push(LoadedChangeset {
83            path: rel_path,
84            id: stem,
85            changeset,
86        });
87    }
88
89    Ok(loaded)
90}
91
92pub fn apply_pre_major(
93    inferred: Severity,
94    policy: PreMajorInferencePolicy,
95    current: &Version,
96    has_prior_release: bool,
97) -> (Severity, bool) {
98    if policy == PreMajorInferencePolicy::OFF {
99        return (inferred, false);
100    }
101    if current.major() != Some(0) || current.minor() == Some(0) || !has_prior_release {
102        return (inferred, false);
103    }
104
105    match (policy, inferred) {
106        (p, Severity::Major) if p.breaking_to_minor => (Severity::Minor, true),
107        (p, Severity::Minor) if p.feat_to_patch => (Severity::Patch, true),
108        (_, s) => (s, false),
109    }
110}
111
112pub fn aggregate<D, R, I>(
113    graph: &D,
114    config: &ResolvedConfig,
115    _runner: &R,
116    tags: &TagIndex,
117    base_versions: &BTreeMap<PackageId, Version>,
118    _pre: Option<&callisto_format::PreState>,
119    inference: &I,
120) -> Result<Aggregation, GraphError>
121where
122    D: DependencyResolver,
123    R: CommandRunner,
124    I: SeverityInference,
125{
126    let loaded = load_changesets(&config.root, config)?;
127    let mut agg = Aggregation::default();
128
129    for pkg in graph.packages() {
130        let cur_sev = agg
131            .severities
132            .get(&pkg.id)
133            .copied()
134            .unwrap_or(Severity::None);
135        let pathspecs: Vec<PathBuf> = pkg.manifests.iter().map(|m| m.path.clone()).collect();
136        let last_tag = tags.last_tag(&pkg.id);
137        let cur_ver = last_tag
138            .map(|t| t.version.clone())
139            .or_else(|| base_versions.get(&pkg.id).cloned())
140            .ok_or_else(|| {
141                GraphError::Manifest(callisto_model::ManifestError::MissingField {
142                    path: pkg
143                        .manifests
144                        .first()
145                        .map(|m| m.path.clone())
146                        .unwrap_or_default(),
147                    field: "version",
148                })
149            })?;
150
151        let window = crate::infer::InferenceWindowSpec {
152            pathspecs: &pathspecs,
153            since: None,
154            current_version: &cur_ver,
155            has_prior_release: last_tag.is_some(),
156            policy: PreMajorInferencePolicy::OFF,
157        };
158
159        if let Ok(Some(outcome)) = inference.infer(pkg, window) {
160            if outcome.severity > cur_sev {
161                agg.severities.insert(pkg.id.clone(), outcome.severity);
162                agg.reasons.insert(
163                    pkg.id.clone(),
164                    BumpReason::Inference {
165                        commits: outcome.commit_count,
166                        remapped: outcome.remapped,
167                    },
168                );
169                agg.named_by.insert(pkg.id.clone(), NamedBy::Inference);
170            }
171        }
172    }
173
174    for cs in loaded {
175        agg.consumed.push(cs.path.clone());
176        for entry in cs.changeset.entries {
177            if let Ok(id) = PackageId::parse(&entry.name) {
178                if let Some(target_pkg) = graph.packages().find(|p| p.id.matches(&id)) {
179                    let canonical_id = target_pkg.id.clone();
180                    let cur_sev = agg
181                        .severities
182                        .get(&canonical_id)
183                        .copied()
184                        .unwrap_or(Severity::None);
185                    if entry.severity > cur_sev {
186                        agg.severities.insert(canonical_id.clone(), entry.severity);
187                        agg.reasons.insert(
188                            canonical_id.clone(),
189                            BumpReason::Changeset {
190                                changesets: vec![cs.id.clone()],
191                            },
192                        );
193                        agg.named_by
194                            .insert(canonical_id.clone(), NamedBy::Changeset);
195                    }
196
197                    if entry.severity != Severity::None {
198                        let pkg_ver = tags
199                            .last_tag(&canonical_id)
200                            .map(|t| t.version.clone())
201                            .or_else(|| base_versions.get(&canonical_id).cloned())
202                            .unwrap_or_else(|| Version::semver(0, 0, 0));
203                        let cl_input = agg
204                            .changelog_inputs
205                            .entry(canonical_id.clone())
206                            .or_insert_with(|| ChangelogInput {
207                                package: canonical_id.clone(),
208                                from: pkg_ver,
209                                to: None,
210                                entries: Vec::new(),
211                            });
212                        cl_input.entries.push(ChangelogEntry {
213                            severity: entry.severity,
214                            source: ChangeSource::Changeset {
215                                filename: cs.id.clone(),
216                                summary: cs.changeset.summary.clone(),
217                            },
218                        });
219                    }
220                }
221            }
222        }
223    }
224
225    loop {
226        let mut changed = false;
227        if union_fixed(&mut agg, &config.groups) {
228            changed = true;
229        }
230        if union_linked(&mut agg, &config.groups) {
231            changed = true;
232        }
233        if !changed {
234            break;
235        }
236    }
237
238    Ok(agg)
239}
240
241pub(crate) fn union_fixed(agg: &mut Aggregation, groups: &GroupTable) -> bool {
242    let mut changed = false;
243    for g in groups.fixed.values() {
244        let pkg_members: Vec<PackageId> = g
245            .members(crate::config::GroupMemberKind::Package)
246            .filter_map(|m| match m {
247                crate::config::GroupMember::Package(ref id) => Some(id.clone()),
248                _ => None,
249            })
250            .collect();
251
252        let mut target = Severity::None;
253        for m in &pkg_members {
254            if let Some(&s) = agg.severities.get(m) {
255                if s > target {
256                    target = s;
257                }
258            }
259        }
260
261        if target == Severity::None {
262            continue;
263        }
264
265        for m in pkg_members {
266            let cur = agg.severities.get(&m).copied().unwrap_or(Severity::None);
267            if target > cur {
268                agg.severities.insert(m.clone(), target);
269                agg.reasons.insert(
270                    m.clone(),
271                    BumpReason::FixedGroupUnion {
272                        group: g.name.clone(),
273                    },
274                );
275                changed = true;
276            }
277        }
278    }
279    changed
280}
281
282pub(crate) fn union_linked(agg: &mut Aggregation, groups: &GroupTable) -> bool {
283    let mut changed = false;
284    for g in groups.linked.values() {
285        let named: Vec<PackageId> = g
286            .members(crate::config::GroupMemberKind::Package)
287            .filter_map(|m| match m {
288                crate::config::GroupMember::Package(ref id) => {
289                    if agg.named_by.contains_key(id) {
290                        Some(id.clone())
291                    } else {
292                        None
293                    }
294                }
295                _ => None,
296            })
297            .collect();
298
299        if named.is_empty() {
300            continue;
301        }
302
303        let mut target_sev = Severity::None;
304        for m in &named {
305            if let Some(&s) = agg.severities.get(m) {
306                if s > target_sev {
307                    target_sev = s;
308                }
309            }
310        }
311
312        let all_members: Vec<PackageId> = g
313            .members(crate::config::GroupMemberKind::Package)
314            .filter_map(|m| match m {
315                crate::config::GroupMember::Package(ref id) => Some(id.clone()),
316                _ => None,
317            })
318            .collect();
319
320        for m in all_members {
321            let cur = agg.severities.get(&m).copied().unwrap_or(Severity::None);
322            if target_sev > cur {
323                agg.severities.insert(m.clone(), target_sev);
324                agg.reasons.insert(
325                    m.clone(),
326                    BumpReason::LinkedGroupUnion {
327                        group: g.name.clone(),
328                    },
329                );
330                changed = true;
331            }
332        }
333    }
334    changed
335}