diff --git a/Cargo.lock b/Cargo.lock index 9eb9ab59..ce9a006d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2117,6 +2117,7 @@ dependencies = [ "tar", "tokio", "toml", + "toml_edit 0.23.9", "zip", ] @@ -6211,6 +6212,7 @@ dependencies = [ "indexmap", "toml_datetime 0.7.3", "toml_parser", + "toml_writer", "winnow", ] @@ -6229,6 +6231,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" diff --git a/crates/tools/fission-cli/Cargo.toml b/crates/tools/fission-cli/Cargo.toml index bbf55ee7..c34c93a1 100644 --- a/crates/tools/fission-cli/Cargo.toml +++ b/crates/tools/fission-cli/Cargo.toml @@ -23,12 +23,13 @@ serde_json = "1.0" sha2 = "0.10" tar = "0.4" toml = "0.8" +toml_edit = "0.23" fission = { path = "../../authoring/fission", version = "0.1.1", default-features = false, features = ["terminal-shell"] } fission-shell-site = { path = "../../shell/fission-shell-site", version = "0.1.1" } base64 = "0.22" chacha20poly1305 = "0.10" getrandom = { version = "0.2", features = ["std"] } -keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] } +keyring = { version = "3", default-features = false, features = ["apple-native", "windows-native", "linux-native", "crypto-rust"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] } aws-config = "1" aws-sdk-s3 = "1" diff --git a/crates/tools/fission-cli/src/release.rs b/crates/tools/fission-cli/src/release.rs index 9e222eaf..a43fd878 100644 --- a/crates/tools/fission-cli/src/release.rs +++ b/crates/tools/fission-cli/src/release.rs @@ -13,6 +13,10 @@ use std::io::{self, IsTerminal, Read}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; +use toml_edit::{ + Array as TomlEditArray, DocumentMut, Item as TomlEditItem, Table as TomlEditTable, + Value as TomlEditValue, +}; mod content; mod microsoft_store_ops; @@ -780,11 +784,9 @@ fn set_release_field(project_dir: &Path, field: &str, value: &str, yes: bool) -> let path = project_dir.join("fission.toml"); let data = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; - let mut doc: toml::Value = - toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?; - set_toml_path(&mut doc, field, toml::Value::String(value.to_string()))?; - fs::write(&path, toml::to_string_pretty(&doc)? + "\n") - .with_context(|| format!("failed to write {}", path.display()))?; + let mut doc = parse_toml_edit_document(&data, &path)?; + set_toml_edit_path(&mut doc, field, toml_edit::value(value.to_string()))?; + write_toml_edit_document(&path, &doc)?; Ok(()) } @@ -812,6 +814,41 @@ fn add_release( Ok(()) } +fn parse_toml_edit_document(text: &str, path: &Path) -> Result { + text.parse::() + .with_context(|| format!("failed to parse {}", path.display())) +} + +fn write_toml_edit_document(path: &Path, doc: &DocumentMut) -> Result<()> { + fs::write(path, format!("{doc}\n")) + .with_context(|| format!("failed to write {}", path.display())) +} + +fn set_toml_edit_path(root: &mut DocumentMut, path: &str, value: TomlEditItem) -> Result<()> { + let parts = path.split('.').collect::>(); + if parts.is_empty() || parts.iter().any(|part| part.trim().is_empty()) { + bail!("field path must be dot-separated and non-empty"); + } + let mut current = root.as_table_mut(); + for part in &parts[..parts.len() - 1] { + current = current + .entry(part) + .or_insert(TomlEditItem::Table(TomlEditTable::new())) + .as_table_mut() + .context("field path traversed through a non-table value")?; + } + current[parts[parts.len() - 1]] = value; + Ok(()) +} + +fn toml_edit_string_array(values: impl IntoIterator) -> TomlEditItem { + let mut array = TomlEditArray::default(); + for value in values { + array.push(value); + } + TomlEditItem::Value(TomlEditValue::Array(array)) +} + fn edit_release_file( project_dir: &Path, release: &str, @@ -1282,6 +1319,7 @@ fn print_report(mut report: LifecycleReport, json: bool) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use std::fs; #[test] fn auth_setup_documents_provider_credentials_without_secrets() { @@ -1302,4 +1340,23 @@ mod tests { .is_some_and(|details| details.contains("Pages")) })); } + + #[test] + fn release_config_set_preserves_existing_comments_and_formatting() { + let dir = + std::env::temp_dir().join(format!("fission-release-config-set-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("fission.toml"); + fs::write(&path, "# keep this comment\n[app]\nname = \"Todo\"\n").unwrap(); + + set_release_field(&dir, "app.version", "1.2.3", true).unwrap(); + + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("# keep this comment")); + assert!(text.contains("version = \"1.2.3\"")); + assert!(text.contains("name = \"Todo\"")); + + let _ = fs::remove_dir_all(&dir); + } } diff --git a/crates/tools/fission-cli/src/release/microsoft_store_ops.rs b/crates/tools/fission-cli/src/release/microsoft_store_ops.rs index 5c4bdaab..de9ac5f4 100644 --- a/crates/tools/fission-cli/src/release/microsoft_store_ops.rs +++ b/crates/tools/fission-cli/src/release/microsoft_store_ops.rs @@ -435,40 +435,40 @@ fn write_imported_microsoft_listings( .as_deref() .context("active release metadata path is required for Microsoft Store metadata import")?; let toml_path = project_dir.join("fission.toml"); - let mut fission_doc: toml::Value = toml::from_str(&fs::read_to_string(&toml_path)?)?; + let mut fission_doc = parse_toml_edit_document(&fs::read_to_string(&toml_path)?, &toml_path)?; for listing in remote { if let Some(title) = listing.title.clone() { - set_toml_path( + set_toml_edit_path( &mut fission_doc, &format!( "release.store_listing.microsoft_store.{}.title", listing.language ), - toml::Value::String(title), + toml_edit::value(title), )?; } if let Some(short_description) = listing.short_description.clone() { - set_toml_path( + set_toml_edit_path( &mut fission_doc, &format!( "release.store_listing.microsoft_store.{}.short_description", listing.language ), - toml::Value::String(short_description), + toml_edit::value(short_description), )?; } if let Some(privacy_url) = listing.privacy_url.clone() { - set_toml_path( + set_toml_edit_path( &mut fission_doc, &format!( "release.store_listing.microsoft_store.{}.privacy_url", listing.language ), - toml::Value::String(privacy_url), + toml_edit::value(privacy_url), )?; } } - fs::write(&toml_path, toml::to_string_pretty(&fission_doc)? + "\n")?; + write_toml_edit_document(&toml_path, &fission_doc)?; let metadata_abs = project_dir.join(metadata_path); let mut metadata_doc: toml::Value = if metadata_abs.exists() { diff --git a/crates/tools/fission-cli/src/release/signing_ops.rs b/crates/tools/fission-cli/src/release/signing_ops.rs index 94a8c573..75d81e24 100644 --- a/crates/tools/fission-cli/src/release/signing_ops.rs +++ b/crates/tools/fission-cli/src/release/signing_ops.rs @@ -136,23 +136,22 @@ fn import_android( let relative = project_relative_or_absolute(project_dir, &keystore); let path = project_dir.join("fission.toml"); let data = fs::read_to_string(&path).unwrap_or_default(); - let mut root: toml::Value = if data.trim().is_empty() { - toml::Value::Table(Default::default()) + let mut root = if data.trim().is_empty() { + toml_edit::DocumentMut::new() } else { - toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))? + parse_toml_edit_document(&data, &path)? }; - set_toml_path( + set_toml_edit_path( &mut root, "package.android.keystore", - toml::Value::String(relative.clone()), + toml_edit::value(relative.clone()), )?; - set_toml_path( + set_toml_edit_path( &mut root, "package.android.keystore_alias", - toml::Value::String(alias.clone()), + toml_edit::value(alias.clone()), )?; - fs::write(&path, toml::to_string_pretty(&root)? + "\n") - .with_context(|| format!("failed to write {}", path.display()))?; + write_toml_edit_document(&path, &root)?; report.checks.push(ok_check( "signing.android.config_written", format!("package.android.keystore = {relative}, alias = {alias}"), diff --git a/crates/tools/fission-cli/src/release/store_ops.rs b/crates/tools/fission-cli/src/release/store_ops.rs index c51fa317..dbe5296c 100644 --- a/crates/tools/fission-cli/src/release/store_ops.rs +++ b/crates/tools/fission-cli/src/release/store_ops.rs @@ -1557,7 +1557,8 @@ fn write_imported_app_store_localizations( } else { toml::Value::Table(Default::default()) }; - let mut fission_doc: toml::Value = toml::from_str(&fs::read_to_string(&fission_path)?)?; + let mut fission_doc = + parse_toml_edit_document(&fs::read_to_string(&fission_path)?, &fission_path)?; for item in remote { if selected.is_some_and(|selected| !selected.contains(&item.locale)) { continue; @@ -1575,34 +1576,34 @@ fn write_imported_app_store_localizations( )?; } if let Some(value) = &item.support_url { - set_toml_path( + set_toml_edit_path( &mut fission_doc, &format!( "release.store_listing.app_store.{}.support_url", item.locale ), - toml::Value::String(value.clone()), + toml_edit::value(value.clone()), )?; } if let Some(value) = &item.marketing_url { - set_toml_path( + set_toml_edit_path( &mut fission_doc, &format!( "release.store_listing.app_store.{}.marketing_url", item.locale ), - toml::Value::String(value.clone()), + toml_edit::value(value.clone()), )?; } if let Some(value) = &item.keywords { - set_toml_path( + set_toml_edit_path( &mut fission_doc, &format!("release.store_listing.app_store.{}.keywords", item.locale), - toml::Value::Array( + toml_edit_string_array( value .split(',') - .map(|item| toml::Value::String(item.trim().to_string())) - .collect(), + .map(|item| item.trim().to_string()) + .collect::>(), ), )?; } @@ -1614,7 +1615,7 @@ fn write_imported_app_store_localizations( &metadata_path, toml::to_string_pretty(&metadata_doc)? + "\n", )?; - fs::write(&fission_path, toml::to_string_pretty(&fission_doc)? + "\n")?; + write_toml_edit_document(&fission_path, &fission_doc)?; Ok(()) } @@ -1830,32 +1831,30 @@ fn write_imported_play_listings( let fission_path = project_dir.join("fission.toml"); let data = fs::read_to_string(&fission_path) .with_context(|| format!("failed to read {}", fission_path.display()))?; - let mut doc: toml::Value = toml::from_str(&data) - .with_context(|| format!("failed to parse {}", fission_path.display()))?; + let mut doc = parse_toml_edit_document(&data, &fission_path)?; for listing in listings { - set_toml_path( + set_toml_edit_path( &mut doc, &format!("release.store_listing.play_store.{}.title", listing.locale), - toml::Value::String(listing.title.clone()), + toml_edit::value(listing.title.clone()), )?; - set_toml_path( + set_toml_edit_path( &mut doc, &format!( "release.store_listing.play_store.{}.short_description", listing.locale ), - toml::Value::String(listing.short_description.clone()), + toml_edit::value(listing.short_description.clone()), )?; if let Some(video) = &listing.video { - set_toml_path( + set_toml_edit_path( &mut doc, &format!("release.store_listing.play_store.{}.video", listing.locale), - toml::Value::String(video.clone()), + toml_edit::value(video.clone()), )?; } } - fs::write(&fission_path, toml::to_string_pretty(&doc)? + "\n") - .with_context(|| format!("failed to write {}", fission_path.display()))?; + write_toml_edit_document(&fission_path, &doc)?; let metadata_path = active_release(root) .and_then(|release| release.metadata.as_deref())