use super::helpers;
use crate::commands::bump::cargo_edit::{WorkspaceInfo, load_workspace};
use crate::commands::bump::inference::find_last_tag_for_prefix;
use crate::commands::changelog_sync::{
ChangelogRouting, RefreshTarget, extract_unreleased_section, refresh_changelogs,
};
use crate::commands::tag::{RepoShape, detect_repo_shape};
use crate::pipeline;
use anodizer_cli::ChangelogFormat;
use anodizer_core::config::Config;
use anodizer_core::context::{Context, ContextOptions};
use anodizer_core::git;
use anodizer_core::log::{StageLogger, Verbosity};
use anodizer_core::stage::Stage;
use anyhow::{Context as _, Result, bail};
use std::io::Write as _;
use std::path::{Path, PathBuf};
pub struct ChangelogOpts {
pub crate_name: Option<String>,
pub range: Option<String>,
pub format: ChangelogFormat,
pub write: bool,
pub snapshot: bool,
pub config_override: Option<PathBuf>,
pub verbose: bool,
pub debug: bool,
pub quiet: bool,
}
enum RangeStart {
Pending,
FullHistory,
Ref(String),
}
struct ResolvedRange {
start: RangeStart,
to: Option<String>,
pinned_crate: Option<String>,
}
pub fn run(opts: ChangelogOpts) -> Result<()> {
let ChangelogOpts {
crate_name,
range,
format,
write,
snapshot,
config_override,
verbose,
debug,
quiet,
} = opts;
let log = StageLogger::new("changelog", Verbosity::from_flags(quiet, verbose, debug));
if write && format != ChangelogFormat::KeepAChangelog {
bail!(
"--write is only valid with --format keep-a-changelog; \
redirect stdout to capture release-notes/json output"
);
}
let path = pipeline::find_config_with_logger(config_override.as_deref(), Some(&log))?;
let config = pipeline::load_config(&path)?;
let workspace_root =
crate::commands::helpers::discover_workspace_root(config_override.as_deref())?;
let workspace = load_workspace(&workspace_root).ok();
crate::commands::tag::guard_flat_aggregate_coherence(
Some(&config),
workspace.as_ref(),
&workspace_root,
)?;
match format {
ChangelogFormat::KeepAChangelog => run_refresh(
&workspace_root,
&config,
crate_name.as_deref(),
range.as_deref(),
write,
&log,
),
ChangelogFormat::ReleaseNotes => run_release_notes(
&workspace_root,
config,
crate_name,
range.as_deref(),
snapshot,
verbose,
debug,
&log,
),
ChangelogFormat::Json => run_json(
&workspace_root,
&config,
crate_name.as_deref(),
range.as_deref(),
&log,
),
}
}
fn resolve_range(
workspace_root: &Path,
config: &Config,
range: Option<&str>,
) -> Result<ResolvedRange> {
let Some(spec) = range else {
return Ok(ResolvedRange {
start: RangeStart::Pending,
to: None,
pinned_crate: None,
});
};
if let Some((from, to)) = spec.split_once("..") {
let start = if from.is_empty() {
RangeStart::FullHistory
} else {
RangeStart::Ref(from.to_string())
};
return Ok(ResolvedRange {
start,
to: (!to.is_empty()).then(|| to.to_string()),
pinned_crate: None,
});
}
let (owning_crate, prefix) = resolve_tag_owner(config, spec)?;
let predecessor = predecessor_tag(workspace_root, &prefix, spec)?;
Ok(ResolvedRange {
start: predecessor.map_or(RangeStart::FullHistory, RangeStart::Ref),
to: Some(spec.to_string()),
pinned_crate: Some(owning_crate),
})
}
fn resolve_start_bound(
start: &RangeStart,
workspace_root: &Path,
prefix: &str,
) -> Result<Option<String>> {
match start {
RangeStart::Pending => find_last_tag_for_prefix(workspace_root, prefix),
RangeStart::FullHistory => Ok(None),
RangeStart::Ref(r) => Ok(Some(r.clone())),
}
}
fn resolve_tag_owner(config: &Config, tag: &str) -> Result<(String, String)> {
let all_crates = config.crates.iter().chain(
config
.workspaces
.as_deref()
.unwrap_or_default()
.iter()
.flat_map(|w| &w.crates),
);
let mut best: Option<(&str, String)> = None;
for c in all_crates {
let prefix =
git::extract_tag_prefix(&c.tag_template).unwrap_or_else(|| format!("{}-v", c.name));
if let Some(remainder) = tag.strip_prefix(&prefix) {
let is_version = remainder
.split('.')
.next()
.is_some_and(|s| !s.is_empty() && s.chars().all(|ch| ch.is_ascii_digit()));
if is_version && best.as_ref().is_none_or(|(_, p)| prefix.len() > p.len()) {
best = Some((c.name.as_str(), prefix));
}
}
}
match best {
Some((name, prefix)) => Ok((name.to_string(), prefix)),
None => bail!("no crate in the config matches tag '{}'", tag),
}
}
fn predecessor_tag(workspace_root: &Path, prefix: &str, tag: &str) -> Result<Option<String>> {
let tags = git::get_all_semver_tags_in(workspace_root, prefix, None, None)
.with_context(|| format!("list tags with prefix '{}'", prefix))?;
let target = git::parse_semver_tag(tag).ok();
let Some(target) = target else {
return Ok(None);
};
for candidate in &tags {
if let Ok(v) = git::parse_semver_tag(candidate)
&& v < target
{
return Ok(Some(candidate.clone()));
}
}
Ok(None)
}
fn global_tag_prefix(config: &Config) -> String {
config
.tag
.as_ref()
.and_then(|t| t.tag_prefix.clone())
.unwrap_or_else(|| "v".to_string())
}
fn select_crates(
workspace_root: &Path,
config: &Config,
workspace: Option<&WorkspaceInfo>,
crate_filter: Option<&str>,
) -> Vec<(String, PathBuf, String)> {
let prefix_for = |c: &anodizer_core::config::CrateConfig| -> String {
git::extract_tag_prefix(&c.tag_template).unwrap_or_else(|| format!("{}-v", c.name))
};
let global_prefix = global_tag_prefix(config);
let entries: Vec<(String, PathBuf, String)> =
match detect_repo_shape(workspace_root, Some(config), workspace) {
RepoShape::Single => {
let c = config.crates.first().or_else(|| {
config
.workspaces
.as_deref()
.unwrap_or_default()
.iter()
.flat_map(|w| &w.crates)
.next()
});
match c {
Some(c) => vec![(
c.name.clone(),
workspace_root.join(&c.path),
git::extract_tag_prefix(&c.tag_template)
.unwrap_or_else(|| global_prefix.clone()),
)],
None => vec![(
config.project_name.clone(),
workspace_root.to_path_buf(),
global_prefix.clone(),
)],
}
}
RepoShape::Lockstep | RepoShape::FlatAggregate(_) => {
vec![(
config.project_name.clone(),
workspace_root.to_path_buf(),
global_prefix.clone(),
)]
}
RepoShape::PerCrate(groups) => groups
.iter()
.flatten()
.map(|c| (c.name.clone(), workspace_root.join(&c.path), prefix_for(c)))
.collect(),
};
match crate_filter {
Some(name) => entries.into_iter().filter(|(n, _, _)| n == name).collect(),
None => entries,
}
}
fn resolve_single_track(
selected: &[(String, PathBuf, String)],
root_enabled: bool,
per_crate: bool,
crate_filtered: bool,
) -> bool {
if crate_filtered || selected.len() != 1 {
return false;
}
root_enabled && !per_crate
}
fn run_refresh(
workspace_root: &Path,
config: &Config,
crate_filter: Option<&str>,
range: Option<&str>,
write: bool,
log: &StageLogger,
) -> Result<()> {
let resolved = resolve_range(workspace_root, config, range)?;
let effective_filter = resolved.pinned_crate.as_deref().or(crate_filter);
let workspace = load_workspace(workspace_root).ok();
let selected = select_crates(workspace_root, config, workspace.as_ref(), effective_filter);
if selected.is_empty() {
log.warn("no crates selected for changelog refresh");
return Ok(());
}
let empty = anodizer_core::config::ChangelogConfig::default();
let mut routing = ChangelogRouting::from_config(config.changelog.as_ref().unwrap_or(&empty));
routing.single_track = resolve_single_track(
&selected,
routing.root_enabled,
routing.per_crate,
effective_filter.is_some(),
);
let root_crate_names =
crate::commands::changelog_sync::config_root_crate_names(config, routing.root_crates);
routing.multitrack =
routing.root_enabled && !routing.single_track && root_crate_names.len() > 1;
routing.root_crate_names = root_crate_names;
let targets: Vec<RefreshTarget> = selected
.into_iter()
.map(|(name, dir, prefix)| {
let from_tag = resolve_start_bound(&resolved.start, workspace_root, &prefix)?;
Ok(RefreshTarget {
crate_name: name,
crate_dir: dir,
from_tag,
to_ref: resolved.to.clone(),
})
})
.collect::<Result<_>>()?;
let outputs = refresh_changelogs(workspace_root, &targets, &routing, write, log)?;
if outputs.is_empty() {
log.warn("no changelog sections to refresh");
return Ok(());
}
if write {
return Ok(());
}
let multi = outputs.len() > 1;
let mut stdout = std::io::stdout();
for out in &outputs {
let section = extract_unreleased_section(&out.rendered_text);
if multi {
writeln!(stdout, "--- {} ---", out.rel_path)
.context("changelog: write preview separator")?;
}
writeln!(stdout, "{}", section).context("changelog: write preview section")?;
if multi {
writeln!(stdout).context("changelog: write preview spacer")?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_release_notes(
workspace_root: &Path,
mut config: Config,
crate_filter: Option<String>,
range: Option<&str>,
snapshot: bool,
verbose: bool,
debug: bool,
log: &StageLogger,
) -> Result<()> {
let resolved = resolve_range(workspace_root, &config, range)?;
let effective_filter = resolved.pinned_crate.clone().or(crate_filter);
log.status("generating release notes");
if effective_filter.is_none() && config.crates.len() > 1 {
let empty = anodizer_core::config::ChangelogConfig::default();
let routing = ChangelogRouting::from_config(config.changelog.as_ref().unwrap_or(&empty));
let workspace = load_workspace(workspace_root).ok();
let selected = select_crates(workspace_root, &config, workspace.as_ref(), None);
let single_track =
resolve_single_track(&selected, routing.root_enabled, routing.per_crate, false);
if single_track && let Some(mut first) = config.crates.first().cloned() {
first.path = String::new();
config.crates = vec![first];
}
}
if effective_filter.is_none() && config.crates.is_empty() {
let workspace_crates: Vec<anodizer_core::config::CrateConfig> = config
.workspaces
.as_deref()
.unwrap_or_default()
.iter()
.flat_map(|w| w.crates.iter().cloned())
.collect();
if workspace_crates.is_empty() {
let global_prefix = global_tag_prefix(&config);
config.crates = vec![anodizer_core::config::CrateConfig {
name: config.project_name.clone(),
path: String::new(),
tag_template: format!("{}{{{{ Version }}}}", global_prefix),
..Default::default()
}];
} else {
config.crates = workspace_crates;
}
}
let selected_crates: Vec<String> = match effective_filter.as_ref() {
Some(name) => vec![name.clone()],
None => Vec::new(),
};
if let Some(ref target) = effective_filter
&& config.crates.is_empty()
{
let ws_for_target = config
.workspaces
.as_ref()
.and_then(|ws_list| {
ws_list
.iter()
.find(|ws| ws.crates.iter().any(|c| &c.name == target))
})
.cloned();
if let Some(ws) = ws_for_target {
log.verbose(&format!(
"--crate {} lives in workspace '{}'; applying workspace overlay",
target, ws.name
));
helpers::apply_workspace_overlay(&mut config, &ws);
}
}
let explicit_from = match &resolved.start {
RangeStart::Ref(r) => Some(r.clone()),
RangeStart::Pending | RangeStart::FullHistory => None,
};
let ctx_opts = ContextOptions {
verbose,
debug,
selected_crates,
snapshot,
changelog_from: explicit_from.clone(),
changelog_full_history: matches!(resolved.start, RangeStart::FullHistory),
changelog_to: resolved.to.clone(),
changelog_preview: true,
..Default::default()
};
let mut ctx = Context::new(config.clone(), ctx_opts);
helpers::resolve_scm_token_type(&mut ctx, &config);
ctx.populate_time_vars();
ctx.populate_runtime_vars();
helpers::resolve_git_context(&mut ctx, &config, log)?;
if let Some(ref t) = resolved.to {
ctx.template_vars_mut().set("Tag", t);
}
if let Some(ref f) = explicit_from {
ctx.template_vars_mut().set("PreviousTag", f);
}
let stage = anodizer_stage_changelog::ChangelogStage;
stage.run(&mut ctx)?;
let mut entries: Vec<(&String, &String)> = ctx.stage_outputs.changelogs.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut aggregated = String::new();
for (name, body) in &entries {
if entries.len() > 1 {
aggregated.push_str(&format!("\n---\n{}\n---\n\n", name));
}
aggregated.push_str(body);
if !body.ends_with('\n') {
aggregated.push('\n');
}
}
print!("{}", aggregated);
if ctx.stage_outputs.changelogs.is_empty() {
log.warn("no changelogs generated");
}
Ok(())
}
fn run_json(
workspace_root: &Path,
config: &Config,
crate_filter: Option<&str>,
range: Option<&str>,
log: &StageLogger,
) -> Result<()> {
let resolved = resolve_range(workspace_root, config, range)?;
let effective_filter = resolved.pinned_crate.as_deref().or(crate_filter);
let workspace = load_workspace(workspace_root).ok();
let selected = select_crates(workspace_root, config, workspace.as_ref(), effective_filter);
if selected.is_empty() {
log.warn("no crates selected for changelog json");
}
let mut elems: Vec<serde_json::Value> = Vec::new();
for (name, dir, prefix) in &selected {
let from_tag = resolve_start_bound(&resolved.start, workspace_root, prefix)?;
let json = anodizer_stage_changelog::render_changelog_json(
workspace_root,
dir,
from_tag.as_deref(),
resolved.to.as_deref(),
)
.with_context(|| format!("render json changelog for {}", name))?;
let payload: serde_json::Value = match json {
Some(s) => serde_json::from_str(&s)
.with_context(|| format!("parse json changelog for {}", name))?,
None => serde_json::json!({
"from": from_tag,
"to": resolved.to.clone().unwrap_or_else(|| "HEAD".to_string()),
"groups": [],
}),
};
let mut obj = serde_json::Map::new();
obj.insert("crate".to_string(), serde_json::Value::String(name.clone()));
if let serde_json::Value::Object(map) = payload {
for (k, v) in map {
obj.insert(k, v);
}
}
elems.push(serde_json::Value::Object(obj));
}
elems.sort_by(|a, b| {
a.get("crate")
.and_then(|v| v.as_str())
.cmp(&b.get("crate").and_then(|v| v.as_str()))
});
let out = serde_json::to_string_pretty(&serde_json::Value::Array(elems))
.context("serialize changelog json array")?;
let mut stdout = std::io::stdout();
writeln!(stdout, "{}", out).context("changelog: write json")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::CrateConfig;
use serial_test::serial;
fn default_opts(config: Option<&Path>) -> ChangelogOpts {
ChangelogOpts {
crate_name: None,
range: None,
format: ChangelogFormat::default(),
write: false,
snapshot: false,
config_override: config.map(|p| p.to_path_buf()),
verbose: false,
debug: false,
quiet: true,
}
}
fn crate_cfg(name: &str, tag_template: &str) -> CrateConfig {
CrateConfig {
name: name.to_string(),
tag_template: tag_template.to_string(),
..Default::default()
}
}
fn cfg_with_crates(crates: Vec<CrateConfig>) -> Config {
Config {
crates,
..Default::default()
}
}
#[test]
fn default_format_is_keep_a_changelog() {
assert_eq!(ChangelogFormat::default(), ChangelogFormat::KeepAChangelog);
}
#[test]
#[serial]
fn missing_config_returns_err() {
let tmp = tempfile::tempdir().unwrap();
let bogus = tmp.path().join("missing.yaml");
let err = run(default_opts(Some(&bogus))).unwrap_err().to_string();
assert!(err.contains("config file not found"), "{err}");
}
#[test]
#[serial]
fn write_with_release_notes_format_errors() {
let tmp = tempfile::tempdir().unwrap();
let bogus = tmp.path().join("missing.yaml");
let mut opts = default_opts(Some(&bogus));
opts.write = true;
opts.format = ChangelogFormat::ReleaseNotes;
let err = run(opts).unwrap_err().to_string();
assert!(err.contains("--write is only valid"), "{err}");
}
#[test]
#[serial]
fn write_with_json_format_errors() {
let tmp = tempfile::tempdir().unwrap();
let bogus = tmp.path().join("missing.yaml");
let mut opts = default_opts(Some(&bogus));
opts.write = true;
opts.format = ChangelogFormat::Json;
let err = run(opts).unwrap_err().to_string();
assert!(err.contains("--write is only valid"), "{err}");
}
#[test]
#[serial]
fn write_guard_precedes_config_load() {
let tmp = tempfile::tempdir().unwrap();
let bogus = tmp.path().join("missing.yaml");
let mut opts = default_opts(Some(&bogus));
opts.write = true;
opts.format = ChangelogFormat::Json;
let err = run(opts).unwrap_err().to_string();
assert!(err.contains("--write"), "{err}");
}
#[test]
fn resolve_range_none_is_pending() {
let cfg = Config::default();
let tmp = tempfile::tempdir().unwrap();
let r = resolve_range(tmp.path(), &cfg, None).unwrap();
assert!(matches!(r.start, RangeStart::Pending));
assert!(r.to.is_none());
assert!(r.pinned_crate.is_none());
}
#[test]
fn resolve_range_explicit_range_splits() {
let cfg = Config::default();
let tmp = tempfile::tempdir().unwrap();
let r = resolve_range(tmp.path(), &cfg, Some("v0.1.0..v0.2.0")).unwrap();
assert!(matches!(r.start, RangeStart::Ref(ref f) if f == "v0.1.0"));
assert_eq!(r.to.as_deref(), Some("v0.2.0"));
assert!(r.pinned_crate.is_none());
}
#[test]
fn resolve_range_open_ended_left_is_full_history() {
let cfg = Config::default();
let tmp = tempfile::tempdir().unwrap();
let r = resolve_range(tmp.path(), &cfg, Some("..v0.2.0")).unwrap();
assert!(matches!(r.start, RangeStart::FullHistory));
assert_eq!(r.to.as_deref(), Some("v0.2.0"));
}
#[test]
fn resolve_range_bare_dotdot_is_full_history() {
let cfg = Config::default();
let tmp = tempfile::tempdir().unwrap();
let r = resolve_range(tmp.path(), &cfg, Some("..")).unwrap();
assert!(matches!(r.start, RangeStart::FullHistory));
assert!(r.to.is_none());
}
#[test]
fn resolve_tag_owner_picks_longest_prefix() {
let cfg = cfg_with_crates(vec![
crate_cfg("app", "v{{ .Version }}"),
crate_cfg("core", "core-v{{ .Version }}"),
]);
let (name, prefix) = resolve_tag_owner(&cfg, "core-v0.2.0").unwrap();
assert_eq!(name, "core");
assert_eq!(prefix, "core-v");
let (name, prefix) = resolve_tag_owner(&cfg, "v1.0.0").unwrap();
assert_eq!(name, "app");
assert_eq!(prefix, "v");
}
#[test]
fn resolve_tag_owner_no_match_errors() {
let cfg = cfg_with_crates(vec![crate_cfg("core", "core-v{{ .Version }}")]);
let err = resolve_tag_owner(&cfg, "other-v1.0.0")
.unwrap_err()
.to_string();
assert!(err.contains("no crate"), "{err}");
}
#[test]
fn select_crates_per_crate_filters() {
let cfg = cfg_with_crates(vec![
crate_cfg("app", "v{{ .Version }}"),
crate_cfg("core", "core-v{{ .Version }}"),
]);
let tmp = tempfile::tempdir().unwrap();
let all = select_crates(tmp.path(), &cfg, None, None);
assert_eq!(all.len(), 2);
let filtered = select_crates(tmp.path(), &cfg, None, Some("core"));
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].0, "core");
assert_eq!(filtered[0].2, "core-v");
}
fn ws_no_lockstep() -> WorkspaceInfo {
WorkspaceInfo {
workspace_package_version: None,
members: vec![],
}
}
#[test]
#[serial]
fn select_crates_flat_aggregate_collapses_to_one_root_entry() {
let tmp = tempfile::tempdir().unwrap();
let mut cfg = cfg_with_crates(vec![
crate_cfg("core", "v{{ .Version }}"),
crate_cfg("cli", "v{{ .Version }}"),
]);
cfg.project_name = "proj".into();
let selected = select_crates(tmp.path(), &cfg, Some(&ws_no_lockstep()), None);
assert_eq!(
selected.len(),
1,
"flat aggregate must collapse to one entry"
);
assert_eq!(selected[0].0, "proj");
assert_eq!(selected[0].1, tmp.path());
assert_eq!(selected[0].2, "v");
}
#[test]
fn resolve_single_track_one_shared_root_entry_is_flat() {
let tmp = tempfile::tempdir().unwrap();
let selected = vec![("proj".into(), tmp.path().to_path_buf(), "v".into())];
assert!(resolve_single_track(&selected, true, false, false));
}
#[test]
fn resolve_single_track_multi_entry_is_per_crate() {
let tmp = tempfile::tempdir().unwrap();
let selected = vec![
(
"core".into(),
tmp.path().join("crates/core"),
"core-v".into(),
),
("cli".into(), tmp.path().join("crates/cli"), "cli-v".into()),
];
assert!(!resolve_single_track(&selected, true, false, false));
}
#[test]
fn resolve_single_track_respects_explicit_crate_filter() {
let tmp = tempfile::tempdir().unwrap();
let selected = vec![("core".into(), tmp.path().join("crates/core"), "v".into())];
assert!(!resolve_single_track(&selected, true, false, true));
}
#[test]
fn resolve_single_track_per_crate_files_is_per_crate() {
let tmp = tempfile::tempdir().unwrap();
let selected = vec![("proj".into(), tmp.path().to_path_buf(), "v".into())];
assert!(!resolve_single_track(&selected, true, true, false));
}
}