arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! Deterministic Cargo.toml manifest editing (RV2.7).
//!
//! Edits the `version` field in a crate's `Cargo.toml` and the
//! `arcature-dx` dependency requirement inside `core`'s `arcature`
//! manifest. The edit is deterministic and idempotent: applying the same
//! target version to a manifest already at that version is a no-op
//! (ADR-0005 invariant 9).
//!
//! The edit is line-based and preserves the surrounding TOML structure —
//! it does not re-serialize the whole manifest, which would lose comments,
//! formatting, and ordering. Only the specific `version = "…"` line is
//! replaced. This is a narrow, structural edit, not ad-hoc string surgery
//! (ADR-0005 invariant 16).
//!
//! For `core`, the three fields are edited in one atomic step:
//! `arcature`'s `version`, `arcature-dx`'s `version`, and the
//! `arcature → arcature-dx` `version = "= V"` requirement (ADR-0005
//! Decision §2 / invariant 3).

/// The result of a manifest edit: the new text and whether it changed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ManifestEdit {
    pub(crate) text: String,
    pub(crate) changed: bool,
}

/// Edit the `[package] version = "…"` field in a Cargo.toml manifest.
///
/// Replaces the first `version = "…"` line under `[package]` with the
/// target version. Idempotent: if the version is already the target, the
/// text is returned unchanged with `changed: false`.
///
/// Returns `Err` if the `[package]` table or its `version` field cannot be
/// found — fail closed on ambiguity (ADR-0005 invariant 16).
pub(crate) fn edit_package_version(
    manifest: &str,
    target: &str,
) -> Result<ManifestEdit, ManifestEditError> {
    let lines: Vec<&str> = manifest.lines().collect();
    let mut in_package = false;

    for (i, line) in lines.iter().enumerate() {
        let trimmed = line.trim();

        if trimmed.starts_with('[') {
            in_package = trimmed == "[package]";
            continue;
        }

        if in_package
            && trimmed.starts_with("version")
            && let Some(rest) = trimmed.strip_prefix("version")
        {
            let rest = rest.trim_start();
            if rest.starts_with('=') {
                let value_part = rest.trim_start_matches('=').trim();
                let current = value_part.trim_matches('"').trim_matches('\'');
                if current == target {
                    return Ok(ManifestEdit {
                        text: manifest.to_string(),
                        changed: false,
                    });
                }
                let indentation = line.len() - line.trim_start().len();
                let prefix = &line[..indentation];
                let new_line = format!("{prefix}version = \"{target}\"");
                return Ok(rebuild_manifest(manifest, &lines, i, new_line));
            }
        }
    }

    Err(ManifestEditError::PackageVersionNotFound)
}

