use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use callisto_changelog::{ChangeSource, ChangelogEntry, ChangelogInput};
use callisto_format::{parse_changeset, Changeset};
use callisto_model::{BumpReason, CommitSha, Diagnostic, Package, PackageId, Severity, Version};
use callisto_vcs::{GitAccess, GitDataSource};
use crate::config::resolve::resolve_package_config;
use crate::config::GroupTable;
use crate::config::{PreMajorInferencePolicy, ResolvedConfig};
use crate::error::GraphError;
use crate::infer::SeverityInference;
use crate::resolver::DependencyResolver;
use crate::tags::TagIndex;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LoadedChangeset {
pub path: PathBuf,
pub id: String,
pub changeset: Changeset,
}
#[derive(Clone, Debug, Default)]
pub struct Aggregation {
pub severities: BTreeMap<PackageId, Severity>,
pub reasons: BTreeMap<PackageId, BumpReason>,
pub named_by: BTreeMap<PackageId, NamedBy>,
pub consumed: Vec<PathBuf>,
pub changelog_inputs: BTreeMap<PackageId, ChangelogInput>,
pub inference_commits: BTreeMap<PackageId, Vec<(CommitSha, String)>>,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NamedBy {
Changeset,
Inference,
}
pub fn load_changesets(root: &Path, cfg: &ResolvedConfig) -> Result<Vec<LoadedChangeset>, GraphError> {
let dir = root.join(&cfg.changesets_dir);
if !dir.exists() {
return Ok(Vec::new());
}
let entries = fs::read_dir(&dir).map_err(|e| callisto_model::ManifestError::Read {
path: dir.clone(),
message: e.to_string(),
})?;
let mut files = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
if file_name != "README.md" && file_name != "config.json" && file_name != "pre.json" {
files.push(path);
}
}
}
}
files.sort();
let mut loaded = Vec::new();
for path in files {
let content = fs::read_to_string(&path).map_err(|e| callisto_model::ManifestError::Read {
path: path.clone(),
message: e.to_string(),
})?;
let changeset = parse_changeset(&content).map_err(|e| GraphError::ParseChangeset {
path: path.clone(),
source: e,
})?;
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
let rel_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
loaded.push(LoadedChangeset {
path: rel_path,
id: stem,
changeset,
});
}
Ok(loaded)
}
pub fn apply_pre_major(
inferred: Severity,
policy: PreMajorInferencePolicy,
current: &Version,
has_prior_release: bool,
) -> (Severity, bool) {
if policy == PreMajorInferencePolicy::Off {
return (inferred, false);
}
if current.major() != Some(0) || current.minor() == Some(0) || !has_prior_release {
return (inferred, false);
}
match (policy, inferred) {
(PreMajorInferencePolicy::Conservative | PreMajorInferencePolicy::ConservativeFeat, Severity::Major) => {
(Severity::Minor, true)
}
(PreMajorInferencePolicy::ConservativeFeat, Severity::Minor) => (Severity::Patch, true),
(_, s) => (s, false),
}
}
fn resolve_since(git: &impl GitDataSource, tag_name: &str) -> Option<CommitSha> {
git.resolve_commit(tag_name).ok().flatten()
}
pub(crate) fn resolve_target_package<'a>(
packages: impl Iterator<Item = &'a Package>,
id: &PackageId,
) -> Result<Option<&'a Package>, GraphError> {
id.resolve_unique(packages, |p| &p.id)
.map_err(|candidates| GraphError::AmbiguousName {
name: id.display_name(),
candidates: candidates.iter().map(|p| p.id.clone()).collect(),
})
}
pub fn aggregate<D, I>(
graph: &D,
config: &ResolvedConfig,
git: &GitAccess<'_>,
tags: &TagIndex,
base_versions: &BTreeMap<PackageId, Version>,
pre: Option<&callisto_format::PreState>,
inference: &I,
) -> Result<Aggregation, GraphError>
where
D: DependencyResolver,
I: SeverityInference,
{
let loaded = load_changesets(&config.root, config)?;
let mut agg = Aggregation::default();
for pkg in graph.packages() {
let cur_sev = agg.severities.get(&pkg.id).copied().unwrap_or(Severity::None);
let pathspecs: Vec<PathBuf> = pkg.manifests.iter().map(|m| m.path.clone()).collect();
let last_tag = tags.last_tag(&pkg.id);
let cur_ver = last_tag
.map(|t| t.version.clone())
.or_else(|| base_versions.get(&pkg.id).cloned())
.ok_or_else(|| {
GraphError::Manifest(callisto_model::ManifestError::MissingField {
path: pkg.manifests.first().map(|m| m.path.clone()).unwrap_or_default(),
field: "version",
})
})?;
let since = last_tag.and_then(|t| resolve_since(git, t.name.as_str()));
let policy = resolve_package_config(&pkg.id, config)?
.and_then(|pcfg| pcfg.pre_major_inference)
.unwrap_or(PreMajorInferencePolicy::Off);
let window = crate::infer::InferenceWindowSpec {
pathspecs: &pathspecs,
since,
current_version: &cur_ver,
has_prior_release: last_tag.is_some(),
policy,
};
match inference.infer(pkg, git, window) {
Ok(Some(outcome)) => {
if outcome.severity > cur_sev {
agg.severities.insert(pkg.id.clone(), outcome.severity);
agg.reasons.insert(
pkg.id.clone(),
BumpReason::Inference {
commits: outcome.commit_count,
remapped: outcome.remapped,
},
);
agg.named_by.insert(pkg.id.clone(), NamedBy::Inference);
agg.inference_commits.insert(pkg.id.clone(), outcome.commits.clone());
}
}
Ok(None) => {}
Err(e) => {
agg.diagnostics.push(Diagnostic {
code: callisto_model::DiagnosticCode::PreMajorInferenceInert,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!("Commit inference failed for package `{}`: {e}", pkg.id.display_name()),
package: Some(pkg.id.clone()),
path: None,
governed_by: None,
escalated_by: None,
});
}
}
}
let is_pre_mode = pre.map(|s| s.mode == callisto_format::PreMode::Pre).unwrap_or(false);
for cs in loaded {
let mut matched_any = false;
for entry in cs.changeset.entries {
let id = match PackageId::parse(&entry.name) {
Ok(id) => id,
Err(_) => {
agg.diagnostics.push(Diagnostic {
code: callisto_model::DiagnosticCode::UnknownPackage,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"Changeset `{}` contains invalid package name `{}`",
cs.path.display(),
entry.name
),
package: None,
path: Some(cs.path.clone()),
governed_by: None,
escalated_by: None,
});
continue;
}
};
match resolve_target_package(graph.packages(), &id)? {
Some(target_pkg) => {
matched_any = true;
let canonical_id = target_pkg.id.clone();
let cur_sev = agg.severities.get(&canonical_id).copied().unwrap_or(Severity::None);
if entry.severity > cur_sev {
agg.severities.insert(canonical_id.clone(), entry.severity);
agg.reasons.insert(
canonical_id.clone(),
BumpReason::Changeset {
changesets: vec![cs.id.clone()],
},
);
agg.named_by.insert(canonical_id.clone(), NamedBy::Changeset);
}
if entry.severity != Severity::None {
let pkg_ver = if is_pre_mode {
pre.and_then(|s| s.initial_versions.get(&canonical_id.display_name()))
.cloned()
.or_else(|| base_versions.get(&canonical_id).cloned())
.unwrap_or_else(|| Version::semver(0, 0, 0))
} else {
tags.last_tag(&canonical_id)
.map(|t| t.version.clone())
.or_else(|| base_versions.get(&canonical_id).cloned())
.unwrap_or_else(|| Version::semver(0, 0, 0))
};
let cl_input =
agg.changelog_inputs
.entry(canonical_id.clone())
.or_insert_with(|| ChangelogInput {
package: canonical_id.clone(),
from: pkg_ver,
to: None,
entries: Vec::new(),
});
cl_input.entries.push(ChangelogEntry {
severity: entry.severity,
source: ChangeSource::Changeset {
filename: cs.id.clone(),
summary: cs.changeset.summary.clone(),
},
});
}
}
None => {
agg.diagnostics.push(Diagnostic {
code: callisto_model::DiagnosticCode::UnknownPackage,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"Changeset `{}` references package `{}` which is not in the \
workspace; the changeset will not be consumed until this entry \
is resolved",
cs.path.display(),
entry.name
),
package: None,
path: Some(cs.path.clone()),
governed_by: None,
escalated_by: None,
});
}
}
}
if matched_any && !is_pre_mode {
agg.consumed.push(cs.path.clone());
}
}
loop {
let mut changed = false;
if union_fixed(&mut agg, &config.groups, base_versions) {
changed = true;
}
if union_linked(&mut agg, &config.groups, base_versions) {
changed = true;
}
if !changed {
break;
}
}
Ok(agg)
}
pub(crate) fn union_fixed(
agg: &mut Aggregation,
groups: &GroupTable,
base_versions: &BTreeMap<PackageId, Version>,
) -> bool {
let mut changed = false;
for g in groups.fixed.values() {
let pkg_members: Vec<PackageId> = g
.members(crate::config::GroupMemberKind::Package)
.filter_map(|m| match m {
crate::config::GroupMember::Package(ref id) => Some(id.clone()),
_ => None,
})
.collect();
let mut target = Severity::None;
for m in &pkg_members {
if let Some(&s) = agg.severities.get(m) {
if s > target {
target = s;
}
}
}
if target == Severity::None {
continue;
}
for m in pkg_members {
let cur = agg.severities.get(&m).copied().unwrap_or(Severity::None);
if target > cur {
if !base_versions.contains_key(&m) {
agg.diagnostics.push(Diagnostic {
code: callisto_model::DiagnosticCode::UnknownPackage,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"Fixed group `{}` references package `{}` which is not in the \
workspace; the stale group member is skipped. Remove it from \
callisto.toml to silence this warning.",
g.name,
m.display_name()
),
package: Some(m.clone()),
path: None,
governed_by: Some(callisto_model::ConfigKey::FIXED_GROUP),
escalated_by: None,
});
continue;
}
agg.severities.insert(m.clone(), target);
agg.reasons
.insert(m.clone(), BumpReason::FixedGroupUnion { group: g.name.clone() });
changed = true;
}
}
}
changed
}
pub(crate) fn union_linked(
agg: &mut Aggregation,
groups: &GroupTable,
base_versions: &BTreeMap<PackageId, Version>,
) -> bool {
let mut changed = false;
for g in groups.linked.values() {
let named: Vec<PackageId> = g
.members(crate::config::GroupMemberKind::Package)
.filter_map(|m| match m {
crate::config::GroupMember::Package(ref id) => {
if agg.named_by.contains_key(id) {
Some(id.clone())
} else {
None
}
}
_ => None,
})
.collect();
if named.is_empty() {
continue;
}
let mut target_sev = Severity::None;
for m in &named {
if let Some(&s) = agg.severities.get(m) {
if s > target_sev {
target_sev = s;
}
}
}
let all_members: Vec<PackageId> = g
.members(crate::config::GroupMemberKind::Package)
.filter_map(|m| match m {
crate::config::GroupMember::Package(ref id) => Some(id.clone()),
_ => None,
})
.collect();
for m in all_members {
let cur = agg.severities.get(&m).copied().unwrap_or(Severity::None);
if target_sev > cur {
if !base_versions.contains_key(&m) {
agg.diagnostics.push(Diagnostic {
code: callisto_model::DiagnosticCode::UnknownPackage,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"Linked group `{}` references package `{}` which is not in the \
workspace; the stale group member is skipped. Remove it from \
callisto.toml to silence this warning.",
g.name,
m.display_name()
),
package: Some(m.clone()),
path: None,
governed_by: Some(callisto_model::ConfigKey::LINKED_GROUP),
escalated_by: None,
});
continue;
}
agg.severities.insert(m.clone(), target_sev);
agg.reasons
.insert(m.clone(), BumpReason::LinkedGroupUnion { group: g.name.clone() });
changed = true;
}
}
}
changed
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use callisto_model::{
CommandError, CommandOutput, CommandRunner, DepEdge, GroupKind, GroupName, ManifestDecl, ManifestFormat,
ManifestRole, Package,
};
use crate::config::{GroupDef, GroupMember};
use crate::infer::{InferenceOutcome, InferenceWindowSpec, SeverityInference};
use callisto_fixtures::git::{init_repo, run_git, PoisonedRunner};
#[test]
fn apply_pre_major_off_never_downgrades() {
let v = Version::semver(0, 1, 0);
assert_eq!(
apply_pre_major(Severity::Major, PreMajorInferencePolicy::Off, &v, true),
(Severity::Major, false)
);
assert_eq!(
apply_pre_major(Severity::Minor, PreMajorInferencePolicy::Off, &v, true),
(Severity::Minor, false)
);
}
#[test]
fn apply_pre_major_conservative_downgrades_major_to_minor_only() {
let v = Version::semver(0, 1, 0);
assert_eq!(
apply_pre_major(Severity::Major, PreMajorInferencePolicy::Conservative, &v, true),
(Severity::Minor, true)
);
assert_eq!(
apply_pre_major(Severity::Minor, PreMajorInferencePolicy::Conservative, &v, true),
(Severity::Minor, false),
"Conservative must not also downgrade Minor->Patch"
);
}
#[test]
fn apply_pre_major_conservative_feat_downgrades_both_levels() {
let v = Version::semver(0, 1, 0);
assert_eq!(
apply_pre_major(Severity::Major, PreMajorInferencePolicy::ConservativeFeat, &v, true),
(Severity::Minor, true)
);
assert_eq!(
apply_pre_major(Severity::Minor, PreMajorInferencePolicy::ConservativeFeat, &v, true),
(Severity::Patch, true)
);
}
struct RealGitRunner;
impl CommandRunner for RealGitRunner {
fn run(&self, program: &str, args: &[&str], cwd: &Path) -> Result<CommandOutput, CommandError> {
let output = std::process::Command::new(program)
.args(args)
.current_dir(cwd)
.output()
.map_err(|e| CommandError::Io {
program: program.to_string(),
message: e.to_string(),
})?;
Ok(CommandOutput {
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
fn non_repo_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
assert!(
callisto_vcs::GitRepository::discover(dir.path()).is_err(),
"test fixture must not be discoverable as a Git repo"
);
dir
}
struct FakeRevParseRunner {
calls: AtomicUsize,
tag: String,
sha: CommitSha,
}
impl CommandRunner for FakeRevParseRunner {
fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> Result<CommandOutput, CommandError> {
assert_eq!(program, "git");
assert_eq!(
args,
[
"rev-parse",
"--verify",
"--quiet",
format!("{}^{{commit}}", self.tag).as_str()
]
);
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(CommandOutput {
exit_code: Some(0),
stdout: format!("{}\n", self.sha.as_str()),
stderr: String::new(),
})
}
}
#[test]
fn test_load_changesets_error_includes_filename() {
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
let cs_dir = root.join(".changeset");
std::fs::create_dir_all(&cs_dir).unwrap();
std::fs::write(cs_dir.join("malformed-changeset.md"), "cargo/foo: patch\n\nSummary.\n").unwrap();
let cfg = crate::config::load(root).unwrap();
let result = load_changesets(root, &cfg);
let err = result.expect_err("load_changesets must return Err for a malformed changeset file");
let err_display = format!("{err}");
assert!(
err_display.contains("malformed-changeset"),
"error message must contain the offending filename so the developer can triage; \
got: {err_display:?}"
);
}
#[test]
fn test_resolve_since_falls_back_to_command_runner_without_gix() {
let dir = non_repo_dir();
let sha = CommitSha::parse("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").unwrap();
let runner = FakeRevParseRunner {
calls: AtomicUsize::new(0),
tag: "pkg-a@1.0.0".to_string(),
sha: sha.clone(),
};
let git = GitAccess::discover(dir.path(), &runner);
let resolved = resolve_since(&git, "pkg-a@1.0.0");
assert_eq!(
resolved,
Some(sha),
"resolve_since must resolve the tag via the CommandRunner fallback when gix is \
unavailable, not silently return None"
);
assert_eq!(runner.calls.load(Ordering::SeqCst), 1);
}
struct SinglePackageGraph {
pkg: Package,
}
impl DependencyResolver for SinglePackageGraph {
fn packages(&self) -> impl Iterator<Item = &Package> {
std::iter::once(&self.pkg)
}
fn dependencies_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
std::iter::empty()
}
fn dependents_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
std::iter::empty()
}
}
#[derive(Default)]
struct RecordingInference {
captured_since: Mutex<Option<CommitSha>>,
}
impl SeverityInference for RecordingInference {
fn infer(
&self,
_pkg: &Package,
_git: &GitAccess<'_>,
window: InferenceWindowSpec<'_>,
) -> Result<Option<InferenceOutcome>, GraphError> {
*self.captured_since.lock().unwrap() = window.since.clone();
Ok(None)
}
}
#[test]
fn test_aggregate_scopes_inference_window_to_last_tag() {
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
init_repo(root);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "initial commit"]);
let pkg_id = PackageId::parse("pkg-a").unwrap();
let tag_name = format!("{}@1.0.0", pkg_id.display_name());
run_git(root, &["-c", "tag.gpgSign=false", "tag", "-m", "release", &tag_name]);
std::fs::write(root.join("CHANGES.md"), "more\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "feat: add changes file"]);
let expected_sha_output = std::process::Command::new("git")
.args(["rev-parse", "--verify", "--quiet", &format!("{tag_name}^{{commit}}")])
.current_dir(root)
.output()
.unwrap();
assert!(expected_sha_output.status.success());
let expected_sha = CommitSha::parse(String::from_utf8_lossy(&expected_sha_output.stdout).trim()).unwrap();
let runner = RealGitRunner;
let manifest = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
let graph = SinglePackageGraph {
pkg: Package {
id: pkg_id.clone(),
manifests: vec![manifest],
changelog: None,
release_trigger: callisto_model::ReleaseTrigger::Changeset,
publish_to: Vec::new(),
tag_template: None,
},
};
let git = GitAccess::discover(root, &runner);
let cfg = crate::config::load(root).unwrap();
let tags = TagIndex::build(&git, &graph, &cfg).unwrap();
assert_eq!(
tags.last_tag(&pkg_id).map(|t| t.version.render().to_string()),
Some("1.0.0".to_string())
);
let inference = RecordingInference::default();
let base_versions = BTreeMap::new();
aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &inference).unwrap();
let captured = inference.captured_since.lock().unwrap().clone();
assert_eq!(
captured,
Some(expected_sha),
"aggregate() must scope inference to last_tag..HEAD instead of hardcoding `since: None` \
(full history)"
);
}
struct FixedCommitsInference {
commits: Vec<(CommitSha, String)>,
}
impl SeverityInference for FixedCommitsInference {
fn infer(
&self,
_pkg: &Package,
_git: &GitAccess<'_>,
_window: InferenceWindowSpec<'_>,
) -> Result<Option<InferenceOutcome>, GraphError> {
Ok(Some(InferenceOutcome {
severity: Severity::Minor,
commit_count: self.commits.len(),
remapped: false,
commits: self.commits.clone(),
}))
}
}
#[test]
fn test_aggregate_retains_inference_commits_on_aggregation() {
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
init_repo(root);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "initial commit"]);
let pkg_id = PackageId::parse("pkg-a").unwrap();
let manifest = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
let graph = SinglePackageGraph {
pkg: Package {
id: pkg_id.clone(),
manifests: vec![manifest],
changelog: None,
release_trigger: callisto_model::ReleaseTrigger::Changeset,
publish_to: Vec::new(),
tag_template: None,
},
};
let runner = RealGitRunner;
let git = GitAccess::discover(root, &runner);
let cfg = crate::config::load(root).unwrap();
let tags = TagIndex::build(&git, &graph, &cfg).unwrap();
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_id.clone(), callisto_model::Version::semver(1, 0, 0));
let sha_recent = CommitSha::parse(&"a".repeat(40)).unwrap();
let inference = FixedCommitsInference {
commits: vec![(sha_recent.clone(), "feat: recent".to_string())],
};
let agg = aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &inference).unwrap();
assert_eq!(
agg.inference_commits.get(&pkg_id),
Some(&vec![(sha_recent, "feat: recent".to_string())]),
"Aggregation.inference_commits must retain InferenceOutcome.commits for the package"
);
}
#[test]
fn test_aggregate_resolves_since_without_shelling_through_runner() {
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
init_repo(root);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "initial commit"]);
let pkg_id = PackageId::parse("pkg-a").unwrap();
let tag_name = format!("{}@1.0.0", pkg_id.display_name());
run_git(root, &["-c", "tag.gpgSign=false", "tag", "-m", "release", &tag_name]);
std::fs::write(root.join("CHANGES.md"), "more\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "feat: add changes file"]);
let expected_sha_output = std::process::Command::new("git")
.args(["rev-parse", "--verify", "--quiet", &format!("{tag_name}^{{commit}}")])
.current_dir(root)
.output()
.unwrap();
assert!(expected_sha_output.status.success());
let expected_sha = CommitSha::parse(String::from_utf8_lossy(&expected_sha_output.stdout).trim()).unwrap();
let poisoned = PoisonedRunner;
let manifest = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
let graph = SinglePackageGraph {
pkg: Package {
id: pkg_id.clone(),
manifests: vec![manifest],
changelog: None,
release_trigger: callisto_model::ReleaseTrigger::Changeset,
publish_to: Vec::new(),
tag_template: None,
},
};
let git = GitAccess::discover(root, &poisoned);
let cfg = crate::config::load(root).unwrap();
let tags = TagIndex::build(&git, &graph, &cfg).unwrap();
assert_eq!(
tags.last_tag(&pkg_id).map(|t| t.version.render().to_string()),
Some("1.0.0".to_string())
);
let inference = RecordingInference::default();
let base_versions = BTreeMap::new();
aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &inference).unwrap();
let captured = inference.captured_since.lock().unwrap().clone();
assert_eq!(
captured,
Some(expected_sha),
"aggregate() must resolve `since` via callisto_vcs::GitRepository (gix), not by \
shelling out through the CommandRunner"
);
}
fn make_pkg(id: PackageId) -> Package {
let manifest = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
Package {
id,
manifests: vec![manifest],
changelog: None,
release_trigger: callisto_model::ReleaseTrigger::Changeset,
publish_to: Vec::new(),
tag_template: None,
}
}
#[test]
fn test_resolve_target_package_ambiguous_bare_name_errors() {
let pkg_cargo = make_pkg(PackageId::parse("cargo/foo").unwrap());
let pkg_npm = make_pkg(PackageId::parse("npm/foo").unwrap());
let packages = [pkg_cargo, pkg_npm];
let bare = PackageId::parse("foo").unwrap();
let result = resolve_target_package(packages.iter(), &bare);
match result {
Err(GraphError::AmbiguousName { name, candidates }) => {
assert_eq!(name, "foo");
assert_eq!(candidates.len(), 2);
assert!(candidates.contains(&PackageId::parse("cargo/foo").unwrap()));
assert!(candidates.contains(&PackageId::parse("npm/foo").unwrap()));
}
other => panic!("expected GraphError::AmbiguousName, got {other:?}"),
}
}
#[test]
fn test_resolve_target_package_unambiguous_bare_name_resolves() {
let pkg_cargo = make_pkg(PackageId::parse("cargo/foo").unwrap());
let pkg_other = make_pkg(PackageId::parse("cargo/bar").unwrap());
let packages = [pkg_cargo, pkg_other];
let bare = PackageId::parse("foo").unwrap();
let result = resolve_target_package(packages.iter(), &bare).unwrap();
assert_eq!(
result.map(|p| p.id.clone()),
Some(PackageId::parse("cargo/foo").unwrap())
);
}
#[test]
fn test_resolve_target_package_unknown_name_returns_none() {
let pkg_cargo = make_pkg(PackageId::parse("cargo/foo").unwrap());
let packages = [pkg_cargo];
let bare = PackageId::parse("does-not-exist").unwrap();
let result = resolve_target_package(packages.iter(), &bare).unwrap();
assert!(result.is_none());
}
#[test]
fn test_resolve_target_package_ambiguous_bare_name_three_ecosystems_errors() {
let pkg_cargo = make_pkg(PackageId::parse("cargo/foo").unwrap());
let pkg_npm = make_pkg(PackageId::parse("npm/foo").unwrap());
let pkg_pypi = make_pkg(PackageId::parse("pypi/foo").unwrap());
let packages = [pkg_cargo, pkg_npm, pkg_pypi];
let bare = PackageId::parse("foo").unwrap();
let result = resolve_target_package(packages.iter(), &bare);
match result {
Err(GraphError::AmbiguousName { name, candidates }) => {
assert_eq!(name, "foo");
assert_eq!(candidates.len(), 3);
assert!(candidates.contains(&PackageId::parse("cargo/foo").unwrap()));
assert!(candidates.contains(&PackageId::parse("npm/foo").unwrap()));
assert!(candidates.contains(&PackageId::parse("pypi/foo").unwrap()));
}
other => panic!("expected GraphError::AmbiguousName with 3 candidates, got {other:?}"),
}
}
#[test]
fn test_resolve_target_package_bare_name_matching_is_case_sensitive() {
let pkg_cargo = make_pkg(PackageId::parse("cargo/Foo").unwrap());
let packages = [pkg_cargo];
let bare = PackageId::parse("foo").unwrap();
let result = resolve_target_package(packages.iter(), &bare).unwrap();
assert!(
result.is_none(),
"case-sensitive name comparison must not match 'foo' against 'Foo'"
);
}
#[test]
fn test_orphaned_changeset_not_consumed_emits_unknown_package_diagnostic() {
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
init_repo(root);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "initial commit"]);
let cs_dir = root.join(".changeset");
std::fs::create_dir_all(&cs_dir).unwrap();
std::fs::write(
cs_dir.join("orphan-cs.md"),
"---\n\"pkg-foo\": minor\n---\n\nOrphaned changeset.\n",
)
.unwrap();
let pkg_bar_id = PackageId::parse("pkg-bar").unwrap();
let graph = SinglePackageGraph {
pkg: make_pkg(pkg_bar_id.clone()),
};
let cfg = crate::config::load(root).unwrap();
let runner = RealGitRunner;
let git = GitAccess::discover(root, &runner);
let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_bar_id.clone(), Version::semver(1, 0, 0));
let inference = RecordingInference::default();
let agg = aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &inference).unwrap();
assert!(
agg.consumed.is_empty(),
"a fully-orphaned changeset (all entries reference non-existent packages) must NOT \
be added to consumed (which would cause it to be deleted on disk): got {:?}",
agg.consumed
);
let unknown_pkg_diags: Vec<_> = agg
.diagnostics
.iter()
.filter(|d| d.code == callisto_model::DiagnosticCode::UnknownPackage)
.collect();
assert!(
!unknown_pkg_diags.is_empty(),
"must emit at least one UnknownPackage diagnostic for orphaned changeset entries; \
got diagnostics: {:?}",
agg.diagnostics
);
}
#[test]
fn test_union_fixed_stale_member_emits_diagnostic_and_is_skipped() {
let pkg_foo = PackageId::parse("pkg-foo").unwrap(); let pkg_bar = PackageId::parse("pkg-bar").unwrap(); let pkg_baz = PackageId::parse("pkg-baz").unwrap();
let mut agg = Aggregation::default();
agg.severities.insert(pkg_bar.clone(), Severity::Minor);
agg.named_by.insert(pkg_bar.clone(), NamedBy::Changeset);
let mut groups = GroupTable::default();
let group_def = GroupDef {
name: GroupName("fixed-grp".to_string()),
kind: GroupKind::Fixed,
members: vec![
GroupMember::Package(pkg_foo.clone()),
GroupMember::Package(pkg_bar.clone()),
GroupMember::Package(pkg_baz.clone()),
],
};
groups.fixed.insert(group_def.name.clone(), group_def);
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_bar.clone(), Version::semver(1, 0, 0));
base_versions.insert(pkg_baz.clone(), Version::semver(1, 0, 0));
let changed = union_fixed(&mut agg, &groups, &base_versions);
assert!(
changed,
"union_fixed must return true because pkg-baz received a propagated severity"
);
assert_eq!(
agg.severities.get(&pkg_baz),
Some(&Severity::Minor),
"real member pkg-baz must receive the propagated Minor severity"
);
assert!(
!agg.severities.contains_key(&pkg_foo),
"stale group member pkg-foo must NOT be inserted into severities (would crash cascade \
with a misleading MissingField error)"
);
let unknown_diags: Vec<_> = agg
.diagnostics
.iter()
.filter(|d| d.code == callisto_model::DiagnosticCode::UnknownPackage)
.collect();
assert!(
!unknown_diags.is_empty(),
"must emit an UnknownPackage diagnostic for stale fixed group member; \
got diagnostics: {:?}",
agg.diagnostics
);
}
fn linked_group(name: &str, members: &[PackageId]) -> GroupTable {
let mut groups = GroupTable::default();
let group_def = GroupDef {
name: GroupName(name.to_string()),
kind: GroupKind::Linked,
members: members.iter().cloned().map(GroupMember::Package).collect(),
};
groups.linked.insert(group_def.name.clone(), group_def);
groups
}
#[test]
fn test_union_linked_propagates_severity_from_named_member() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let pkg_b = PackageId::parse("pkg-b").unwrap();
let mut agg = Aggregation::default();
agg.severities.insert(pkg_b.clone(), Severity::Minor);
agg.named_by.insert(pkg_b.clone(), NamedBy::Changeset);
let groups = linked_group("linked-pair", &[pkg_a.clone(), pkg_b.clone()]);
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_a.clone(), Version::semver(1, 0, 0));
base_versions.insert(pkg_b.clone(), Version::semver(1, 0, 0));
let changed = union_linked(&mut agg, &groups, &base_versions);
assert!(changed);
assert_eq!(agg.severities.get(&pkg_a), Some(&Severity::Minor));
assert_eq!(agg.severities.get(&pkg_b), Some(&Severity::Minor));
assert_eq!(
agg.reasons.get(&pkg_a),
Some(&BumpReason::LinkedGroupUnion {
group: GroupName("linked-pair".to_string()),
})
);
}
#[test]
fn test_union_linked_does_not_downgrade_higher_existing_severity() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let pkg_b = PackageId::parse("pkg-b").unwrap();
let mut agg = Aggregation::default();
agg.severities.insert(pkg_a.clone(), Severity::Major);
agg.severities.insert(pkg_b.clone(), Severity::Minor);
agg.named_by.insert(pkg_a.clone(), NamedBy::Inference);
agg.named_by.insert(pkg_b.clone(), NamedBy::Changeset);
let groups = linked_group("linked-pair", &[pkg_a.clone(), pkg_b.clone()]);
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_a.clone(), Version::semver(1, 0, 0));
base_versions.insert(pkg_b.clone(), Version::semver(1, 0, 0));
let changed = union_linked(&mut agg, &groups, &base_versions);
assert!(changed);
assert_eq!(agg.severities.get(&pkg_a), Some(&Severity::Major));
assert_eq!(agg.severities.get(&pkg_b), Some(&Severity::Major));
}
#[test]
fn test_union_linked_noop_when_no_member_named() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let pkg_b = PackageId::parse("pkg-b").unwrap();
let mut agg = Aggregation::default();
let groups = linked_group("linked-pair", &[pkg_a.clone(), pkg_b.clone()]);
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_a.clone(), Version::semver(1, 0, 0));
base_versions.insert(pkg_b.clone(), Version::semver(1, 0, 0));
let changed = union_linked(&mut agg, &groups, &base_versions);
assert!(!changed);
assert!(agg.severities.is_empty());
}
#[test]
fn test_aggregate_inference_error_emits_diagnostic() {
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
init_repo(root);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "initial commit"]);
let pkg_id = PackageId::parse("pkg-a").unwrap();
let graph = SinglePackageGraph {
pkg: make_pkg(pkg_id.clone()),
};
let cfg = crate::config::load(root).unwrap();
let runner = RealGitRunner;
let git = GitAccess::discover(root, &runner);
let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_id.clone(), Version::semver(1, 0, 0));
struct AlwaysErrorInference;
impl SeverityInference for AlwaysErrorInference {
fn infer(
&self,
_pkg: &Package,
_git: &GitAccess<'_>,
_window: InferenceWindowSpec<'_>,
) -> Result<Option<InferenceOutcome>, GraphError> {
Err(GraphError::Vcs(callisto_vcs::VcsError::Git(
"simulated inference failure".into(),
)))
}
}
let agg = aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &AlwaysErrorInference).unwrap();
assert!(
!agg.diagnostics.is_empty(),
"aggregate() must emit a diagnostic when SeverityInference::infer returns Err; got none"
);
}
#[test]
fn test_aggregate_bare_name_config_policy_matches_prefixed_package() {
use crate::config::resolve::PreMajorInferencePolicy;
use std::sync::atomic::AtomicBool;
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
init_repo(root);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "initial commit"]);
std::fs::write(
root.join("callisto.toml"),
"[[package]]\nmatch = \"pkg-a\"\npre-major-inference = \"conservative\"\n",
)
.unwrap();
let pkg_id = PackageId::parse("cargo/pkg-a").unwrap();
let graph = SinglePackageGraph {
pkg: make_pkg(pkg_id.clone()),
};
let cfg = crate::config::load(root).unwrap();
let runner = RealGitRunner;
let git = GitAccess::discover(root, &runner);
let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_id.clone(), Version::semver(0, 1, 0));
struct PolicyCapturingInference2 {
saw_non_off: AtomicBool,
}
impl SeverityInference for PolicyCapturingInference2 {
fn infer(
&self,
_pkg: &Package,
_git: &GitAccess<'_>,
window: InferenceWindowSpec<'_>,
) -> Result<Option<InferenceOutcome>, GraphError> {
if window.policy != PreMajorInferencePolicy::Off {
self.saw_non_off.store(true, Ordering::SeqCst);
}
Ok(None)
}
}
let capturing = PolicyCapturingInference2 {
saw_non_off: AtomicBool::new(false),
};
aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &capturing).unwrap();
assert!(
capturing.saw_non_off.load(Ordering::SeqCst),
"a bare-name [[package]] rule must match a prefixed package ID via \
PackageId::matches(); the old == comparison was silently inert for \
cargo/pkg-a when callisto.toml uses match = \"pkg-a\""
);
}
#[test]
fn test_aggregate_does_not_consume_changesets_during_pre_mode() {
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
init_repo(root);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "initial commit"]);
let cs_dir = root.join(".changeset");
std::fs::create_dir_all(&cs_dir).unwrap();
std::fs::write(
cs_dir.join("some-feature.md"),
"---\n\"pkg-a\": minor\n---\n\nA feature in pre mode.\n",
)
.unwrap();
let pkg_id = PackageId::parse("pkg-a").unwrap();
let graph = SinglePackageGraph {
pkg: make_pkg(pkg_id.clone()),
};
let cfg = crate::config::load(root).unwrap();
let runner = RealGitRunner;
let git = GitAccess::discover(root, &runner);
let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_id.clone(), Version::semver(1, 0, 0));
let pre_state = callisto_format::PreState::entering("next", [("pkg-a".to_string(), Version::semver(1, 0, 0))]);
let inference = RecordingInference::default();
let agg = aggregate(&graph, &cfg, &git, &tags, &base_versions, Some(&pre_state), &inference).unwrap();
assert!(
agg.consumed.is_empty(),
"changesets must NOT be consumed during a pre-release cycle (PreMode::Pre); \
agg.consumed must be empty but got: {:?}",
agg.consumed
);
}
#[test]
fn test_aggregate_pre_major_inference_policy_applied() {
use crate::config::resolve::PreMajorInferencePolicy;
use std::sync::atomic::AtomicBool;
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
init_repo(root);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "initial commit"]);
std::fs::write(
root.join("callisto.toml"),
"[[package]]\nmatch = \"pkg-a\"\npre-major-inference = \"conservative\"\n",
)
.unwrap();
let pkg_id = PackageId::parse("pkg-a").unwrap();
let graph = SinglePackageGraph {
pkg: make_pkg(pkg_id.clone()),
};
let cfg = crate::config::load(root).unwrap();
let runner = RealGitRunner;
let git = GitAccess::discover(root, &runner);
let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_id.clone(), Version::semver(0, 1, 0));
struct PolicyCapturingInference {
saw_non_off: AtomicBool,
}
impl SeverityInference for PolicyCapturingInference {
fn infer(
&self,
_pkg: &Package,
_git: &GitAccess<'_>,
window: InferenceWindowSpec<'_>,
) -> Result<Option<InferenceOutcome>, GraphError> {
if window.policy != PreMajorInferencePolicy::Off {
self.saw_non_off.store(true, Ordering::SeqCst);
}
Ok(None)
}
}
let capturing = PolicyCapturingInference {
saw_non_off: AtomicBool::new(false),
};
aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &capturing).unwrap();
assert!(
capturing.saw_non_off.load(Ordering::SeqCst),
"aggregate() must pass the per-package pre_major_inference policy from config.packages \
into InferenceWindowSpec; received OFF even though callisto.toml sets conservative"
);
}
#[test]
fn test_pre_major_inference_prefixed_beats_bare_when_bare_declared_first() {
use crate::config::resolve::PreMajorInferencePolicy;
use std::sync::atomic::AtomicBool;
let ws_dir = tempfile::tempdir().unwrap();
let root = ws_dir.path();
init_repo(root);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "initial commit"]);
std::fs::write(
root.join("callisto.toml"),
"[[package]]\nmatch = \"pkg\"\npre-major-inference = \"conservative\"\n\n[[package]]\nmatch = \"npm:pkg\"\npre-major-inference = \"off\"\n",
)
.unwrap();
let pkg_id = PackageId::parse("pkg").unwrap();
let graph = SinglePackageGraph {
pkg: make_pkg(pkg_id.clone()),
};
let cfg = crate::config::load(root).unwrap();
let runner = RealGitRunner;
let git = GitAccess::discover(root, &runner);
let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
let mut base_versions = BTreeMap::new();
base_versions.insert(pkg_id.clone(), Version::semver(0, 1, 0));
struct PolicyCapturingInference {
invoked: AtomicBool,
saw_non_off: AtomicBool,
}
impl SeverityInference for PolicyCapturingInference {
fn infer(
&self,
_pkg: &Package,
_git: &GitAccess<'_>,
window: InferenceWindowSpec<'_>,
) -> Result<Option<InferenceOutcome>, GraphError> {
self.invoked.store(true, Ordering::SeqCst);
if window.policy != PreMajorInferencePolicy::Off {
self.saw_non_off.store(true, Ordering::SeqCst);
}
Ok(None)
}
}
let capturing = PolicyCapturingInference {
invoked: AtomicBool::new(false),
saw_non_off: AtomicBool::new(false),
};
aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &capturing).unwrap();
assert!(
capturing.invoked.load(Ordering::SeqCst),
"inference was never invoked; fixture is wrong and cannot distinguish OFF policy \
from no invocation. Ensure Version::semver(0, 1, 0) is in base_versions so the \
package is pre-1.0 and pre-major-inference is consulted."
);
assert!(
!capturing.saw_non_off.load(Ordering::SeqCst),
"expected OFF policy from Prefixed rule (npm:pkg); Bare rule (conservative) was \
applied instead. Two-pass specificity is required: Prefixed rules must win over \
Bare rules regardless of declaration order."
);
}
}