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}