use anyhow::{Context, Result, bail};
use clap::ValueEnum;
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item, Value};
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum VersionBump {
Patch,
Minor,
}
pub fn bump_repo_version(path: &Path, bump: VersionBump) -> Result<String> {
let root = find_workspace_root(path)?;
let workspace_cargo_toml = root.join("Cargo.toml");
let cli_cargo_toml = root.join("crates/wwwhat-cli/Cargo.toml");
let current = read_workspace_version(&workspace_cargo_toml)?;
let next = bump_version(¤t, bump)?;
write_workspace_version(&workspace_cargo_toml, &next)?;
write_cli_core_version(&cli_cargo_toml, &next)?;
for website_config in [
root.join("examples/demo/site/application.what"),
root.join("crates/wwwhat-cli/assets/demo/pages/application.what"),
] {
if website_config.exists() {
upsert_what_key(&website_config, "framework_version", &next)?;
upsert_what_key(&website_config, "release_channel", "experimental")?;
}
}
Ok(next)
}
fn find_workspace_root(start: &Path) -> Result<PathBuf> {
let canonical = fs::canonicalize(start)
.with_context(|| format!("Path does not exist: {}", start.display()))?;
let mut current = canonical.as_path();
loop {
let candidate = current.join("Cargo.toml");
if candidate.exists() {
let content = fs::read_to_string(&candidate)?;
if content.contains("[workspace]") {
return Ok(current.to_path_buf());
}
}
current = current.parent().ok_or_else(|| {
anyhow::anyhow!(
"Could not find workspace root starting from {}",
canonical.display()
)
})?;
}
}
fn read_workspace_version(path: &Path) -> Result<String> {
let content = fs::read_to_string(path)?;
let doc = content
.parse::<DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
doc["workspace"]["package"]["version"]
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| anyhow::anyhow!("workspace.package.version missing in {}", path.display()))
}
fn write_workspace_version(path: &Path, version: &str) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut doc = content
.parse::<DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
doc["workspace"]["package"]["version"] = toml_edit::value(version);
fs::write(path, doc.to_string())?;
Ok(())
}
fn write_cli_core_version(path: &Path, version: &str) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut doc = content
.parse::<DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
match &mut doc["dependencies"]["wwwhat-core"] {
Item::Value(Value::InlineTable(table)) => {
table.insert("version", Value::from(version));
}
Item::Table(table) => {
table["version"] = toml_edit::value(version);
}
other => {
bail!(
"Unexpected wwwhat-core dependency format in {}: {:?}",
path.display(),
other.type_name()
);
}
}
fs::write(path, doc.to_string())?;
Ok(())
}
fn bump_version(current: &str, bump: VersionBump) -> Result<String> {
let mut parts = current.split('.');
let major = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Invalid version: {}", current))?
.parse::<u64>()
.with_context(|| format!("Invalid version: {}", current))?;
let minor = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Invalid version: {}", current))?
.parse::<u64>()
.with_context(|| format!("Invalid version: {}", current))?;
let patch = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Invalid version: {}", current))?
.parse::<u64>()
.with_context(|| format!("Invalid version: {}", current))?;
if parts.next().is_some() {
bail!("Invalid version: {}", current);
}
let next = match bump {
VersionBump::Patch => (major, minor, patch + 1),
VersionBump::Minor => (major, minor + 1, 0),
};
Ok(format!("{}.{}.{}", next.0, next.1, next.2))
}
fn upsert_what_key(path: &Path, key: &str, value: &str) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut lines = Vec::new();
let mut found = false;
let prefix = format!("{key} =");
let replacement = format!(r#"{key} = "{value}""#);
for line in content.lines() {
if line.trim_start().starts_with(&prefix) {
lines.push(replacement.clone());
found = true;
} else {
lines.push(line.to_string());
}
}
if !found {
if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
lines.push(String::new());
}
lines.push(replacement);
}
fs::write(path, format!("{}\n", lines.join("\n")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn patch_bump_increments_patch_only() {
assert_eq!(bump_version("0.9.8", VersionBump::Patch).unwrap(), "0.9.9");
}
#[test]
fn minor_bump_resets_patch() {
assert_eq!(bump_version("0.9.8", VersionBump::Minor).unwrap(), "0.10.0");
}
#[test]
fn upsert_what_key_replaces_existing_value() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("application.what");
fs::write(
&path,
"layout = \"components/site-layout.html\"\nframework_version = \"0.9.8\"\n",
)
.unwrap();
upsert_what_key(&path, "framework_version", "0.9.9").unwrap();
let updated = fs::read_to_string(&path).unwrap();
assert!(updated.contains("framework_version = \"0.9.9\""));
}
#[test]
fn write_cli_core_version_updates_inline_table_dependency() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
fs::write(
&path,
r#"[dependencies]
wwwhat-core = { version = "0.9.8", path = "../wwwhat-core" }
"#,
)
.unwrap();
write_cli_core_version(&path, "0.9.9").unwrap();
let updated = fs::read_to_string(&path).unwrap();
assert!(updated.contains(r#"version = "0.9.9""#));
assert!(updated.contains(r#"path = "../wwwhat-core""#));
}
}