run-what 0.94.1

HTML-first web framework powered by Rust. No JavaScript frameworks, no build steps—just HTML.
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(&current, 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""#));
    }
}