Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions vendor/aube/crates/aube-manifest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,86 @@ pub fn parse_json<T: serde::de::DeserializeOwned>(
}
}

/// 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<T: serde::Serialize>(
value: &T,
style: &JsonStyle,
) -> Result<String, serde_json::Error> {
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
Expand Down Expand Up @@ -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);
}
}
37 changes: 29 additions & 8 deletions vendor/aube/crates/aube-manifest/src/workspace/edits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub fn remove_setting_entry(cwd: &Path, key: &str, entry_key: &str) -> Result<bo
return Ok(false);
}
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::<serde_json::Value>(&path, raw)?;
let obj = value.as_object_mut().ok_or_else(|| {
crate::Error::YamlParse(path.clone(), "package.json is not an object".to_string())
Expand Down Expand Up @@ -68,9 +69,8 @@ pub fn remove_setting_entry(cwd: &Path, key: &str, entry_key: &str) -> Result<bo
return Ok(existed);
}

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(existed)
}
Expand Down Expand Up @@ -101,6 +101,7 @@ where
{
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::<serde_json::Value>(&path, raw)?;

let obj = value.as_object_mut().ok_or_else(|| {
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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::<serde_json::Value>(&path, raw)?;
let obj = value.as_object_mut().ok_or_else(|| {
crate::Error::YamlParse(path.clone(), "package.json is not an object".to_string())
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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::<serde_json::Value>(&path, raw)?;
let obj = json.as_object_mut().ok_or_else(|| {
crate::Error::YamlParse(path.clone(), "package.json is not an object".to_string())
Expand All @@ -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(())
}
Expand Down Expand Up @@ -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.
Expand Down
69 changes: 61 additions & 8 deletions vendor/aube/crates/aube/src/commands/manifest_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,22 @@ pub(crate) fn load_manifest_or_default(root: &Path) -> miette::Result<aube_manif
}
}

/// Serialize `value` as pretty JSON with a trailing newline and
/// atomically write it to `path`. Wraps the serialize + atomic-write
/// pair used by add/remove/update/audit when mutating `package.json`.
/// Serialize `value` as pretty JSON in the file's own surface style
/// (indent unit, CRLF, trailing newline — npm/pnpm parity: an edit must
/// never reformat a tab- or 4-space-indented manifest) and atomically
/// write it to `path`. Wraps the serialize + atomic-write pair used by
/// add/remove/update/audit when mutating `package.json`.
pub(crate) fn write_manifest_json<T: serde::Serialize>(
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<F>(path: &Path, update: F) -> miette::Result<()>
Expand All @@ -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")?;
Expand All @@ -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(
Expand Down Expand Up @@ -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:?}");
}
}
Loading
Loading