use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fmt;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail, ensure};
use ctl_core::input::Input;
use ctl_core::validate::{at_least_one, cannot_be_empty, inside_the_repo};
use garde::Validate;
use serde::Deserialize;
use crate::driver::{CommandSpec, Driver, Format};
use crate::publisher::PublisherSpec;
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum CommandField {
Mise(String),
Argv(Vec<String>),
}
impl CommandField {
fn into_spec(self) -> Result<CommandSpec> {
match self {
Self::Mise(task) => {
if task.is_empty() {
bail!("mise task name is empty");
}
Ok(CommandSpec::Mise(task))
}
Self::Argv(argv) => {
ensure!(!argv.is_empty(), "driver argv is empty");
Ok(CommandSpec::Argv(argv))
}
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DriverSpec {
pub format: Option<String>,
pub keys: Option<Vec<String>>,
pub read: Option<CommandField>,
pub write: Option<CommandField>,
pub after: Option<String>,
}
impl DriverSpec {
pub fn into_driver(self, name: &str) -> Result<Driver> {
if let (Some(read), Some(write)) = (self.read, self.write) {
return Ok(Driver::Command {
read: read.into_spec()?,
write: write.into_spec()?,
after: self.after,
});
}
let format = match self.format.as_deref() {
Some("toml") => Format::Toml,
Some("json") => Format::Json,
Some(other) => bail!("driver {name:?} has unknown format {other:?}"),
None => bail!("driver {name:?} needs format+keys or read+write"),
};
let keys = self
.keys
.filter(|keys| !keys.is_empty())
.with_context(|| format!("driver {name:?} needs keys"))?;
Ok(Driver::Path {
format,
keys,
after: self.after,
})
}
fn stock(name: &str) -> Option<Self> {
match name {
"cargo" => Some(Self {
format: Some("toml".into()),
keys: Some(vec![
"workspace.package.version".into(),
"package.version".into(),
]),
..Self::default()
}),
"npm" => Some(Self {
format: Some("json".into()),
keys: Some(vec!["version".into()]),
..Self::default()
}),
_ => None,
}
}
fn merge(self, over: Self) -> Self {
Self {
format: over.format.or(self.format),
keys: over.keys.or(self.keys),
read: over.read.or(self.read),
write: over.write.or(self.write),
after: over.after.or(self.after),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct PackageSpec {
pub name: String,
pub path: PathBuf,
pub driver: Option<String>,
pub publisher: Option<String>,
pub noun: Option<String>,
#[serde(default, rename = "publish")]
pub publish_argv: Option<Vec<String>>,
pub registry: Option<String>,
pub changelog: Option<PathBuf>,
#[serde(default)]
pub first_minor: bool,
#[serde(flatten)]
pub spec: DriverSpec,
}
impl PackageSpec {
#[must_use]
pub fn changelog_path(&self, root: &Path) -> PathBuf {
if let Some(path) = &self.changelog {
return root.join(path);
}
root.join(&self.path)
.parent()
.unwrap_or(root)
.join("CHANGELOG.md")
}
pub fn resolve(&self, config: &Config, root: &Path) -> Result<Driver> {
let inferred = self
.path
.file_name()
.and_then(|name| name.to_str())
.and_then(stock_name_for_file);
let driver_name = self.driver.as_deref().or(inferred);
let mut spec = driver_name
.and_then(|name| config.driver_spec(name))
.unwrap_or_default();
spec = spec.merge(self.spec.clone());
if spec.after.is_none() {
spec.after = crate::detect::follow_up(&root.join(&self.path));
}
spec.into_driver(driver_name.unwrap_or(self.name.as_str()))
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Validate)]
#[serde(deny_unknown_fields)]
#[garde(context(Config))]
pub struct Runner {
#[garde(custom(at_least_one("label")))]
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Job {
pub runners: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Assets {
pub bin: Option<String>,
pub prepare: Option<Vec<String>>,
pub build: Option<Vec<String>>,
pub binary: Option<String>,
#[serde(flatten)]
pub targets: BTreeMap<String, AssetTarget>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct AssetTarget {
pub runner: Option<String>,
pub os: Option<String>,
pub arch: Option<String>,
pub triple: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default, Validate)]
#[serde(deny_unknown_fields)]
#[garde(context(Self as config))]
#[garde(allow_unvalidated)]
pub struct Config {
#[serde(default)]
pub drivers: BTreeMap<String, DriverSpec>,
#[garde(custom(at_least_one("package")))]
pub packages: Vec<PackageSpec>,
#[serde(default)]
pub publishers: BTreeMap<String, PublisherSpec>,
#[serde(default)]
#[garde(dive)]
pub runners: BTreeMap<String, Runner>,
#[serde(default)]
pub assets: Option<Assets>,
#[serde(default)]
pub ci: BTreeMap<String, Job>,
#[serde(default)]
pub prepare: Prepare,
#[serde(default)]
#[garde(dive)]
pub templates: Templates,
#[serde(default)]
#[garde(dive)]
pub tags: Tags,
#[serde(default)]
#[garde(custom(every_pattern_says_where_the_version_goes))]
#[garde(custom(every_pattern_is_listed))]
pub patterns: BTreeMap<String, PinPattern>,
#[serde(default)]
#[garde(dive)]
pub pins: Vec<Pin>,
}
fn every_pattern_says_where_the_version_goes(
patterns: &BTreeMap<String, PinPattern>,
_: &Config,
) -> garde::Result {
for (id, pattern) in patterns {
let found = pattern.r#match.matches(PLACEHOLDER).count();
if found != 1 {
return Err(garde::Error::new(format!(
"{id}: match says {PLACEHOLDER} {found} times, and must say it once"
)));
}
}
Ok(())
}
fn every_pattern_is_listed(
patterns: &BTreeMap<String, PinPattern>,
config: &Config,
) -> garde::Result {
let listed: BTreeSet<&str> = config
.pins
.iter()
.flat_map(|pin| pin.pattern_ids.iter().map(String::as_str))
.collect();
for id in patterns.keys() {
if !listed.contains(id.as_str()) {
return Err(garde::Error::new(format!(
"{id:?} is declared and no pin lists it"
)));
}
}
Ok(())
}
#[derive(Debug, Clone, Deserialize, Validate)]
#[garde(context(Config as config))]
#[garde(allow_unvalidated)]
pub struct Pin {
#[garde(custom(inside_the_repo))]
pub file: PathBuf,
pub tool: Option<String>,
#[serde(default, rename = "patterns")]
#[garde(
custom(each_id_is_declared),
custom(no_id_is_listed_twice),
custom(something_to_rewrite(self.tool.is_some()))
)]
pub pattern_ids: Vec<String>,
#[serde(skip)]
pub patterns: Vec<PinPattern>,
pub package: String,
}
fn each_id_is_declared(ids: &[String], config: &Config) -> garde::Result {
for id in ids {
if !config.patterns.contains_key(id) {
return Err(garde::Error::new(format!("no patterns.{id}")));
}
}
Ok(())
}
fn no_id_is_listed_twice(ids: &[String], _: &Config) -> garde::Result {
let mut seen = BTreeSet::new();
for id in ids {
if !seen.insert(id.as_str()) {
return Err(garde::Error::new(format!("lists {id:?} twice")));
}
}
Ok(())
}
fn something_to_rewrite(has_tool: bool) -> impl Fn(&[String], &Config) -> garde::Result {
move |ids, _| {
if has_tool || !ids.is_empty() {
return Ok(());
}
Err(garde::Error::new("needs a tool or a pattern to rewrite"))
}
}
pub const DIR: &str = ".ctl";
pub const FILE: &str = ".ctl/ver.yaml";
#[must_use]
pub fn root_of(config: &Path) -> PathBuf {
let dir = here(config.parent());
if dir.file_name() == Some(OsStr::new(DIR)) {
return here(dir.parent()).to_path_buf();
}
dir.to_path_buf()
}
fn here(path: Option<&Path>) -> &Path {
path.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
pub const PLACEHOLDER: &str = "{version}";
pub const NAME_PLACEHOLDER: &str = "{name}";
#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
#[garde(context(Config))]
pub struct Tags {
#[serde(default = "Tags::default_template")]
#[garde(
custom(cannot_be_empty(
"it is the shape of every release tag, like \"v{version}\" or \"{name}@{version}\""
)),
custom(tag_template_placeholders)
)]
pub template: String,
}
impl Tags {
fn default_template() -> String {
format!("v{PLACEHOLDER}")
}
#[must_use]
pub fn uses_name(&self) -> bool {
self.template.contains(NAME_PLACEHOLDER)
}
pub fn render(&self, version: &str, name: Option<&str>) -> Result<String> {
let mut out = self.template.replace(PLACEHOLDER, version);
if let Some(name) = name {
out = out.replace(NAME_PLACEHOLDER, name);
} else if out.contains(NAME_PLACEHOLDER) {
bail!(
"tags.template {:?} needs {NAME_PLACEHOLDER} but no package name was given",
self.template
);
}
ensure!(
!out.contains('{') && !out.contains('}'),
"tags.template {:?} still has braces after substitution",
self.template
);
Ok(out)
}
}
impl Default for Tags {
fn default() -> Self {
Self {
template: Self::default_template(),
}
}
}
fn tag_template_placeholders(template: &str, _: &Config) -> garde::Result {
let mut saw_version = false;
let mut saw_name = false;
let mut rest = template;
while let Some(start) = rest.find('{') {
let after = &rest[start + 1..];
let Some(end) = after.find('}') else {
return Err(garde::Error::new("has an opening '{' with no closing '}'"));
};
let name = &after[..end];
match name {
"version" => saw_version = true,
"name" => saw_name = true,
_ => {
return Err(garde::Error::new(format!(
"names {{{name}}}, and the only placeholders are {PLACEHOLDER} and {NAME_PLACEHOLDER}"
)));
}
}
rest = &after[end + 1..];
}
if !saw_version && !saw_name {
return Err(garde::Error::new(format!(
"has no placeholder — put {PLACEHOLDER} where the version goes"
)));
}
if !saw_version {
return Err(garde::Error::new(format!(
"must say {PLACEHOLDER} — a tag with only {NAME_PLACEHOLDER} cannot date a release"
)));
}
let leftover = template
.replace(PLACEHOLDER, "")
.replace(NAME_PLACEHOLDER, "");
if leftover.contains('{') || leftover.contains('}') {
return Err(garde::Error::new(format!(
"has a '{{' or '}}' that is not {PLACEHOLDER} or {NAME_PLACEHOLDER}"
)));
}
let _ = saw_name;
Ok(())
}
#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
#[garde(context(Config))]
pub struct Templates {
#[serde(default = "Templates::default_source")]
#[garde(custom(inside_the_repo))]
pub source: PathBuf,
#[serde(default = "Templates::default_suffix")]
#[garde(custom(cannot_be_empty(
"it is what marks a file in the source tree as a template, like \".jinja\""
)))]
pub suffix: String,
}
impl Templates {
fn default_source() -> PathBuf {
Path::new(DIR).join("templates")
}
fn default_suffix() -> String {
".jinja".to_owned()
}
}
impl Default for Templates {
fn default() -> Self {
Self {
source: Self::default_source(),
suffix: Self::default_suffix(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct PinPattern {
pub r#match: String,
#[serde(default)]
pub occurrences: Occurrences,
#[serde(default)]
pub whole_line: bool,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum Occurrences {
#[default]
Once,
Many,
Never,
Exactly(usize),
AtLeast(usize),
}
const ARITY: &str = "once, many, never, {exactly: N}, or {at_least: N}";
impl<'de> Deserialize<'de> for Occurrences {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_any(OccurrencesVisitor)
}
}
struct OccurrencesVisitor;
impl<'de> serde::de::Visitor<'de> for OccurrencesVisitor {
type Value = Occurrences;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{ARITY}")
}
fn visit_str<E: serde::de::Error>(self, word: &str) -> Result<Occurrences, E> {
match word {
"once" => Ok(Occurrences::Once),
"many" => Ok(Occurrences::Many),
"never" => Ok(Occurrences::Never),
_ => Err(E::custom(format!("must be {ARITY}"))),
}
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut bound: A,
) -> Result<Occurrences, A::Error> {
use serde::de::Error;
let wrong = || A::Error::custom(format!("must be {ARITY}"));
let key: String = bound.next_key()?.ok_or_else(wrong)?;
let count: usize = bound.next_value().map_err(|_| wrong())?;
if bound.next_key::<serde::de::IgnoredAny>()?.is_some() {
return Err(wrong());
}
match key.as_str() {
"exactly" => Ok(Occurrences::Exactly(count)),
"at_least" => Ok(Occurrences::AtLeast(count)),
_ => Err(wrong()),
}
}
}
impl Occurrences {
#[must_use]
pub const fn allows(self, found: usize) -> bool {
match self {
Self::Once => found == 1,
Self::Many => found >= 1,
Self::Never => found == 0,
Self::Exactly(count) => found == count,
Self::AtLeast(count) => found >= count,
}
}
}
impl fmt::Display for Occurrences {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Once => formatter.write_str("exactly once"),
Self::Many => formatter.write_str("one or more"),
Self::Never => formatter.write_str("never"),
Self::Exactly(count) => write!(formatter, "exactly {count} times"),
Self::AtLeast(count) => write!(formatter, "{count} or more times"),
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Prepare {
pub after: Option<Vec<String>>,
#[serde(default)]
pub stage: Vec<String>,
#[serde(default)]
pub stage_ignored: bool,
pub version_label: Option<String>,
}
impl Prepare {
pub const DEFAULT_VERSION_LABEL: &'static str = "verctl:version";
#[must_use]
pub fn version_label(&self) -> &str {
self.version_label
.as_deref()
.filter(|label| !label.is_empty())
.unwrap_or(Self::DEFAULT_VERSION_LABEL)
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
Self::from_input(&Input::read(path)?)
}
pub fn parse(raw: &str) -> Result<Self> {
Self::from_input(&Input::new(".ctl/ver.yaml", raw))
}
fn from_input(input: &Input) -> Result<Self> {
let mut config: Self = input.parse()?;
input.check(&config, &config)?;
config.resolve_patterns();
Ok(config)
}
fn resolve_patterns(&mut self) {
let declared = self.patterns.clone();
for pin in &mut self.pins {
pin.patterns = pin
.pattern_ids
.iter()
.filter_map(|id| declared.get(id).cloned())
.collect();
}
}
pub fn find(&self, name: &str) -> Result<&PackageSpec> {
self.packages
.iter()
.find(|package| package.name == name)
.with_context(|| format!("package {name:?} is not in verctl config"))
}
#[must_use]
pub fn driver_spec(&self, name: &str) -> Option<DriverSpec> {
match (DriverSpec::stock(name), self.drivers.get(name)) {
(Some(stock), Some(over)) => Some(stock.merge(over.clone())),
(Some(stock), None) => Some(stock),
(None, Some(over)) => Some(over.clone()),
(None, None) => None,
}
}
}
fn stock_name_for_file(file_name: &str) -> Option<&'static str> {
match file_name {
"Cargo.toml" => Some("cargo"),
"package.json" => Some("npm"),
_ => None,
}
}
pub(crate) fn stock_driver(name: &str) -> Result<Driver> {
DriverSpec::stock(name)
.with_context(|| format!("unknown stock driver {name:?}"))?
.into_driver(name)
}
#[cfg(test)]
mod tests {
use indoc::{formatdoc, indoc};
use super::*;
fn load(body: &str) -> Result<Config> {
Config::parse(body)
}
const PACKAGE: &str = indoc! {"
packages:
- name: demo
path: Cargo.toml
"};
#[test]
fn the_root_is_the_directory_holding_the_ctl_dir() {
let cases = [
(".ctl/ver.yaml", "."),
("crates/foo/.ctl/ver.yaml", "crates/foo"),
("/srv/repo/.ctl/ver.yaml", "/srv/repo"),
("ver.yaml", "."),
("/tmp/scratch/ver.yaml", "/tmp/scratch"),
];
for (config, expected) in cases {
assert_eq!(root_of(Path::new(config)), Path::new(expected), "{config}");
}
}
#[test]
fn every_occurrence_form_parses_and_means_what_it_says() {
let config = load(&formatdoc! {r#"
{PACKAGE}
patterns:
omitted:
match: "a@{{version}}"
once:
match: "b@{{version}}"
occurrences: once
many:
match: "c@{{version}}"
occurrences: many
never:
match: "d@{{version}}"
occurrences: never
twice:
match: "e@{{version}}"
occurrences: {{exactly: 2}}
floor:
match: "f@{{version}}"
occurrences: {{at_least: 2}}
pins:
- file: README.md
package: demo
patterns: [omitted, once, many, never, twice, floor]
"#})
.unwrap();
let allowed: Vec<Vec<usize>> = config.pins[0]
.patterns
.iter()
.map(|pattern| (0..4).filter(|n| pattern.occurrences.allows(*n)).collect())
.collect();
assert_eq!(
allowed,
[
vec![1], vec![1], vec![1, 2, 3], vec![0], vec![2], vec![2, 3], ]
);
}
#[test]
fn a_misspelled_section_stops_the_load() {
let error = load(&formatdoc! {r#"
{PACKAGE}
pattern:
install:
match: "demo@{{version}}"
"#})
.expect_err("pattern is not a section");
assert!(format!("{error:#}").contains("pattern"), "{error:#}");
}
#[test]
fn a_bound_with_a_second_key_is_not_the_bound_it_starts_with() {
let error = load(&formatdoc! {r#"
{PACKAGE}
patterns:
wrong:
match: "a@{{version}}"
occurrences: {{exactly: 2, at_leats: 3}}
pins:
- file: README.md
package: demo
patterns: [wrong]
"#})
.expect_err("at_leats is not a key");
assert!(format!("{error:#}").contains(ARITY), "{error:#}");
}
#[test]
fn a_bound_with_a_bad_count_names_the_arity() {
for count in ["-1", "many"] {
let error = load(&formatdoc! {r#"
{PACKAGE}
patterns:
wrong:
match: "a@{{version}}"
occurrences: {{exactly: {count}}}
pins:
- file: README.md
package: demo
patterns: [wrong]
"#})
.expect_err("the count is not a whole number");
assert!(format!("{error:#}").contains(ARITY), "{count}: {error:#}");
}
}
#[test]
fn an_unknown_arity_is_not_silently_a_default() {
let broken = load(&formatdoc! {r#"
{PACKAGE}
patterns:
wrong:
match: "a@{{version}}"
occurrences: sometimes
pins:
- file: README.md
package: demo
patterns: [wrong]
"#});
assert!(broken.is_err());
}
#[test]
fn one_named_pattern_serves_every_file_that_lists_it() {
let config = load(&formatdoc! {r#"
{PACKAGE}
patterns:
install:
match: "demo@{{version}}"
mention:
match: "v{{version}}"
occurrences: many
pins:
- file: README.md
package: demo
patterns: [install, mention]
- file: docs/install.md
package: demo
patterns: [install]
"#})
.unwrap();
let carried: Vec<Vec<&str>> = config
.pins
.iter()
.map(|pin| {
pin.patterns
.iter()
.map(|pattern| pattern.r#match.as_str())
.collect()
})
.collect();
assert_eq!(
carried,
[vec!["demo@{version}", "v{version}"], vec!["demo@{version}"]],
"each file carries the patterns it lists, in the order it lists them"
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "one case per rule; a table, not logic"
)]
fn a_config_that_breaks_a_rule_stops_the_load() {
let cases = [
(
"an id no patterns section declares",
formatdoc! {r#"
{PACKAGE}
patterns:
install:
match: "demo@{{version}}"
pins:
- file: README.md
package: demo
patterns: [install, instal]
"#},
"pins[0].pattern_ids: no patterns.instal",
),
(
"the same id listed twice",
formatdoc! {r#"
{PACKAGE}
patterns:
install:
match: "demo@{{version}}"
pins:
- file: README.md
package: demo
patterns: [install, install]
"#},
r#"pins[0].pattern_ids: lists "install" twice"#,
),
(
"a pattern no pin lists",
formatdoc! {r#"
{PACKAGE}
patterns:
install:
match: "demo@{{version}}"
orphan:
match: "old@{{version}}"
pins:
- file: README.md
package: demo
patterns: [install]
"#},
r#"patterns: "orphan" is declared and no pin lists it"#,
),
(
"a pin with nothing to rewrite",
formatdoc! {"
{PACKAGE}
pins:
- file: README.md
package: demo
"},
"pins[0].pattern_ids: needs a tool or a pattern to rewrite",
),
(
"a spelling with nowhere to put the version",
formatdoc! {r#"
{PACKAGE}
patterns:
install:
match: "demo@1.2.3"
pins:
- file: README.md
package: demo
patterns: [install]
"#},
"patterns: install: match says {version} 0 times",
),
(
"a pin reaching out of the repository",
formatdoc! {r#"
{PACKAGE}
patterns:
install:
match: "demo@{{version}}"
pins:
- file: ../elsewhere/README.md
package: demo
patterns: [install]
"#},
"pins[0].file: must stay inside the repository",
),
(
"a machine no label can find",
formatdoc! {"
{PACKAGE}
runners:
ghost:
labels: []
"},
"runners.ghost.labels: must declare at least one label",
),
(
"a suffix that makes every file a template",
formatdoc! {r#"
{PACKAGE}
templates:
suffix: ""
"#},
"templates.suffix: cannot be empty — it is what marks a file",
),
(
"a tag template naming a placeholder that does not exist",
formatdoc! {r#"
{PACKAGE}
tags:
template: "v{{ver}}"
"#},
"tags.template: names {ver}, and the only placeholders are {version} and {name}",
),
(
"a tag template with no placeholder",
formatdoc! {r#"
{PACKAGE}
tags:
template: "latest"
"#},
"tags.template: has no placeholder",
),
(
"a tag template with only the package name",
formatdoc! {r#"
{PACKAGE}
tags:
template: "{{name}}"
"#},
"tags.template: must say {version}",
),
(
"an empty tag template",
formatdoc! {r#"
{PACKAGE}
tags:
template: ""
"#},
"tags.template: cannot be empty",
),
(
"a tag template with a leftover brace",
formatdoc! {r#"
{PACKAGE}
tags:
template: "v{{version}}}}"
"#},
"tags.template: has a '{' or '}' that is not {version} or {name}",
),
];
for (scenario, body, expected) in cases {
let error = load(&body).expect_err(scenario);
assert!(
format!("{error:#}").contains(expected),
"{scenario}: {error:#}"
);
}
}
}