#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ManifestEdit {
pub(crate) text: String,
pub(crate) changed: bool,
}
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)
}
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";
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));
}
}
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();
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;
}
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,
});
}
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(),
))
}
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');
}
if !original.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
ManifestEdit {
text: result,
changed: true,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ManifestEditError {
PackageVersionNotFound,
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() {
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\""));
assert!(result.text.contains("path = \"../arcature-dx\""));
assert!(result.text.contains("optional = true"));
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();
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() {
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\""));
}
}