diff --git a/vendor/aube/crates/aube-manifest/src/lib.rs b/vendor/aube/crates/aube-manifest/src/lib.rs index 55864808c..a1817727b 100644 --- a/vendor/aube/crates/aube-manifest/src/lib.rs +++ b/vendor/aube/crates/aube-manifest/src/lib.rs @@ -1384,6 +1384,86 @@ pub fn parse_json( } } +/// Detected surface style of an existing JSON manifest: indent unit, +/// line-ending flavor, and trailing-newline state. Every `package.json` +/// rewrite must reproduce these — npm and pnpm both preserve the file's +/// own indentation (detect-indent) — so an `update`/`add`/settings edit +/// diffs as the changed keys, never as a whole-file reformat of a tab- +/// or 4-space-indented manifest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JsonStyle { + pub indent: String, + /// `true` when the file uses Windows `\r\n` line endings — reproduced + /// on write so an edit never silently converts a CRLF manifest to LF + /// (which reads as a whole-file diff in git). + pub crlf: bool, + pub trailing_newline: bool, +} + +impl Default for JsonStyle { + /// The style used when there is no original to imitate (a manifest + /// being created from scratch): two-space indent, LF, trailing + /// newline — `serde_json::to_string_pretty` + `\n`, the previous + /// unconditional output. + fn default() -> Self { + JsonStyle { + indent: " ".to_string(), + crlf: false, + trailing_newline: true, + } + } +} + +/// Detect the [`JsonStyle`] of an existing JSON document: the first +/// indented line's leading whitespace run is the indent unit (tabs or +/// any space width), CRLF anywhere marks the file CRLF, and the +/// trailing-newline state is whether the content ends with a newline. +pub fn detect_json_style(content: &str) -> JsonStyle { + let crlf = content.contains("\r\n"); + // `.lines()` strips both `\n` and a trailing `\r`, so indent + // detection is line-ending-agnostic. + let indent = content + .lines() + .find_map(|line| { + let ws_len = line.len() - line.trim_start_matches([' ', '\t']).len(); + (ws_len > 0 && !line.trim().is_empty()).then(|| line[..ws_len].to_string()) + }) + .unwrap_or_else(|| " ".to_string()); + JsonStyle { + indent, + crlf, + // A CRLF file's terminator is `\r\n`; either ending counts as + // "ends with a newline" for the trailing-newline state. + trailing_newline: content.ends_with('\n'), + } +} + +/// Serialize `value` as pretty JSON in the given [`JsonStyle`]: the +/// detected indent unit (vs `to_string_pretty`'s hardwired two spaces), +/// the original line ending (serde's pretty formatter always emits +/// `\n`, so a CRLF source has every `\n` rewritten to `\r\n` after +/// serialization), and the original trailing-newline state. +pub fn serialize_json_with_style( + value: &T, + style: &JsonStyle, +) -> Result { + let mut buf = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(style.indent.as_bytes()); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + value.serialize(&mut ser)?; + // The serializer only ever emits valid UTF-8. + let mut out = String::from_utf8(buf).expect("serde_json output is UTF-8"); + if style.crlf { + // The serialized body has no `\r` of its own (serde emits `\n`), + // so a plain replace is exact and idempotent. + out = out.replace('\n', "\r\n"); + } + if style.trailing_newline { + out.push_str(if style.crlf { "\r\n" } else { "\n" }); + } + Ok(out) +} + /// Parse a YAML document from `content`, returning an [`Error::Parse`] on /// failure with the source content + span attached. `yaml_serde` reports /// errors with a `Location { index, line, column }` we can feed straight @@ -2504,4 +2584,62 @@ ignoredOptionalDependencies: ); assert_eq!(p.update_ignore_dependencies(), vec!["a", "b", "c"]); } + + #[test] + fn json_style_detects_tabs_crlf_and_trailing_newline() { + let tabbed = "{\n\t\"name\": \"x\"\n}\n"; + let style = detect_json_style(tabbed); + assert_eq!(style.indent, "\t"); + assert!(!style.crlf); + assert!(style.trailing_newline); + + let four_crlf_no_eof = "{\r\n \"name\": \"x\"\r\n}"; + let style = detect_json_style(four_crlf_no_eof); + assert_eq!(style.indent, " "); + assert!(style.crlf); + assert!(!style.trailing_newline); + + // No indented line to imitate → the two-space indent default. + assert_eq!(detect_json_style("{}\n"), JsonStyle::default()); + assert_eq!(detect_json_style("{}").indent, " "); + } + + #[test] + fn serialize_json_with_style_reproduces_the_source_shape() { + let value: serde_json::Value = + serde_json::json!({"name": "x", "dependencies": {"a": "^1.0.0"}}); + + let tabs = serialize_json_with_style( + &value, + &JsonStyle { + indent: "\t".to_string(), + crlf: false, + trailing_newline: true, + }, + ) + .unwrap(); + assert!(tabs.contains("\n\t\"name\"")); + assert!(tabs.ends_with("}\n")); + + let crlf = serialize_json_with_style( + &value, + &JsonStyle { + indent: " ".to_string(), + crlf: true, + trailing_newline: false, + }, + ) + .unwrap(); + assert!(crlf.contains("\r\n \"name\"")); + assert!(!crlf.contains("\n\""), "every newline must be CRLF"); + assert!(crlf.ends_with('}')); + + // Detect → serialize round-trips a tabbed document byte-for-byte + // when nothing changes structurally. + let original = "{\n\t\"name\": \"x\",\n\t\"dependencies\": {\n\t\t\"a\": \"^1.0.0\"\n\t}\n}\n"; + let reparsed: serde_json::Value = serde_json::from_str(original).unwrap(); + let rewritten = + serialize_json_with_style(&reparsed, &detect_json_style(original)).unwrap(); + assert_eq!(rewritten, original); + } } diff --git a/vendor/aube/crates/aube-manifest/src/workspace/edits.rs b/vendor/aube/crates/aube-manifest/src/workspace/edits.rs index e9af8f433..972ccaa04 100644 --- a/vendor/aube/crates/aube-manifest/src/workspace/edits.rs +++ b/vendor/aube/crates/aube-manifest/src/workspace/edits.rs @@ -39,6 +39,7 @@ pub fn remove_setting_entry(cwd: &Path, key: &str, entry_key: &str) -> Result(&path, raw)?; let obj = value.as_object_mut().ok_or_else(|| { crate::Error::YamlParse(path.clone(), "package.json is not an object".to_string()) @@ -68,9 +69,8 @@ pub fn remove_setting_entry(cwd: &Path, key: &str, entry_key: &str) -> Result(&path, raw)?; let obj = value.as_object_mut().ok_or_else(|| { @@ -225,9 +226,8 @@ where return Ok(()); } - let mut out = serde_json::to_string_pretty(&value) + let out = crate::serialize_json_with_style(&value, &style) .map_err(|e| crate::Error::YamlParse(path.clone(), format!("failed to serialize: {e}")))?; - out.push('\n'); std::fs::write(&path, out).map_err(|e| crate::Error::Io(path, e))?; Ok(()) } @@ -271,6 +271,7 @@ pub fn add_to_pnpm_only_built_dependencies( let path = cwd.join("package.json"); let raw = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io(path.clone(), e))?; + let style = crate::detect_json_style(&raw); let mut value = crate::parse_json::(&path, raw)?; let obj = value.as_object_mut().ok_or_else(|| { crate::Error::YamlParse(path.clone(), "package.json is not an object".to_string()) @@ -302,9 +303,8 @@ pub fn add_to_pnpm_only_built_dependencies( if *obj == before { return Ok(()); } - let mut out = serde_json::to_string_pretty(&value) + let out = crate::serialize_json_with_style(&value, &style) .map_err(|e| crate::Error::YamlParse(path.clone(), format!("failed to serialize: {e}")))?; - out.push('\n'); std::fs::write(&path, out).map_err(|e| crate::Error::Io(path, e))?; Ok(()) } @@ -336,6 +336,7 @@ pub fn set_pnpm_allow_builds_entries( let path = cwd.join("package.json"); let raw = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io(path.clone(), e))?; + let style = crate::detect_json_style(&raw); let mut json = crate::parse_json::(&path, raw)?; let obj = json.as_object_mut().ok_or_else(|| { crate::Error::YamlParse(path.clone(), "package.json is not an object".to_string()) @@ -361,9 +362,8 @@ pub fn set_pnpm_allow_builds_entries( if *obj == before { return Ok(()); } - let mut out = serde_json::to_string_pretty(&json) + let out = crate::serialize_json_with_style(&json, &style) .map_err(|e| crate::Error::YamlParse(path.clone(), format!("failed to serialize: {e}")))?; - out.push('\n'); std::fs::write(&path, out).map_err(|e| crate::Error::Io(path, e))?; Ok(()) } @@ -648,6 +648,27 @@ mod tests { ); } + /// A settings edit into a tab-indented manifest must not reindent it + /// to serde's two-space default — npm/pnpm preserve the file's own + /// indent unit, and a config write should diff as the changed keys. + #[test] + fn setting_edit_preserves_tab_indentation() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), "{\n\t\"name\": \"x\"\n}\n"); + + edit_setting_map(tmp.path(), "allowBuilds", |m| { + m.insert("esbuild".to_string(), serde_json::Value::Bool(true)); + }) + .unwrap(); + + let raw = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); + assert!( + raw.contains("\n\t\"name\""), + "tab indent must survive the settings write:\n{raw}" + ); + assert!(!raw.contains("\n \""), "no two-space reindent:\n{raw}"); + } + /// A pre-existing `pnpm` namespace is the chosen write target (pnpm-aware /// drop-in compatibility), and the stale value in the other namespace is /// scrubbed so reads see one source of truth. diff --git a/vendor/aube/crates/aube/src/commands/manifest_io.rs b/vendor/aube/crates/aube/src/commands/manifest_io.rs index 7eb6a065c..fe06cdec9 100644 --- a/vendor/aube/crates/aube/src/commands/manifest_io.rs +++ b/vendor/aube/crates/aube/src/commands/manifest_io.rs @@ -23,18 +23,22 @@ pub(crate) fn load_manifest_or_default(root: &Path) -> miette::Result( path: &Path, value: &T, ) -> miette::Result<()> { - let json = serde_json::to_string_pretty(value) + let style = std::fs::read_to_string(path) + .map(|content| aube_manifest::detect_json_style(&content)) + .unwrap_or_default(); + let json = aube_manifest::serialize_json_with_style(value, &style) .into_diagnostic() .wrap_err("failed to serialize package.json")?; - write_manifest_atomic(path, format!("{json}\n").as_bytes()) - .wrap_err("failed to write package.json") + write_manifest_atomic(path, json.as_bytes()).wrap_err("failed to write package.json") } pub(crate) fn update_manifest_json_object(path: &Path, update: F) -> miette::Result<()> @@ -44,6 +48,7 @@ where let content = std::fs::read_to_string(path) .into_diagnostic() .wrap_err("failed to read package.json")?; + let style = aube_manifest::detect_json_style(&content); let mut json: serde_json::Value = serde_json::from_str(&content) .into_diagnostic() .wrap_err("failed to parse package.json")?; @@ -53,10 +58,10 @@ where update(obj)?; - let json = serde_json::to_string_pretty(&json) + let json = aube_manifest::serialize_json_with_style(&json, &style) .into_diagnostic() .wrap_err("failed to serialize package.json")?; - write_manifest_atomic(path, format!("{json}\n").as_bytes()) + write_manifest_atomic(path, json.as_bytes()) } pub(crate) fn write_manifest_dep_sections( @@ -179,4 +184,52 @@ mod tests { }; obj.keys().cloned().collect() } + + #[test] + fn write_manifest_dep_sections_preserves_tab_indentation() { + // Regression: `nub update` reindented a tab-indented package.json + // to two spaces (serde's hardwired to_string_pretty). npm and + // pnpm both preserve the file's own indent unit. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("package.json"); + std::fs::write( + &path, + "{\n\t\"name\": \"example\",\n\t\"devDependencies\": {\n\t\t\"typescript\": \"^6.0.3\"\n\t}\n}\n", + ) + .unwrap(); + + let mut manifest = aube_manifest::PackageJson::from_path(&path).unwrap(); + manifest + .dev_dependencies + .insert("typescript".to_string(), "^6.0.4".to_string()); + + write_manifest_dep_sections(&path, &manifest).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + assert!( + written.contains("\n\t\"devDependencies\": {\n\t\t\"typescript\": \"^6.0.4\""), + "tab indent must survive the rewrite:\n{written}" + ); + assert!(!written.contains("\n \""), "no two-space reindent:\n{written}"); + assert!(written.ends_with("}\n")); + } + + #[test] + fn write_manifest_json_preserves_crlf_and_missing_trailing_newline() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("package.json"); + std::fs::write( + &path, + "{\r\n \"name\": \"example\",\r\n \"license\": \"MIT\"\r\n}", + ) + .unwrap(); + + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + write_manifest_json(&path, &value).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("\r\n \"name\""), "CRLF + four-space indent must survive:\n{written:?}"); + assert!(written.ends_with('}'), "absent trailing newline must stay absent:\n{written:?}"); + } } diff --git a/vendor/aube/crates/aube/src/patches.rs b/vendor/aube/crates/aube/src/patches.rs index 1f2e250ec..30dee7925 100644 --- a/vendor/aube/crates/aube/src/patches.rs +++ b/vendor/aube/crates/aube/src/patches.rs @@ -513,6 +513,7 @@ fn upsert_manifest_patched_dependency( let raw = std::fs::read_to_string(&path) .into_diagnostic() .map_err(|e| miette!("failed to read {}: {e}", path.display()))?; + let style = aube_manifest::detect_json_style(&raw); let mut value = aube_manifest::parse_json::(&path, raw).map_err(miette::Report::new)?; let mut obj = value @@ -535,10 +536,9 @@ fn upsert_manifest_patched_dependency( key.to_string(), serde_json::Value::String(rel_patch_path.to_string()), ); - let mut out = serde_json::to_string_pretty(&value) + let out = aube_manifest::serialize_json_with_style(&value, &style) .into_diagnostic() .map_err(|e| miette!("failed to serialize {}: {e}", path.display()))?; - out.push('\n'); std::fs::write(&path, out) .into_diagnostic() .map_err(|e| miette!("failed to write {}: {e}", path.display()))?; @@ -553,6 +553,7 @@ fn remove_bun_patched_dependency(cwd: &Path, key: &str) -> Result { let raw = std::fs::read_to_string(&path) .into_diagnostic() .map_err(|e| miette!("failed to read {}: {e}", path.display()))?; + let style = aube_manifest::detect_json_style(&raw); let mut value = aube_manifest::parse_json::(&path, raw).map_err(miette::Report::new)?; let obj = value @@ -580,10 +581,9 @@ fn remove_bun_patched_dependency(cwd: &Path, key: &str) -> Result { return Ok(removed); } - let mut out = serde_json::to_string_pretty(&value) + let out = aube_manifest::serialize_json_with_style(&value, &style) .into_diagnostic() .map_err(|e| miette!("failed to serialize {}: {e}", path.display()))?; - out.push('\n'); std::fs::write(&path, out) .into_diagnostic() .map_err(|e| miette!("failed to write {}: {e}", path.display()))?; @@ -817,6 +817,51 @@ mod tests { ); } + #[test] + fn bun_patch_writers_preserve_tab_indentation() { + // patch-commit / patch-remove write into the user's in-place + // package.json, so they must reproduce the file's own style like + // every other manifest writer — not serde's two-space default. + // A bun.lock routes the upsert through + // `upsert_manifest_patched_dependency` and the removal through + // `remove_bun_patched_dependency` (the two writers under test); + // without a lockfile the upsert would take the + // `config_write_target` → `edit_setting_map` path instead, which + // `edits.rs` already covers. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("bun.lock"), "{}").unwrap(); + std::fs::write( + dir.path().join("package.json"), + "{\n\t\"name\": \"x\"\n}\n", + ) + .unwrap(); + + upsert_patched_dependency(dir.path(), "a@1.0.0", "patches/a@1.0.0.patch").unwrap(); + let raw = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); + // The bun arm writes the un-branded top-level field — proof the + // routing reached the writer under test, not edit_setting_map. + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!( + parsed["patchedDependencies"]["a@1.0.0"], + "patches/a@1.0.0.patch" + ); + assert!( + raw.contains("\n\t\"name\""), + "tab indent must survive patch-commit:\n{raw}" + ); + assert!(!raw.contains("\n \""), "no two-space reindent:\n{raw}"); + + remove_patched_dependency(dir.path(), "a@1.0.0").unwrap(); + let raw = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!(parsed["patchedDependencies"].is_null()); + assert!( + raw.contains("\n\t\"name\""), + "tab indent must survive patch-remove:\n{raw}" + ); + assert!(!raw.contains("\n \""), "no two-space reindent:\n{raw}"); + } + #[test] fn upsert_collapses_shadow_when_other_namespace_holds_stale_entry() { // A pnpm-aware tool can add a `pnpm` namespace after aube has