use clap::{Args, Subcommand, ValueEnum};
use std::path::Path;
#[derive(Args)]
pub struct VersionArgs {
#[command(subcommand)]
pub command: Option<VersionCommand>,
}
#[derive(Subcommand)]
pub enum VersionCommand {
Bump(VersionBumpArgs),
}
#[derive(Args)]
pub struct VersionBumpArgs {
#[arg(value_enum, default_value_t = VersionLevel::Patch)]
pub level: VersionLevel,
#[arg(short, long, default_value = ".")]
pub dir: String,
}
#[derive(Clone, Copy, ValueEnum)]
pub enum VersionLevel {
Major,
Minor,
Patch,
}
pub fn run(args: VersionArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
Some(VersionCommand::Bump(args)) => bump(args),
None => {
println!("nativ {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
}
}
fn bump(args: VersionBumpArgs) -> Result<(), Box<dyn std::error::Error>> {
let config_path = Path::new(&args.dir).join("nativ.toml");
let config = nativ_config::NativConfig::load(&config_path)?;
let new_version = bumped_version(&config.app.version, args.level)?;
let content = std::fs::read_to_string(&config_path)?;
let updated = write_app_version(&content, &new_version)?;
std::fs::write(&config_path, updated)?;
println!("Version bumped: {} -> {new_version}", config.app.version);
Ok(())
}
fn bumped_version(version: &str, level: VersionLevel) -> Result<String, String> {
let mut parts = version
.split('.')
.map(|part| {
part.parse::<u32>()
.map_err(|_| format!("invalid version: {version}"))
})
.collect::<Result<Vec<_>, _>>()?;
while parts.len() < 3 {
parts.push(0);
}
match level {
VersionLevel::Major => {
parts[0] += 1;
parts[1] = 0;
parts[2] = 0;
}
VersionLevel::Minor => {
parts[1] += 1;
parts[2] = 0;
}
VersionLevel::Patch => {
parts[2] += 1;
}
}
Ok(format!("{}.{}.{}", parts[0], parts[1], parts[2]))
}
fn write_app_version(content: &str, version: &str) -> Result<String, String> {
let mut out = Vec::new();
let mut in_app = false;
let mut saw_app = false;
let mut wrote = false;
for line in content.lines() {
let trimmed = line.trim();
if in_app && trimmed.starts_with('[') && trimmed.ends_with(']') {
if !wrote {
out.push(format!("version = \"{version}\""));
wrote = true;
}
in_app = false;
}
if trimmed == "[app]" {
in_app = true;
saw_app = true;
}
if in_app && is_version_line(line) {
let trimmed_start = line.trim_start();
let indent = &line[..line.len() - trimmed_start.len()];
out.push(format!("{indent}version = \"{version}\""));
wrote = true;
continue;
}
out.push(line.to_string());
}
if saw_app && in_app && !wrote {
out.push(format!("version = \"{version}\""));
}
if !saw_app {
return Err("[app] section not found in nativ.toml".into());
}
let mut updated = out.join("\n");
if content.ends_with('\n') {
updated.push('\n');
}
Ok(updated)
}
fn is_version_line(line: &str) -> bool {
let Some(rest) = line.trim_start().strip_prefix("version") else {
return false;
};
rest.trim_start().starts_with('=')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_command_never_fails() {
assert!(run(VersionArgs { command: None }).is_ok());
}
#[test]
fn bumps_semver_components() {
assert_eq!(
bumped_version("1.2.3", VersionLevel::Patch).unwrap(),
"1.2.4"
);
assert_eq!(
bumped_version("1.2.3", VersionLevel::Minor).unwrap(),
"1.3.0"
);
assert_eq!(
bumped_version("1.2.3", VersionLevel::Major).unwrap(),
"2.0.0"
);
assert_eq!(bumped_version("1.2", VersionLevel::Patch).unwrap(), "1.2.1");
}
#[test]
fn rewrites_or_inserts_app_version() {
assert!(
write_app_version("[app]\nname = \"Demo\"\nversion = \"1.0.0\"\n", "1.0.1")
.unwrap()
.contains("version = \"1.0.1\"")
);
assert_eq!(
write_app_version("[app]\nname = \"Demo\"\n[build]\nios = true\n", "0.1.1").unwrap(),
"[app]\nname = \"Demo\"\nversion = \"0.1.1\"\n[build]\nios = true\n"
);
}
}