xecute-cli 0.1.15

Project and resource generator for Xecute
//! Preserve Cargo manifest syntax while adding generated runtime and test dependencies.

use toml_edit::{Array, DocumentMut, InlineTable, Item, Value, value};

fn upgrade_runtime(item: &mut Item) {
    let old = |version: &str| {
        matches!(
            version,
            "0.1.0" | "0.1.1" | "0.1.2" | "0.1.3" | "0.1.4" | "0.1.5" | "0.1.6"
        )
    };
    if item.as_str().is_some_and(old) {
        *item = value("0.1.7");
    } else if let Some(table) = item.as_table_like_mut()
        && table.get("version").and_then(Item::as_str).is_some_and(old)
    {
        table.insert("version", value("0.1.7"));
    }
}
fn enable_testing(item: &mut Item) -> Result<(), String> {
    if let Some(version) = item.as_str() {
        let mut table = InlineTable::new();
        table.insert("version", Value::from(version));
        *item = Item::Value(Value::InlineTable(table));
    }
    let table = item
        .as_table_like_mut()
        .ok_or("xecute dependency must be a version or table")?;
    if !table.contains_key("features") {
        table.insert("features", value(Array::new()));
    }
    let features = table
        .get_mut("features")
        .and_then(Item::as_array_mut)
        .ok_or("xecute features must be an array")?;
    if !features
        .iter()
        .any(|feature| feature.as_str() == Some("testing"))
    {
        features.push("testing");
    }
    if let Some(inline) = item.as_inline_table_mut() {
        inline.fmt();
    }
    Ok(())
}

pub(crate) fn testing_dependency(declaration: &str) -> Result<String, String> {
    let mut document = declaration
        .parse::<DocumentMut>()
        .map_err(|_| "invalid runtime declaration")?;
    enable_testing(&mut document["xecute"])?;
    Ok(document.to_string().trim().to_owned())
}

pub(crate) fn resource_dependencies(manifest: &str) -> Result<String, String> {
    let mut document = manifest
        .parse::<DocumentMut>()
        .map_err(|_| "Cargo.toml is invalid; no files changed")?;
    let dependencies = document
        .get_mut("dependencies")
        .and_then(Item::as_table_like_mut)
        .ok_or("Cargo.toml is missing [dependencies]")?;
    if !dependencies.contains_key("validator") {
        let mut validator = InlineTable::new();
        validator.insert("version", "0.20".into());
        let mut features = Array::new();
        features.push("derive");
        validator.insert("features", features.into());
        validator.fmt();
        dependencies.insert("validator", Item::Value(Value::InlineTable(validator)));
    }
    if !dependencies.contains_key("schemars") {
        dependencies.insert("schemars", value("1.2"));
    }
    let runtime = dependencies
        .get_mut("xecute")
        .ok_or("Cargo.toml is missing the xecute dependency")?;
    upgrade_runtime(runtime);
    let mut test_runtime = runtime.clone();
    enable_testing(&mut test_runtime)?;
    if document.get("dev-dependencies").is_none() {
        document["dev-dependencies"] = Item::Table(toml_edit::Table::new());
    }
    let dev = document
        .get_mut("dev-dependencies")
        .and_then(Item::as_table_like_mut)
        .ok_or("invalid [dev-dependencies]")?;
    if let Some(existing) = dev.get_mut("xecute") {
        upgrade_runtime(existing);
        enable_testing(existing)?;
    } else {
        dev.insert("xecute", test_runtime);
    }
    Ok(document.to_string())
}

