use std::borrow::Cow;
use std::io;
use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::Parser;
use serde::Serialize;
use serde_json::Value;
use serde_json::ser::{Formatter, PrettyFormatter};
use similar::TextDiff;
use crate::hook::Hook;
use crate::hooks::{HookOutput, parse_hook_args, run_blocking_file_checks};
#[derive(Parser, Debug)]
#[command(disable_help_subcommand = true)]
#[command(disable_version_flag = true)]
#[command(disable_help_flag = true)]
pub(crate) struct Args {
#[arg(long = "autofix")]
auto_fix: bool,
#[arg(long, default_value = "2")]
indent: String,
#[arg(long)]
no_ensure_ascii: bool,
#[arg(long)]
no_sort_keys: bool,
#[arg(long, value_name = "KEYS", value_delimiter = ',')]
top_keys: Vec<String>,
#[arg(value_name = "FILENAMES")]
filenames: Vec<PathBuf>,
}
struct PreparedArgs {
auto_fix: bool,
ensure_ascii: bool,
indent_bytes: Vec<u8>,
ordered_top_keys: Vec<String>,
sort_keys: bool,
}
impl From<&Args> for PreparedArgs {
fn from(args: &Args) -> Self {
let indent_bytes = match args.indent.parse::<usize>() {
Ok(num_spaces) => vec![b' '; num_spaces],
Err(_) => args.indent.as_bytes().to_vec(),
};
let mut ordered_top_keys = Vec::with_capacity(args.top_keys.len());
for top_key in &args.top_keys {
if !ordered_top_keys.contains(top_key) {
ordered_top_keys.push(top_key.clone());
}
}
Self {
auto_fix: args.auto_fix,
ensure_ascii: !args.no_ensure_ascii,
indent_bytes,
ordered_top_keys,
sort_keys: !args.no_sort_keys,
}
}
}
pub(crate) async fn run(hook: &Hook, filenames: &[&Path]) -> Result<HookOutput> {
let args: Args = parse_hook_args(hook)?;
let prepared = PreparedArgs::from(&args);
run_blocking_file_checks(
hook.project().relative_path(),
&args.filenames,
filenames,
move |file_path, display_path| check_file(file_path, display_path, &prepared),
)
.await
}
fn check_file(file_path: &Path, display_path: &Path, args: &PreparedArgs) -> Result<HookOutput> {
let original_content = fs_err::read_to_string(file_path)?;
match prettify_json(&original_content, args) {
Ok(prettified_json) => {
if matches_pretty_format(&original_content, &prettified_json) {
Ok(HookOutput::unchanged(0, Vec::new()))
} else if args.auto_fix {
let output = with_original_line_ending(&prettified_json, &original_content);
fs_err::write(file_path, output.as_bytes())?;
let message = format!("Fixing file {}\n", display_path.display());
Ok(HookOutput::known(1, message.into_bytes(), true))
} else {
let normalized_content = normalize_newlines(&original_content);
let diff =
generate_diff(normalized_content.as_ref(), &prettified_json, display_path);
let message = format!("{}: not pretty-formatted.\n{diff}", display_path.display());
Ok(HookOutput::unchanged(1, message.into_bytes()))
}
}
Err(err) => {
let error_message = format!(
"{}: invalid JSON ({err}). Consider using the `check-json` hook.\n",
display_path.display(),
);
Ok(HookOutput::unchanged(1, error_message.into_bytes()))
}
}
}
fn prettify_json(json: &str, args: &PreparedArgs) -> Result<String> {
let mut value: Value = serde_json::from_str(json)?;
if args.sort_keys || !args.ordered_top_keys.is_empty() {
reorder_keys(&mut value, &args.ordered_top_keys, args.sort_keys);
}
let mut buf = Vec::with_capacity(json.len());
let formatter = JsonFormatter::with_indent(&args.indent_bytes, args.ensure_ascii);
let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
value.serialize(&mut ser)?;
let mut result = String::from_utf8(buf)?;
if !result.ends_with('\n') {
result.push('\n');
}
Ok(result)
}
fn matches_pretty_format(original: &str, pretty_lf: &str) -> bool {
let original = original.as_bytes();
let pretty_lf = pretty_lf.as_bytes();
let mut original_index = 0;
let mut pretty_index = 0;
while original_index < original.len() && pretty_index < pretty_lf.len() {
match original[original_index] {
b'\r' => {
if pretty_lf[pretty_index] != b'\n' {
return false;
}
original_index += if original.get(original_index + 1) == Some(&b'\n') {
2
} else {
1
};
pretty_index += 1;
}
byte if byte == pretty_lf[pretty_index] => {
original_index += 1;
pretty_index += 1;
}
_ => return false,
}
}
original_index == original.len() && pretty_index == pretty_lf.len()
}
fn detect_line_ending(content: &str) -> &'static str {
let bytes = content.as_bytes();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'\r' if bytes.get(index + 1) == Some(&b'\n') => return "\r\n",
b'\r' => return "\r",
b'\n' => return "\n",
_ => index += 1,
}
}
"\n"
}
fn with_original_line_ending<'a>(content: &'a str, original: &str) -> Cow<'a, str> {
let line_ending = detect_line_ending(original);
if line_ending == "\n" {
Cow::Borrowed(content)
} else {
Cow::Owned(content.replace('\n', line_ending))
}
}
fn normalize_newlines(content: &str) -> Cow<'_, str> {
let bytes = content.as_bytes();
if !bytes.contains(&b'\r') {
return Cow::Borrowed(content);
}
let mut normalized = String::with_capacity(content.len());
let mut index = 0;
let mut chunk_start = 0;
while index < bytes.len() {
match bytes[index] {
b'\r' => {
normalized.push_str(&content[chunk_start..index]);
normalized.push('\n');
index += 1;
if bytes.get(index) == Some(&b'\n') {
index += 1;
}
chunk_start = index;
}
_ => index += 1,
}
}
normalized.push_str(&content[chunk_start..]);
Cow::Owned(normalized)
}
struct JsonFormatter<'a> {
pretty: PrettyFormatter<'a>,
ensure_ascii: bool,
}
impl<'a> JsonFormatter<'a> {
fn with_indent(indent: &'a [u8], ensure_ascii: bool) -> Self {
Self {
pretty: PrettyFormatter::with_indent(indent),
ensure_ascii,
}
}
}
impl Formatter for JsonFormatter<'_> {
fn begin_array<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
self.pretty.begin_array(writer)
}
fn end_array<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
self.pretty.end_array(writer)
}
fn begin_array_value<W>(&mut self, writer: &mut W, first: bool) -> io::Result<()>
where
W: ?Sized + io::Write,
{
self.pretty.begin_array_value(writer, first)
}
fn end_array_value<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
self.pretty.end_array_value(writer)
}
fn begin_object<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
self.pretty.begin_object(writer)
}
fn end_object<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
self.pretty.end_object(writer)
}
fn begin_object_key<W>(&mut self, writer: &mut W, first: bool) -> io::Result<()>
where
W: ?Sized + io::Write,
{
self.pretty.begin_object_key(writer, first)
}
fn begin_object_value<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
self.pretty.begin_object_value(writer)
}
fn end_object_value<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
self.pretty.end_object_value(writer)
}
fn write_string_fragment<W>(&mut self, writer: &mut W, fragment: &str) -> io::Result<()>
where
W: ?Sized + io::Write,
{
if !self.ensure_ascii || fragment.is_ascii() {
return writer.write_all(fragment.as_bytes());
}
write_ascii_only_fragment(writer, fragment)
}
}
fn write_ascii_only_fragment<W>(writer: &mut W, fragment: &str) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let mut start = 0;
for (index, ch) in fragment.char_indices() {
if ch.is_ascii() {
continue;
}
if start < index {
writer.write_all(&fragment.as_bytes()[start..index])?;
}
write_unicode_escape(writer, ch)?;
start = index + ch.len_utf8();
}
writer.write_all(&fragment.as_bytes()[start..])
}
fn write_unicode_escape<W>(writer: &mut W, ch: char) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let mut buf = [0_u16; 2];
for unit in ch.encode_utf16(&mut buf).iter().copied() {
write_u16_escape(writer, unit)?;
}
Ok(())
}
fn write_u16_escape<W>(writer: &mut W, unit: u16) -> io::Result<()>
where
W: ?Sized + io::Write,
{
const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
let escape = [
b'\\',
b'u',
HEX_DIGITS[((unit >> 12) & 0x0f) as usize],
HEX_DIGITS[((unit >> 8) & 0x0f) as usize],
HEX_DIGITS[((unit >> 4) & 0x0f) as usize],
HEX_DIGITS[(unit & 0x0f) as usize],
];
writer.write_all(&escape)
}
fn reorder_keys(value: &mut Value, top_keys: &[String], sort_keys: bool) {
match value {
Value::Object(map) => {
for nested in map.values_mut() {
reorder_keys(nested, top_keys, sort_keys);
}
if sort_keys {
map.sort_keys();
}
let mut insert_at = 0;
for top_key in top_keys {
if let Some((key, nested)) = map.shift_remove_entry(top_key.as_str()) {
map.shift_insert(insert_at, key, nested);
insert_at += 1;
}
}
}
Value::Array(array) => {
for nested in array {
reorder_keys(nested, top_keys, sort_keys);
}
}
_ => {}
}
}
fn generate_diff(original: &str, formatted: &str, display_path: &Path) -> String {
TextDiff::from_lines(original, formatted)
.unified_diff()
.context_radius(3)
.header(
&display_path.display().to_string(),
&display_path.display().to_string(),
)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::tempdir;
const PRETTY_JSON: &str = indoc::indoc! {r#"
{
"alist": [
2,
34,
234
],
"blah": null,
"foo": "bar"
}
"#};
const UNSORTED_JSON: &str = indoc::indoc! {r#"
{
"foo": "bar",
"alist": [
2,
34,
234
],
"blah": null
}
"#};
const NON_ASCII_JSON: &str = indoc::indoc! {r#"
{
"alist": [
2,
34,
234
],
"blah": null,
"foo": "bar",
"non_ascii": "\u4E2D\u6587\u306B\u307B\u3093\u3054\uD55C\uAD6D\uC5B4"
}
"#};
async fn create_test_file(
dir: &tempfile::TempDir,
name: &str,
content: &str,
) -> Result<PathBuf> {
let file_path = dir.path().join(name);
fs_err::tokio::write(&file_path, content).await?;
Ok(file_path)
}
#[tokio::test]
async fn test_empty_json_file() -> Result<()> {
let dir = tempdir()?;
create_test_file(&dir, "empty.json", "").await?;
let args = PreparedArgs {
auto_fix: false,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(
&dir.path().join("empty.json"),
Path::new("empty.json"),
&args,
)?;
assert_eq!(result.exit_status, 1);
let output = String::from_utf8(result.output)?;
assert_eq!(
output,
"empty.json: invalid JSON (EOF while parsing a value at line 1 column 0). Consider using the `check-json` hook.\n",
);
Ok(())
}
#[tokio::test]
async fn test_invalid_json() -> Result<()> {
let dir = tempdir()?;
create_test_file(&dir, "invalid.json", r#"{"foo": bar}"#).await?;
let args = PreparedArgs {
auto_fix: false,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(
&dir.path().join("invalid.json"),
Path::new("invalid.json"),
&args,
)?;
assert_eq!(result.exit_status, 1);
let output = String::from_utf8(result.output)?;
assert_eq!(
output,
"invalid.json: invalid JSON (expected value at line 1 column 9). Consider using the `check-json` hook.\n",
);
Ok(())
}
#[tokio::test]
async fn test_pretty_json_file() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "pretty.json", PRETTY_JSON).await?;
let args = PreparedArgs {
auto_fix: false,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 0);
assert_eq!(result.output, b"");
Ok(())
}
#[tokio::test]
async fn test_pretty_crlf_json_file() -> Result<()> {
let dir = tempdir()?;
let pretty_crlf_json = PRETTY_JSON.replace('\n', "\r\n");
let file_path = create_test_file(&dir, "pretty.json", &pretty_crlf_json).await?;
let args = PreparedArgs {
auto_fix: false,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 0);
assert_eq!(result.output, b"");
Ok(())
}
#[tokio::test]
async fn test_pretty_mixed_line_endings_do_not_need_fixing() -> Result<()> {
let dir = tempdir()?;
let pretty_mixed_json = PRETTY_JSON.replacen('\n', "\r\n", 3);
let file_path = create_test_file(&dir, "pretty.json", &pretty_mixed_json).await?;
let args = PreparedArgs {
auto_fix: false,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 0);
assert_eq!(result.output, b"");
Ok(())
}
#[tokio::test]
async fn test_unsorted_json_file() -> Result<()> {
let dir = tempdir()?;
create_test_file(&dir, "non_pretty.json", UNSORTED_JSON).await?;
let args = PreparedArgs {
auto_fix: false,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(
&dir.path().join("non_pretty.json"),
Path::new("non_pretty.json"),
&args,
)?;
assert_eq!(result.exit_status, 1);
let output = String::from_utf8(result.output)?;
let expected = indoc::indoc! {r#"
non_pretty.json: not pretty-formatted.
--- non_pretty.json
+++ non_pretty.json
@@ -1,9 +1,9 @@
{
- "foo": "bar",
"alist": [
2,
34,
234
],
- "blah": null
+ "blah": null,
+ "foo": "bar"
}
"#};
assert_eq!(output, expected);
assert_eq!(
fs_err::tokio::read_to_string(dir.path().join("non_pretty.json")).await?,
UNSORTED_JSON
);
Ok(())
}
#[tokio::test]
async fn test_sorting_disabled() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "non_pretty.json", UNSORTED_JSON).await?;
let args = PreparedArgs {
auto_fix: false,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: false,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 0);
assert_eq!(result.output, b"");
Ok(())
}
#[tokio::test]
async fn test_top_keys() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "non_pretty.json", UNSORTED_JSON).await?;
let args = PreparedArgs {
auto_fix: false,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec!["blah".to_string()],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
let output_str = String::from_utf8_lossy(&result.output);
assert!(output_str.contains("not pretty-formatted."));
Ok(())
}
#[test]
fn test_prepared_args_deduplicates_top_keys() {
let args = Args {
auto_fix: false,
indent: "2".to_string(),
no_ensure_ascii: false,
no_sort_keys: false,
top_keys: vec![
"name".to_string(),
"version".to_string(),
"name".to_string(),
],
filenames: Vec::new(),
};
let prepared = PreparedArgs::from(&args);
assert_eq!(prepared.ordered_top_keys, vec!["name", "version"]);
}
#[tokio::test]
async fn test_autofix() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "non_pretty.json", UNSORTED_JSON).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
assert!(String::from_utf8_lossy(&result.output).contains("Fixing file"));
let result = fs_err::tokio::read_to_string(&file_path).await?;
assert_eq!(result, PRETTY_JSON);
Ok(())
}
#[tokio::test]
async fn test_autofix_preserves_crlf() -> Result<()> {
let dir = tempdir()?;
let unsorted_crlf_json = UNSORTED_JSON.replace('\n', "\r\n");
let file_path = create_test_file(&dir, "non_pretty.json", &unsorted_crlf_json).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
assert!(String::from_utf8_lossy(&result.output).contains("Fixing file"));
let result = fs_err::tokio::read_to_string(&file_path).await?;
assert_eq!(result, PRETTY_JSON.replace('\n', "\r\n"));
Ok(())
}
#[tokio::test]
async fn test_tab_indent() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "non_pretty.json", UNSORTED_JSON).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: true,
indent_bytes: b"\t".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
assert!(String::from_utf8_lossy(&result.output).contains("Fixing file"));
let result = fs_err::tokio::read_to_string(&file_path).await?;
let expected = "{\n\t\"alist\": [\n\t\t2,\n\t\t34,\n\t\t234\n\t],\n\t\"blah\": null,\n\t\"foo\": \"bar\"\n}\n";
assert_eq!(result, expected);
Ok(())
}
#[tokio::test]
async fn test_custom_space_indent() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "non_pretty.json", UNSORTED_JSON).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
assert!(String::from_utf8_lossy(&result.output).contains("Fixing file"));
let result = fs_err::tokio::read_to_string(&file_path).await?;
let expected = indoc::indoc! {r#"
{
"alist": [
2,
34,
234
],
"blah": null,
"foo": "bar"
}
"#};
assert_eq!(result, expected);
Ok(())
}
#[tokio::test]
async fn test_remove_tab_indent() -> Result<()> {
let dir = tempdir()?;
let tab_content = indoc::indoc! {r#"
{
"alist": [
2,
34,
234
],
"blah": null,
"foo": "bar"
}
"#};
let file_path = create_test_file(&dir, "tab_indented.json", tab_content).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
assert!(String::from_utf8_lossy(&result.output).contains("Fixing file"));
let result = fs_err::tokio::read_to_string(&file_path).await?;
assert_eq!(result, PRETTY_JSON);
Ok(())
}
#[tokio::test]
async fn test_ensure_ascii_uppercase_to_lowercase() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "non_ascii.json", NON_ASCII_JSON).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
assert!(String::from_utf8_lossy(&result.output).contains("Fixing file"));
let result = fs_err::tokio::read_to_string(&file_path).await?;
let expected = indoc::indoc! {r#"
{
"alist": [
2,
34,
234
],
"blah": null,
"foo": "bar",
"non_ascii": "\u4e2d\u6587\u306b\u307b\u3093\u3054\ud55c\uad6d\uc5b4"
}
"#};
assert_eq!(result, expected);
Ok(())
}
#[tokio::test]
async fn test_ensure_ascii_already_lowercase() -> Result<()> {
let dir = tempdir()?;
let lowercase_content = NON_ASCII_JSON.to_lowercase();
let file_path = create_test_file(&dir, "non_ascii.json", &lowercase_content).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 0);
let result = fs_err::tokio::read_to_string(&file_path).await?;
let expected = indoc::indoc! {r#"
{
"alist": [
2,
34,
234
],
"blah": null,
"foo": "bar",
"non_ascii": "\u4e2d\u6587\u306b\u307b\u3093\u3054\ud55c\uad6d\uc5b4"
}
"#};
assert_eq!(result, expected);
Ok(())
}
#[tokio::test]
async fn test_no_ensure_ascii() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "non_ascii.json", NON_ASCII_JSON).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: false,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
assert!(String::from_utf8_lossy(&result.output).contains("Fixing file"));
let result = fs_err::tokio::read_to_string(&file_path).await?;
let expected = indoc::indoc! {r#"
{
"alist": [
2,
34,
234
],
"blah": null,
"foo": "bar",
"non_ascii": "中文にほんご한국어"
}
"#};
assert_eq!(result, expected);
Ok(())
}
#[test]
fn test_ensure_ascii_surrogate_pair_and_object_keys() -> Result<()> {
let args = PreparedArgs {
auto_fix: false,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let formatted = prettify_json(r#"{"emoji":"🐐","α":"beta"}"#, &args)?;
let expected = indoc::indoc! {r#"
{
"emoji": "\ud83d\udc10",
"\u03b1": "beta"
}
"#};
assert_eq!(formatted, expected);
Ok(())
}
#[tokio::test]
async fn test_nested_objects() -> Result<()> {
let dir = tempdir()?;
let nested = r#"{"outer": {"inner": "value", "another": 123}, "top": true}"#;
let file_path = create_test_file(&dir, "nested.json", nested).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
let result = fs_err::tokio::read_to_string(&file_path).await?;
let expected = indoc::indoc! {r#"
{
"outer": {
"another": 123,
"inner": "value"
},
"top": true
}
"#};
assert_eq!(result, expected);
Ok(())
}
#[tokio::test]
async fn test_array_preservation() -> Result<()> {
let dir = tempdir()?;
let array_json = r#"{"numbers": [5, 1, 9, 3], "sorted": false}"#;
let file_path = create_test_file(&dir, "array.json", array_json).await?;
let args = PreparedArgs {
auto_fix: true,
ensure_ascii: true,
indent_bytes: b" ".to_vec(),
ordered_top_keys: vec![],
sort_keys: true,
};
let result = check_file(&file_path, &file_path, &args)?;
assert_eq!(result.exit_status, 1);
let result = fs_err::tokio::read_to_string(&file_path).await?;
let expected = indoc::indoc! {r#"
{
"numbers": [
5,
1,
9,
3
],
"sorted": false
}
"#};
assert_eq!(result, expected);
Ok(())
}
}