mod common;
use std::fs;
use indoc::indoc;
use tempfile::TempDir;
use verctl::fragment::{self, Bump, Fragment, Group, PackageBump};
use verctl::prepare::PlanEntry;
use verctl::release;
#[test]
fn contributing_fragments_skip_unused() {
let used = Fragment {
path: std::path::PathBuf::from("used.md"),
packages: vec![PackageBump {
name: "demo".into(),
bump: Bump::Patch,
}],
summary: "used".into(),
group: None,
};
let unused = Fragment {
path: std::path::PathBuf::from("skip.md"),
packages: vec![PackageBump {
name: "other".into(),
bump: Bump::Patch,
}],
summary: "skip".into(),
group: None,
};
let plan = [PlanEntry {
name: "demo".into(),
from: "1.0.0".into(),
to: "1.0.1".into(),
bump: Bump::Patch,
path: std::path::PathBuf::from("Cargo.toml"),
driver: verctl::driver::Driver::Path {
format: verctl::driver::Format::Toml,
keys: vec!["package.version".into()],
after: None,
},
}];
let fragments = [used, unused];
let hits = release::contributing_fragments(&plan, &fragments);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].summary, "used");
}
#[test]
fn consume_fragments_deletes_the_files() {
let root = TempDir::new().unwrap();
let path = root.path().join("gone.md");
fs::write(&path, "x").unwrap();
let fragment = Fragment {
path: path.clone(),
packages: vec![PackageBump {
name: "demo".into(),
bump: Bump::Patch,
}],
summary: "gone".into(),
group: None,
};
release::consume_fragments(&[fragment]).unwrap();
assert!(!path.exists());
}
#[test]
fn prepend_changelog_creates_and_inserts() {
let root = TempDir::new().unwrap();
let path = root.path().join("CHANGELOG.md");
release::prepend_changelog(
&path,
indoc! {"
## demo 1.0.1
- first
"},
)
.unwrap();
let first = fs::read_to_string(&path).unwrap();
assert!(
first.starts_with(indoc! {"
# Changelog
## demo 1.0.1
"}),
"{first}"
);
release::prepend_changelog(
&path,
indoc! {"
## demo 1.0.2
- second
"},
)
.unwrap();
let next = fs::read_to_string(&path).unwrap();
assert!(next.contains("## demo 1.0.2"), "{next}");
assert!(
next.find("1.0.2").unwrap() < next.find("1.0.1").unwrap(),
"{next}"
);
}
#[test]
fn write_changelogs_renders_fragment_summaries() {
let root = TempDir::new().unwrap();
let changelog = root.path().join("CHANGELOG.md");
let frag_path = root.path().join("note.md");
let fragment = fragment::parse_str(
indoc! {"
---
demo: patch
---
Local prepare writes versions.
"},
&frag_path,
)
.unwrap();
let plan = [PlanEntry {
name: "demo".into(),
from: "1.0.0".into(),
to: "1.0.1".into(),
bump: Bump::Patch,
path: root.path().join("Cargo.toml"),
driver: verctl::driver::Driver::Path {
format: verctl::driver::Format::Toml,
keys: vec!["package.version".into()],
after: None,
},
}];
common::write_config(
root.path(),
indoc! {"
packages:
- name: demo
path: Cargo.toml
"},
);
let config = verctl::config::Config::load(&root.path().join(verctl::config::FILE)).unwrap();
release::write_changelogs(&config, root.path(), &plan, &[fragment]).unwrap();
let body = fs::read_to_string(&changelog).unwrap();
assert!(body.contains("## demo 1.0.1"), "{body}");
assert!(body.contains("Local prepare writes versions"), "{body}");
}
#[test]
fn each_package_gets_its_own_changelog() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("crates/a")).unwrap();
fs::create_dir_all(root.path().join("crates/b")).unwrap();
common::write_config(
root.path(),
indoc! {"
packages:
- name: a
path: crates/a/Cargo.toml
- name: b
path: crates/b/Cargo.toml
"},
);
fs::write(
root.path().join("crates/a/Cargo.toml"),
indoc! {r#"
[package]
name = "a"
version = "1.0.0"
"#},
)
.unwrap();
fs::write(
root.path().join("crates/b/Cargo.toml"),
indoc! {r#"
[package]
name = "b"
version = "2.0.0"
"#},
)
.unwrap();
let changes = root.path().join(".changeset");
fs::create_dir_all(&changes).unwrap();
fs::write(
changes.join("both.md"),
indoc! {"
---
a: patch
b: minor
---
Two crates.
"},
)
.unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_verctl"))
.current_dir(root.path())
.args(["prepare", "--no-pr"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let a = fs::read_to_string(root.path().join("crates/a/CHANGELOG.md")).unwrap();
let b = fs::read_to_string(root.path().join("crates/b/CHANGELOG.md")).unwrap();
assert!(a.contains("## a 1.0.1"), "{a}");
assert!(b.contains("## b 2.1.0"), "{b}");
assert!(!a.contains("## b "), "{a}");
assert!(!b.contains("## a "), "{b}");
}
#[test]
fn notes_for_reads_each_package_changelog() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("crates/a")).unwrap();
fs::create_dir_all(root.path().join("crates/b")).unwrap();
common::write_config(
root.path(),
indoc! {"
packages:
- name: a
path: crates/a/Cargo.toml
- name: b
path: crates/b/Cargo.toml
"},
);
fs::write(
root.path().join("crates/a/CHANGELOG.md"),
indoc! {"
# Changelog
## a 1.0.1
- First crate.
## a 1.0.0
- Birth.
"},
)
.unwrap();
fs::write(
root.path().join("crates/b/CHANGELOG.md"),
indoc! {"
# Changelog
## b 2.1.0
- Second crate.
"},
)
.unwrap();
let config = verctl::config::Config::load(&root.path().join(verctl::config::FILE)).unwrap();
let notes = release::notes_for(&config, root.path(), [("a", "1.0.1"), ("b", "2.1.0")]);
assert!(notes.contains("## a 1.0.1"), "{notes}");
assert!(notes.contains("First crate"), "{notes}");
assert!(notes.contains("## b 2.1.0"), "{notes}");
assert!(!notes.contains("## a 1.0.0"), "{notes}");
}
#[test]
fn missing_changelog_section_fails_closed() {
let root = TempDir::new().unwrap();
common::write_config(
root.path(),
indoc! {"
packages:
- name: demo
path: Cargo.toml
"},
);
let config = verctl::config::Config::load(&root.path().join(verctl::config::FILE)).unwrap();
let err =
release::require_changelog_versions(&config, root.path(), [("demo", "1.0.1")]).unwrap_err();
assert!(format!("{err:#}").contains("demo@1.0.1"), "{err:#}");
}
#[test]
fn pr_body_is_the_changelog() {
assert_eq!(release::pr_body(""), "Prepared by verctl.");
let notes = indoc! {"
## demo 1.0.1
- Patch.
"};
assert_eq!(release::pr_body(notes), notes.trim());
}
#[test]
fn named_or_bare_heading_covers_the_version() {
assert!(
release::changelog_section_for(
indoc! {"
# Changelog
## demo 1.0.1
- x
"},
"demo",
"1.0.1",
)
.is_some()
);
assert!(
release::changelog_section_for(
indoc! {"
# Changelog
## 1.0.1
- x
"},
"demo",
"1.0.1",
)
.is_some()
);
assert!(
release::changelog_section_for(
indoc! {"
# Changelog
## demo 1.0.0
- x
"},
"demo",
"1.0.1",
)
.is_none()
);
}
#[test]
fn local_prepare_writes_changelog_and_consumes() {
let root = TempDir::new().unwrap();
common::write_config(
root.path(),
indoc! {"
packages:
- name: demo
path: Cargo.toml
"},
);
fs::write(
root.path().join("Cargo.toml"),
indoc! {r#"
[package]
name = "demo"
version = "1.0.0"
"#},
)
.unwrap();
let changes = root.path().join(".changeset");
fs::create_dir_all(&changes).unwrap();
let frag = changes.join("bump.md");
fs::write(
&frag,
indoc! {"
---
demo: patch
---
Patch.
"},
)
.unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_verctl"))
.current_dir(root.path())
.args(["prepare", "--no-pr"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!frag.exists(),
"prepare consumes fragments after writing CHANGELOG"
);
let log = fs::read_to_string(root.path().join("CHANGELOG.md")).unwrap();
assert!(log.contains("# Changelog"), "{log}");
assert!(log.contains("## demo 1.0.1"), "{log}");
assert!(log.contains("Patch."), "{log}");
}
#[expect(
clippy::unwrap_used,
reason = "fixture setup outside a #[test]: a broken fixture fails the test"
)]
fn commit_all(repo: &git2::Repository, subject: &str) -> git2::Oid {
let sig = git2::Signature::now("t", "t@example.com").unwrap();
let mut index = repo.index().unwrap();
index
.add_all(["."].iter(), git2::IndexAddOption::DEFAULT, None)
.unwrap();
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let parent = repo.head().ok().and_then(|head| head.peel_to_commit().ok());
let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, subject, &tree, &parents)
.unwrap()
}
#[test]
fn prepare_links_the_pull_request_or_commit_that_added_each_fragment() {
let root = TempDir::new().unwrap();
common::write_config(
root.path(),
indoc! {"
packages:
- name: demo
path: Cargo.toml
"},
);
fs::write(
root.path().join("Cargo.toml"),
indoc! {r#"
[package]
name = "demo"
version = "1.0.0"
"#},
)
.unwrap();
let repo = git2::Repository::init(root.path()).unwrap();
repo.remote("origin", "https://github.com/acme/demo.git")
.unwrap();
commit_all(&repo, "chore: start");
let changes = root.path().join(".changeset");
fs::create_dir_all(&changes).unwrap();
fs::write(
changes.join("parser.md"),
indoc! {"
---
demo: patch
---
Fix the parser
"},
)
.unwrap();
commit_all(&repo, "fix: the parser (#12)");
fs::write(
changes.join("direct.md"),
indoc! {"
---
demo: patch
---
Fix the reader
"},
)
.unwrap();
let direct = commit_all(&repo, "fix: the reader").to_string();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_verctl"))
.current_dir(root.path())
.env_remove("GITHUB_TOKEN")
.env_remove("GH_TOKEN")
.args(["prepare", "--no-pr"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let log = fs::read_to_string(root.path().join("CHANGELOG.md")).unwrap();
assert!(
log.contains("- Fix the parser ([#12](https://github.com/acme/demo/pull/12))."),
"{log}"
);
let short = &direct[..7];
assert!(
log.contains(&format!(
"- Fix the reader ([`{short}`](https://github.com/acme/demo/commit/{direct}))."
)),
"{log}"
);
}
fn demo_plan() -> [PlanEntry; 1] {
[PlanEntry {
name: "demo".into(),
from: "1.0.0".into(),
to: "1.0.1".into(),
bump: Bump::Patch,
path: std::path::PathBuf::from("Cargo.toml"),
driver: verctl::driver::Driver::Path {
format: verctl::driver::Format::Toml,
keys: vec!["package.version".into()],
after: None,
},
}]
}
fn demo_fragment(summary: &str, group: Option<Group>) -> Fragment {
Fragment {
path: std::path::PathBuf::from(format!("{summary}.md")),
packages: vec![PackageBump {
name: "demo".into(),
bump: Bump::Patch,
}],
summary: summary.into(),
group,
}
}
#[test]
fn a_section_with_no_group_stays_flat() {
let fragments = [demo_fragment("First", None), demo_fragment("Second", None)];
assert_eq!(
release::changelog_sections(&demo_plan(), &fragments).unwrap(),
indoc! {"
## demo 1.0.1
- First.
- Second.
"}
);
}
#[test]
fn an_ungrouped_fragment_lands_in_the_default_group() {
let fragments = [
demo_fragment("Fix the parser", Some(Group::Fixed)),
demo_fragment("Rename a flag", None),
demo_fragment("Add a verb", Some(Group::Added)),
];
assert_eq!(
release::changelog_sections(&demo_plan(), &fragments).unwrap(),
indoc! {"
## demo 1.0.1
### Added
- Add a verb.
### Changed
- Rename a flag.
### Fixed
- Fix the parser.
"}
);
}