/// Edit a dependency's `version = "= V"` requirement in a Cargo.toml
/// manifest. Used inside `core` so a published `arcature@V` resolves
/// `arcature-dx@V` and nothing else (ADR-0005 Decision §2 / invariant 3).
///
/// Handles two declaration forms:
/// 1. Table form — `[dependencies.<name>]` with a `version = "…"` line.
/// 2. Inline form — `<name> = { …, version = "…", … }` on one line.
///
/// Replaces the version value with `= {target}`. Idempotent. Preserves
/// the surrounding declaration (path, optional, features, etc.).
pub(crate) fn edit_dependency_version(
    manifest: &str,
    dep_name: &str,
    target: &str,
) -> Result<ManifestEdit, ManifestEditError> {
    let table_header = format!("[dependencies.{dep_name}]");
    let lines: Vec<&str> = manifest.lines().collect();
    let mut in_dep_table = false;
    let exact_target = format!("={target}");
    let version_prefix = "version";

    // Pass 1: table form `[dependencies.<name>]` with `version = "…"`.
    for (i, line) in lines.iter().enumerate() {
        let trimmed = line.trim();

        if trimmed.starts_with('[') {
            in_dep_table = trimmed == table_header;
            continue;
        }

        if in_dep_table && trimmed.starts_with(version_prefix) {
            let value_part = trimmed
                .trim_start_matches(version_prefix)
                .trim_start()
                .trim_start_matches('=')
                .trim();
            let current = value_part.trim_matches('"').trim_matches('\'');
            if current == exact_target {
                return Ok(ManifestEdit {
                    text: manifest.to_string(),
                    changed: false,
                });
            }
            let indentation = line.len() - line.trim_start().len();
            let prefix = &line[..indentation];
            let new_line = format!("{prefix}version = \"={target}\"");
            return Ok(rebuild_manifest(manifest, &lines, i, new_line));
        }
    }

    // Pass 2: inline form `<dep_name> = { …, version = "…", … }`.
    // The dependency is declared on one line as an inline table. Find the
    // `version = "…"` key inside the inline table and replace only that
    // value, preserving the rest of the declaration (path, features,
    // optional, etc.).
    let key_with_space = format!("{dep_name} =");
    let key_no_space = format!("{dep_name}=");
    let version_marker = "version = \"";
    for (i, line) in lines.iter().enumerate() {
        let trimmed = line.trim();

        // Match the dependency name at the start of the line, followed by
        // an inline-table opening brace.
        let starts_with_key =
            trimmed.starts_with(&key_with_space) || trimmed.starts_with(&key_no_space);
        if !starts_with_key || !trimmed.contains('{') || !trimmed.contains(version_marker) {
            continue;
        }

        // Find the version value within the inline table.
        let marker_pos = match trimmed.find(version_marker) {
            Some(pos) => pos,
            None => continue,
        };
        let value_start = marker_pos + version_marker.len();
        let value_end = match trimmed[value_start..].find('"') {
            Some(offset) => value_start + offset,
            None => continue,
        };
        let current = &trimmed[value_start..value_end];

        if current == exact_target {
            return Ok(ManifestEdit {
                text: manifest.to_string(),
                changed: false,
            });
        }

        // Replace only the version value within the line, preserving the
        // surrounding declaration (path, optional, features, etc.).
        let old_fragment = format!("{version_marker}{current}\"");
        let new_fragment = format!("{version_marker}{exact_target}\"");
        let new_line = line.replace(&old_fragment, &new_fragment);
        return Ok(rebuild_manifest(manifest, &lines, i, new_line));
    }

    Err(ManifestEditError::DependencyVersionNotFound(
        dep_name.to_string(),
    ))
}

/// Rebuild the manifest text with one line replaced.
fn rebuild_manifest(
    original: &str,
    lines: &[&str],
    replace_idx: usize,
    new_line: String,
) -> ManifestEdit {
    let mut result = String::with_capacity(original.len());
    for (i, line) in lines.iter().enumerate() {
        if i == replace_idx {
            result.push_str(&new_line);
        } else {
            result.push_str(line);
        }
        result.push('\n');
    }
    // Remove trailing newline if the original didn't have one.
    if !original.ends_with('\n') && result.ends_with('\n') {
        result.pop();
    }
    ManifestEdit {
        text: result,
        changed: true,
    }
}

/// The error returned when a manifest edit cannot locate the target field.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ManifestEditError {
    /// The `[package] version` field was not found.
    PackageVersionNotFound,
    /// The `[dependencies.<name>] version` field was not found.
    DependencyVersionNotFound(String),
}

impl std::fmt::Display for ManifestEditError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::PackageVersionNotFound => {
                write!(formatter, "cannot find [package] version field in manifest")
            }
            Self::DependencyVersionNotFound(name) => {
                write!(
                    formatter,
                    "cannot find [dependencies.{name}] version field or inline \
                     `{name} = {{ …, version = \"\", … }}` field in manifest"
                )
            }
        }
    }
}