/// Lift dependency sources into the workspace, retaining member-specific features.
pub(crate) fn workspace_dependencies(
    manifest: &str,
    workspace: &str,
    runtime_override: bool,
) -> Result<(String, String), String> {
    let mut member = manifest.parse::<DocumentMut>().map_err(|e| e.to_string())?;
    let mut root = workspace
        .parse::<DocumentMut>()
        .map_err(|e| e.to_string())?;
    let workspace = root
        .get_mut("workspace")
        .and_then(Item::as_table_like_mut)
        .ok_or("missing [workspace]")?;
    if !workspace.contains_key("dependencies") {
        workspace.insert("dependencies", Item::Table(toml_edit::Table::new()));
    }
    let shared = workspace
        .get_mut("dependencies")
        .and_then(Item::as_table_like_mut)
        .ok_or("invalid [workspace.dependencies]")?;
    for section in ["dependencies", "dev-dependencies"] {
        let Some(dependencies) = member.get_mut(section).and_then(Item::as_table_like_mut) else {
            continue;
        };
        for (key, dependency) in dependencies.iter_mut() {
            let name = key.get();
            let inherited = dependency
                .as_table_like()
                .and_then(|t| t.get("workspace"))
                .and_then(Item::as_bool)
                == Some(true);
            if !inherited
                && (!shared.contains_key(name)
                    || (runtime_override && name == "xecute" && section == "dependencies"))
            {
                let mut source = dependency.clone();
                if let Some(table) = source.as_table_like_mut() {
                    // These apply at the app level, and optional is forbidden at workspace level.
                    table.remove("features");
                    table.remove("optional");
                }
                shared.insert(name, source);
            }
            if !shared.contains_key(name) {
                return Err(format!("workspace.dependencies is missing {name}"));
            }
            let mut reference = InlineTable::new();
            reference.insert("workspace", true.into());
            if let Some(table) = dependency.as_table_like() {
                for key in ["features", "optional"] {
                    if let Some(value) = table.get(key).and_then(Item::as_value) {
                        reference.insert(key, value.clone());
                    }
                }
                // Cargo cannot disable defaults in a member if the workspace enables them.
                if table.get("default-features").and_then(Item::as_bool) == Some(false)
                    && shared
                        .get(name)
                        .and_then(Item::as_table_like)
                        .and_then(|t| t.get("default-features"))
                        .and_then(Item::as_bool)
                        != Some(false)
                {
                    return Err(format!(
                        "set default-features = false for {name} in workspace.dependencies first"
                    ));
                }
            }
            reference.fmt();
            *dependency = Item::Value(Value::InlineTable(reference));
        }
    }
    Ok((member.to_string(), root.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn adds_test_feature_preserves_custom_paths_features_and_comments() {
        for declaration in [
            "xecute = \"0.1.6\"",
            "xecute = { path = \"../runtime\", features = [\"custom\"] }",
            "[dependencies.xecute]\npath = \"../runtime\"",
        ] {
            let input = format!(
                "# keep this\n[package]\nname = \"example\"\n[dependencies]\n{declaration}\n"
            );
            let output = resource_dependencies(&input).unwrap();
            let doc = output.parse::<DocumentMut>().unwrap();
            assert!(output.contains("# keep this"));
            assert!(
                doc["dev-dependencies"]["xecute"]["features"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .any(|value| value.as_str() == Some("testing"))
            );
            assert_eq!(resource_dependencies(&output).unwrap(), output);
            if declaration.contains("path") {
                assert_eq!(
                    doc["dev-dependencies"]["xecute"]["path"].as_str(),
                    Some("../runtime")
                );
            }
        }
    }
    #[test]
    fn workspace_sources_are_shared_and_member_features_are_preserved() {
        let member = r#"
[dependencies]
xecute = { path = "/runtime", features = ["custom"] }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
xecute = { path = "/runtime", features = ["testing"] }
"#;
        let root =
            "[workspace]\nmembers = [\"apps/*\"]\n[workspace.dependencies]\nserde = \"=1.0.228\"\n";
        let (member, root) = workspace_dependencies(member, root, false).unwrap();
        let doc = root.parse::<DocumentMut>().unwrap();
        assert_eq!(
            doc["workspace"]["dependencies"]["serde"].as_str(),
            Some("=1.0.228")
        );
        assert!(
            doc["workspace"]["dependencies"]["xecute"]
                .get("features")
                .is_none()
        );
        let doc = member.parse::<DocumentMut>().unwrap();
        assert_eq!(
            doc["dependencies"]["serde"]["workspace"].as_bool(),
            Some(true)
        );
        assert_eq!(
            doc["dependencies"]["serde"]["features"][0].as_str(),
            Some("derive")
        );
        assert_eq!(
            doc["dev-dependencies"]["xecute"]["features"][0].as_str(),
            Some("testing")
        );
        assert_eq!(
            workspace_dependencies(&member, &root, false).unwrap(),
            (member, root.clone())
        );
        let (_, overridden) = workspace_dependencies(
            "[dependencies]\nxecute = { path = \"/replacement\" }\n",
            &root,
            true,
        )
        .unwrap();
        let doc = overridden.parse::<DocumentMut>().unwrap();
        assert_eq!(
            doc["workspace"]["dependencies"]["xecute"]["path"].as_str(),
            Some("/replacement")
        );
    }

    #[test]
    fn invalid_manifests_and_features_are_rejected() {
        assert!(resource_dependencies("not toml").is_err());
        assert!(
            resource_dependencies(
                "[dependencies]\nxecute = { version = \"0.1.6\", features = false }"
            )
            .is_err()
        );
    }
}