canic-host 0.70.6

Host-side build, install, deployment, and fleet-template library for Canic workspaces
Documentation
use super::{
    RenamedFleetRoleSource,
    support::{toml_assignment_key, toml_string_literal, validate_role_name},
};
use crate::release_set::config::model::RenamedFleetRole;
use canic_core::{bootstrap::parse_config_model, ids::CanisterRole};
use std::{fs, path::Path};
use toml::Value as TomlValue;

pub(in crate::release_set) fn rename_fleet_role_source(
    config_source: &str,
    config_path: &Path,
    expected_fleet: &str,
    old_role: &str,
    new_role: &str,
) -> Result<RenamedFleetRoleSource, Box<dyn std::error::Error>> {
    let old_role = old_role.trim();
    let new_role = new_role.trim();
    validate_role_name(old_role)?;
    validate_role_name(new_role)?;
    if old_role == "root" || new_role == "root" {
        return Err("root role cannot be renamed through fleet role rename".into());
    }
    if old_role == new_role {
        return Err("old role and new role must differ".into());
    }

    let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
    let actual_fleet = config
        .fleet_name()
        .ok_or_else(|| "missing required [fleet].name in canic.toml".to_string())?;
    if actual_fleet != expected_fleet {
        return Err(format!(
            "selected config declares fleet {actual_fleet:?}, not {expected_fleet:?}"
        )
        .into());
    }

    let old_id = CanisterRole::owned(old_role.to_string());
    let new_id = CanisterRole::owned(new_role.to_string());
    let declaration = config
        .roles
        .get(&old_id)
        .ok_or_else(|| format!("role {expected_fleet}.{old_role} is not declared"))?;
    if config.declares_role(&new_id) {
        return Err(format!("role {expected_fleet}.{new_role} is already declared").into());
    }

    let source = rename_config_role_references(config_source, old_role, new_role)?;
    parse_config_model(&source).map_err(|err| err.to_string())?;

    let (package_manifest, package_source, package_manifest_note) =
        config_path.parent().map_or_else(
            || (None, None, Some("config path has no parent".to_string())),
            |parent| {
                let manifest = parent.join(&declaration.package).join("Cargo.toml");
                match update_package_manifest_role(&manifest, expected_fleet, old_role, new_role) {
                    Ok(Some(updated)) => (Some(manifest), Some(updated), None),
                    Ok(None) => (
                        None,
                        None,
                        Some(format!(
                            "{} did not contain matching [package.metadata.canic] fleet/role metadata",
                            manifest.display()
                        )),
                    ),
                    Err(err) => (None, None, Some(err.to_string())),
                }
            },
        );

    Ok(RenamedFleetRoleSource {
        source,
        package_manifest: package_manifest.clone(),
        package_source,
        role: RenamedFleetRole {
            fleet: expected_fleet.to_string(),
            old_role: old_role.to_string(),
            new_role: new_role.to_string(),
            old_display: format!("{expected_fleet}.{old_role}"),
            new_display: format!("{expected_fleet}.{new_role}"),
            package_manifest,
            package_manifest_note,
        },
    })
}

fn rename_config_role_references(
    source: &str,
    old_role: &str,
    new_role: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    let old_literal = toml_string_literal(old_role);
    let new_literal = toml_string_literal(new_role);
    let mut updated = Vec::new();

    for line in source.lines() {
        let mut line = rename_role_header(line, old_role, new_role)?;
        let trimmed = line.trim_start();
        if toml_assignment_key(trimmed) == Some("canister_role")
            || toml_assignment_key(trimmed) == Some("app_index")
        {
            line = line.replace(&old_literal, &new_literal);
        }
        updated.push(line);
    }

    let mut result = updated.join("\n");
    if source.ends_with('\n') {
        result.push('\n');
    }
    Ok(result)
}

fn rename_role_header(
    line: &str,
    old_role: &str,
    new_role: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    let trimmed = line.trim();
    if !trimmed.starts_with('[') || !trimmed.ends_with(']') || trimmed.starts_with("[[") {
        return Ok(line.to_string());
    }

    let Some(prefix_len) = line.find('[') else {
        return Ok(line.to_string());
    };
    let inner = &trimmed[1..trimmed.len() - 1];
    let mut path = parse_toml_dotted_path(inner)?;
    let rename_roles_header = path.len() == 2 && path[0] == "roles" && path[1] == old_role;
    let rename_canister_header =
        path.len() >= 4 && path[0] == "subnets" && path[2] == "canisters" && path[3] == old_role;

    if rename_roles_header {
        path[1] = new_role.to_string();
    } else if rename_canister_header {
        path[3] = new_role.to_string();
    } else {
        return Ok(line.to_string());
    }

    Ok(format!(
        "{}[{}]",
        &line[..prefix_len],
        path.iter()
            .map(|part| toml_string_literal(part))
            .collect::<Vec<_>>()
            .join(".")
    ))
}

fn parse_toml_dotted_path(path: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let mut parts = Vec::new();
    let mut current = String::new();
    let mut chars = path.chars();
    let mut in_quote = false;

    while let Some(ch) = chars.next() {
        match ch {
            '"' if !in_quote => in_quote = true,
            '"' if in_quote => in_quote = false,
            '\\' if in_quote => {
                let Some(escaped) = chars.next() else {
                    return Err("unterminated TOML escape in table header".into());
                };
                current.push(escaped);
            }
            '.' if !in_quote => {
                parts.push(current.trim().to_string());
                current.clear();
            }
            ch => current.push(ch),
        }
    }

    if in_quote {
        return Err("unterminated quoted TOML table header".into());
    }
    parts.push(current.trim().to_string());
    Ok(parts)
}

fn update_package_manifest_role(
    manifest: &Path,
    expected_fleet: &str,
    old_role: &str,
    new_role: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
    if !manifest.is_file() {
        return Ok(None);
    }

    let source = fs::read_to_string(manifest)?;
    let metadata = toml::from_str::<TomlValue>(&source)?;
    let Some(canic_metadata) = metadata
        .get("package")
        .and_then(TomlValue::as_table)
        .and_then(|package| package.get("metadata"))
        .and_then(TomlValue::as_table)
        .and_then(|metadata| metadata.get("canic"))
        .and_then(TomlValue::as_table)
    else {
        return Ok(None);
    };
    if canic_metadata.get("fleet").and_then(TomlValue::as_str) != Some(expected_fleet)
        || canic_metadata.get("role").and_then(TomlValue::as_str) != Some(old_role)
    {
        return Ok(None);
    }

    Ok(Some(rename_package_metadata_role_source(
        &source, old_role, new_role,
    )))
}

fn rename_package_metadata_role_source(source: &str, old_role: &str, new_role: &str) -> String {
    let mut in_canic_metadata = false;
    let old_literal = toml_string_literal(old_role);
    let new_literal = toml_string_literal(new_role);
    let mut lines = Vec::new();

    for line in source.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('[') && trimmed.ends_with(']') {
            in_canic_metadata = trimmed == "[package.metadata.canic]";
        }
        if in_canic_metadata && toml_assignment_key(line.trim_start()) == Some("role") {
            lines.push(line.replace(&old_literal, &new_literal));
        } else {
            lines.push(line.to_string());
        }
    }

    let mut result = lines.join("\n");
    if source.ends_with('\n') {
        result.push('\n');
    }
    result
}