impl std::error::Error for ManifestEditError {}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn edits_package_version() {
        let manifest =
            "[package]\nname = \"arcature-auth\"\nversion = \"2026.1.0\"\n\n[dependencies]\n";
        let result = edit_package_version(manifest, "2026.1.1").unwrap();
        assert!(result.changed);
        assert!(result.text.contains("version = \"2026.1.1\""));
        assert!(!result.text.contains("version = \"2026.1.0\""));
    }

    #[test]
    fn idempotent_when_already_target() {
        let manifest = "[package]\nname = \"x\"\nversion = \"2026.1.1\"\n";
        let result = edit_package_version(manifest, "2026.1.1").unwrap();
        assert!(!result.changed);
        assert_eq!(result.text, manifest);
    }

    #[test]
    fn preserves_comments_and_formatting() {
        let manifest = "# This is a comment\n[package]\nname = \"x\"\nversion = \"2026.1.0\"\n# Another comment\n";
        let result = edit_package_version(manifest, "2026.1.1").unwrap();
        assert!(result.text.contains("# This is a comment"));
        assert!(result.text.contains("# Another comment"));
    }

    #[test]
    fn preserves_indentation() {
        let manifest = "[package]\n  name = \"x\"\n  version = \"2026.1.0\"\n";
        let result = edit_package_version(manifest, "2026.1.1").unwrap();
        assert!(result.text.contains("  version = \"2026.1.1\""));
    }

    #[test]
    fn error_when_no_package_version() {
        let manifest = "[package]\nname = \"x\"\n";
        assert!(edit_package_version(manifest, "2026.1.1").is_err());
    }

    #[test]
    fn error_when_no_package_table() {
        let manifest = "[dependencies]\nfoo = \"1.0\"\n";
        assert!(edit_package_version(manifest, "2026.1.1").is_err());
    }

    #[test]
    fn edits_dependency_version() {
        let manifest =
            "[dependencies.arcature-dx]\nversion = \"=2026.1.0\"\npath = \"../arcature-dx\"\n";
        let result = edit_dependency_version(manifest, "arcature-dx", "2026.1.1").unwrap();
        assert!(result.changed);
        assert!(result.text.contains("version = \"=2026.1.1\""));
    }

    #[test]
    fn edits_inline_dependency_version() {
        // Inline-table form: `arcature-dx = { path = "…", version = "=2026.1.0", optional = true }`
        let manifest = "[dependencies]\narcature-dx = { path = \"../arcature-dx\", version = \"=2026.1.0\", optional = true }\n";
        let result = edit_dependency_version(manifest, "arcature-dx", "2026.2.0").unwrap();
        assert!(result.changed);
        assert!(result.text.contains("version = \"=2026.2.0\""));
        // The rest of the inline declaration is preserved.
        assert!(result.text.contains("path = \"../arcature-dx\""));
        assert!(result.text.contains("optional = true"));
        // The old version is gone.
        assert!(!result.text.contains("=2026.1.0"));
    }

    #[test]
    fn idempotent_inline_dependency() {
        let manifest = "[dependencies]\narcature-dx = { path = \"../arcature-dx\", version = \"=2026.2.0\", optional = true }\n";
        let result = edit_dependency_version(manifest, "arcature-dx", "2026.2.0").unwrap();
        assert!(!result.changed);
        assert_eq!(result.text, manifest);
    }

    #[test]
    fn inline_edit_preserves_other_keys_and_order() {
        let manifest = "[dependencies]\narcature-dx = { path = \"../arcature-dx\", version = \"=2026.1.0\", optional = true, features = [\"full\"] }\n";
        let result = edit_dependency_version(manifest, "arcature-dx", "2026.2.0").unwrap();
        // The surrounding keys and their order are preserved.
        assert!(result.text.contains("path = \"../arcature-dx\""));
        assert!(result.text.contains("optional = true"));
        assert!(result.text.contains("features = [\"full\"]"));
        assert!(result.text.contains("version = \"=2026.2.0\""));
    }

    #[test]
    fn does_not_match_prefix_collision_inline() {
        // `arcature-dx` must not match a line starting with `arcature-dx-extra`.
        let manifest = "[dependencies]\narcature-dx-extra = { path = \"../arcature-dx-extra\", version = \"=2026.1.0\" }\n";
        assert!(edit_dependency_version(manifest, "arcature-dx", "2026.2.0").is_err());
    }

    #[test]
    fn idempotent_dependency() {
        let manifest = "[dependencies.arcature-dx]\nversion = \"=2026.1.1\"\n";
        let result = edit_dependency_version(manifest, "arcature-dx", "2026.1.1").unwrap();
        assert!(!result.changed);
    }

    #[test]
    fn error_when_no_dependency_version() {
        let manifest = "[dependencies.arcature-dx]\npath = \"../arcature-dx\"\n";
        assert!(edit_dependency_version(manifest, "arcature-dx", "2026.1.1").is_err());
    }

    #[test]
    fn does_not_edit_version_in_other_tables() {
        let manifest = "[package]\nname = \"x\"\nversion = \"2026.1.0\"\n\n[dependencies]\nfoo = { version = \"1.0\" }\n";
        let result = edit_package_version(manifest, "2026.1.1").unwrap();
        assert!(result.text.contains("version = \"2026.1.1\""));
        assert!(result.text.contains("version = \"1.0\""));
    }

    #[test]
    fn handles_no_trailing_newline() {
        let manifest = "[package]\nname = \"x\"\nversion = \"2026.1.0\"";
        let result = edit_package_version(manifest, "2026.1.1").unwrap();
        assert!(result.changed);
        assert!(!result.text.ends_with('\n'));
        assert!(result.text.contains("version = \"2026.1.1\""));
    }
}