use crate::change_detection::classify_path;
use crate::config::ChangelogFilters;
use crate::error::{ConfigError, RailError, RailResult};
use crate::release::changelog::{CommitRef, parse_subject};
use crate::workspace::WorkspaceContext;
use glob::Pattern;
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct AttributedCommit {
pub sha: String,
pub subject: String,
pub body: Option<String>,
pub crates: Vec<String>,
pub code_crates: Vec<String>,
}
impl AttributedCommit {
pub fn as_ref(&self) -> CommitRef<'_> {
CommitRef {
sha: &self.sha,
subject: &self.subject,
body: self.body.as_deref(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AttributedHistory {
commits: Vec<AttributedCommit>,
}
impl AttributedHistory {
pub fn commits(&self) -> &[AttributedCommit] {
&self.commits
}
pub fn for_crate<'a>(&'a self, crate_name: &'a str) -> impl Iterator<Item = &'a AttributedCommit> + 'a {
self
.commits
.iter()
.filter(move |commit| commit.crates.iter().any(|c| c == crate_name))
}
pub fn commit_refs_for_crate<'a>(&'a self, crate_name: &'a str) -> Vec<CommitRef<'a>> {
self.for_crate(crate_name).map(AttributedCommit::as_ref).collect()
}
pub fn has_code_changes(&self, crate_name: &str) -> bool {
self
.commits
.iter()
.any(|commit| commit.code_crates.iter().any(|c| c == crate_name))
}
}
pub struct CommitAttributor<'a> {
ctx: &'a WorkspaceContext,
members: HashSet<&'a str>,
}
impl<'a> CommitAttributor<'a> {
pub fn new(ctx: &'a WorkspaceContext) -> Self {
let members = ctx.graph().workspace_members().iter().map(String::as_str).collect();
Self { ctx, members }
}
pub fn history(&self, from: Option<&str>, to: &str) -> RailResult<AttributedHistory> {
self.history_with_filters(from, to, None)
}
pub fn history_with_filters(
&self,
from: Option<&str>,
to: &str,
filters: Option<&ChangelogFilters>,
) -> RailResult<AttributedHistory> {
let entries = self.ctx.git()?.git().log_with_files(from, to)?;
let path_filters = PathFilters::compile(filters)?;
let mut commits = Vec::with_capacity(entries.len());
for entry in entries {
let mut crates: Vec<String> = Vec::new();
let mut code_crates: Vec<String> = Vec::new();
let mut any_file_filtered = false;
for file in &entry.files {
let Some(workspace_path) = self.ctx.to_workspace_path(file) else {
continue;
};
if !path_filters.allows(&workspace_path) {
any_file_filtered = true;
continue;
}
let Some(owner) = self.ctx.graph().file_to_crate(&workspace_path) else {
continue;
};
if classify_path(&workspace_path).seeds_build_test_transitive() && !code_crates.contains(&owner) {
code_crates.push(owner.clone());
}
if !crates.contains(&owner) {
crates.push(owner);
}
}
let parsed = parse_subject(&entry.subject, entry.body.as_deref());
if let Some(scope) = parsed.scope
&& self.members.contains(scope)
{
crates.retain(|c| c == scope);
code_crates.retain(|c| c == scope);
if crates.is_empty() && !any_file_filtered {
crates.push(scope.to_string());
}
}
crates.sort_unstable();
code_crates.sort_unstable();
commits.push(AttributedCommit {
sha: entry.sha,
subject: entry.subject,
body: entry.body,
crates,
code_crates,
});
}
Ok(AttributedHistory { commits })
}
}
#[derive(Debug, Default)]
struct PathFilters {
include: Vec<Pattern>,
exclude: Vec<Pattern>,
}
impl PathFilters {
fn compile(filters: Option<&ChangelogFilters>) -> RailResult<Self> {
let Some(filters) = filters else {
return Ok(Self::default());
};
Ok(Self {
include: compile_patterns("release.changelog.filters.include_paths", &filters.include_paths)?,
exclude: compile_patterns("release.changelog.filters.exclude_paths", &filters.exclude_paths)?,
})
}
fn allows(&self, path: &Path) -> bool {
let included = self.include.is_empty() || self.include.iter().any(|pattern| pattern.matches_path(path));
let excluded = self.exclude.iter().any(|pattern| pattern.matches_path(path));
included && !excluded
}
}
fn compile_patterns(field: &str, patterns: &[String]) -> RailResult<Vec<Pattern>> {
patterns
.iter()
.map(|pattern| {
Pattern::new(pattern).map_err(|e| {
RailError::Config(ConfigError::InvalidGlobPattern {
pattern: format!("{} = {}", field, pattern),
message: e.to_string(),
})
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn commit(subject: &str, crates: &[&str], code_crates: &[&str]) -> AttributedCommit {
AttributedCommit {
sha: "aaaaaaa1111111".to_string(),
subject: subject.to_string(),
body: None,
crates: crates.iter().map(|s| s.to_string()).collect(),
code_crates: code_crates.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn for_crate_filters_by_attribution() {
let history = AttributedHistory {
commits: vec![
commit("feat: core change", &["core"], &["core"]),
commit("fix: cli change", &["cli"], &["cli"]),
commit("refactor: both", &["cli", "core"], &["cli", "core"]),
],
};
let core: Vec<_> = history.for_crate("core").map(|c| c.subject.as_str()).collect();
assert_eq!(core, vec!["feat: core change", "refactor: both"]);
assert!(history.has_code_changes("cli"));
assert!(!history.has_code_changes("ghost"));
}
#[test]
fn infra_only_commits_attribute_to_no_crate() {
let history = AttributedHistory {
commits: vec![commit("ci: bump action", &[], &[])],
};
assert_eq!(history.for_crate("core").count(), 0);
assert!(!history.commits()[0].crates.iter().any(|_| true));
}
}