use anyhow::Result;
use deno_terminal::colors;
use dprint_core::communication::AtomicFlag;
use dprint_core::plugins::HostFormatRequest;
use dprint_core::plugins::NullCancellationToken;
use parking_lot::Mutex;
use std::path::Path;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use thiserror::Error;

use crate::arg_parser::CheckSubCommand;
use crate::arg_parser::CliArgs;
use crate::arg_parser::FmtSubCommand;
use crate::arg_parser::OutputFormatTimesSubCommand;
use crate::arg_parser::StdInFmtSubCommand;
use crate::configuration::resolve_config_from_args;
use crate::environment::Environment;
use crate::format::EnsureStableFormat;
use crate::format::RunForFilePathError;
use crate::format::run_parallelized;
use crate::incremental::get_incremental_file;
use crate::patterns::FileMatcher;
use crate::plugins::PluginResolver;
use crate::resolution::PluginsScope;
use crate::resolution::ResolvePluginsScopeAndPathsOptions;
use crate::resolution::resolve_plugins_scope;
use crate::resolution::resolve_plugins_scope_and_paths;
use crate::utils::AtomicCounter;
use crate::utils::get_difference;

pub async fn stdin_fmt<TEnvironment: Environment>(
  cmd: &StdInFmtSubCommand,
  args: &CliArgs,
  environment: &TEnvironment,
  plugin_resolver: &Rc<PluginResolver<TEnvironment>>,
) -> Result<()> {
  let config = Rc::new(resolve_config_from_args(args, environment).await?);
  let plugins_scope = Rc::new(resolve_plugins_scope(config, environment, plugin_resolver).await?);
  plugins_scope.ensure_plugins_found()?;
  plugins_scope.ensure_no_global_config_diagnostics()?;

  // if the path is absolute, then apply exclusion rules
  if environment.is_absolute_path(&cmd.file_name_or_path) {
    let file_matcher = FileMatcher::new(environment.clone(), plugins_scope.config.as_ref().unwrap(), &cmd.patterns, &environment.cwd())?;
    // canonicalize the file path, then check if it's in the list of file paths.
    let resolved_file_path = environment.canonicalize(&cmd.file_name_or_path)?;
    // log the file text as-is since it's not in the list of files to format
    if !file_matcher.matches(resolved_file_path) {
      environment.log_machine_readable(&cmd.file_bytes);
      return Ok(());
    }
  }
  output_stdin_format(PathBuf::from(&cmd.file_name_or_path), &cmd.file_bytes, plugins_scope, environment).await
}

async fn output_stdin_format<TEnvironment: Environment>(
  file_path: PathBuf,
  file_bytes: &[u8],
  plugins_scope: Rc<PluginsScope<TEnvironment>>,
  environment: &TEnvironment,
) -> Result<()> {
  let result = plugins_scope
    .format(HostFormatRequest {
      file_path,
      file_bytes: file_bytes.to_vec(),
      range: None,
      override_config: Default::default(),
      token: Arc::new(NullCancellationToken),
    })
    .await?;
  match result {
    Some(text) => environment.log_machine_readable(&text),
    None => environment.log_machine_readable(file_bytes),
  }
  Ok(())
}

pub async fn output_format_times<TEnvironment: Environment>(
  cmd: &OutputFormatTimesSubCommand,
  args: &CliArgs,
  environment: &TEnvironment,
  plugin_resolver: &Rc<PluginResolver<TEnvironment>>,
) -> Result<()> {
  let scopes = resolve_plugins_scope_and_paths(
    args,
    &cmd.patterns,
    environment,
    plugin_resolver,
    ResolvePluginsScopeAndPathsOptions { skip_traversal: false },
  )
  .await?;
  scopes.ensure_valid_for_cli_args(args)?;
  let durations: Arc<Mutex<Vec<(PathBuf, u128)>>> = Arc::new(Mutex::new(Vec::new()));

  for scope_and_paths in scopes.into_iter() {
    run_parallelized(scope_and_paths, environment, None, EnsureStableFormat(false), {
      let durations = durations.clone();
      move |file_path, _, _, start_instant, _| {
        let duration = start_instant.elapsed().as_millis();
        durations.lock().push((file_path, duration));
        Ok(())
      }
    })
    .await?;
  }

  let mut durations = durations.lock();
  durations.sort_by_key(|k| k.1);
  for (file_path, duration) in durations.iter() {
    log_stdout_info!(environment, "{}ms - {}", duration, file_path.display());
  }

  Ok(())
}

#[derive(Error, Debug)]
#[error("{}", match self {
  Self::Files { count } => format!(
    "Found {} not formatted {}. Run {} to fix.",
    colors::bold(count.to_string()),
    if *count == 1 { "file" } else { "files" },
    colors::bold("dprint fmt"),
  ),
  Self::DisplayNone => "".to_string(), // no output
})]
pub enum CheckError {
  DisplayNone,
  Files { count: usize },
}

pub async fn check<TEnvironment: Environment>(
  cmd: &CheckSubCommand,
  args: &CliArgs,
  environment: &TEnvironment,
  plugin_resolver: &Rc<PluginResolver<TEnvironment>>,
) -> Result<()> {
  let scopes = resolve_plugins_scope_and_paths(
    args,
    &cmd.patterns,
    environment,
    plugin_resolver,
    ResolvePluginsScopeAndPathsOptions { skip_traversal: false },
  )
  .await?;
  scopes.ensure_valid_for_cli_args(args)?;
  let not_formatted_files_count = Arc::new(AtomicCounter::default());
  let list_different = cmd.list_different;
  let fail_fast_flag = cmd.fail_fast.then(|| Arc::new(AtomicFlag::default()));

  for scope_and_paths in scopes.into_iter() {
    let incremental_file = scope_and_paths
      .scope
      .config
      .as_ref()
      .and_then(|config| get_incremental_file(cmd.incremental, config, &scope_and_paths.scope, environment))
      .map(Arc::new);
    run_parallelized(scope_and_paths, environment, incremental_file.clone(), EnsureStableFormat(false), {
      let not_formatted_files_count = not_formatted_files_count.clone();
      let incremental_file = incremental_file.clone();
      let fail_fast_flag = fail_fast_flag.clone();
      move |file_path, file_bytes, formatted_bytes, _, environment| {
        if formatted_bytes != file_bytes {
          not_formatted_files_count.inc();
          if list_different {
            log_stdout_info!(environment, "{}", file_path.display());
          } else {
            output_difference(&file_path, &file_bytes, &formatted_bytes, &environment);
          }
          if let Some(fail_fast_flag) = &fail_fast_flag {
            fail_fast_flag.raise();
            return Err(RunForFilePathError::Stop);
          }
        } else {
          // update the incremental cache when the file is already formatted correctly
          // so that this runs faster next time, but don't update it with the
          // correctly formatted file because it hasn't undergone a stable
          // formatting check
          if let Some(incremental_file) = &incremental_file {
            incremental_file.update_file(&formatted_bytes);
          }
        }
        Ok(())
      }
    })
    .await?;

    if fail_fast_flag.as_ref().map(|flag| flag.is_raised()).unwrap_or(false) {
      return Err(CheckError::DisplayNone.into());
    }

    if let Some(incremental_file) = &incremental_file {
      incremental_file.write();
    }
  }

  let not_formatted_files_count = not_formatted_files_count.get();
  if not_formatted_files_count == 0 {
    Ok(())
  } else {
    Err(
      if list_different {
        CheckError::DisplayNone
      } else {
        CheckError::Files {
          count: not_formatted_files_count,
        }
      }
      .into(),
    )
  }
}

fn output_difference(file_path: &Path, file_bytes: &[u8], formatted_bytes: &[u8], environment: &impl Environment) {
  let file_text = match String::from_utf8(file_bytes.to_vec()) {
    Ok(text) => text,
    Err(err) => {
      log_warn!(
        environment,
        "Failed outputting difference for {}. Could not get original text as utf-8. {:#}",
        file_path.display(),
        err
      );
      return;
    }
  };
  let formatted_text = match String::from_utf8(formatted_bytes.to_vec()) {
    Ok(text) => text,
    Err(err) => {
      log_warn!(
        environment,
        "Failed outputting difference for {}. Coult not get formatted text as utf-8. {:#}",
        file_path.display(),
        err
      );
      return;
    }
  };
  let difference_text = get_difference(&file_text, &formatted_text);
  log_stdout_info!(environment, "{} {}:\n{}\n--", colors::red_bold("from"), file_path.display(), difference_text);
}

pub async fn format<TEnvironment: Environment>(
  cmd: &FmtSubCommand,
  args: &CliArgs,
  environment: &TEnvironment,
  plugin_resolver: &Rc<PluginResolver<TEnvironment>>,
) -> Result<()> {
  let scopes = resolve_plugins_scope_and_paths(
    args,
    &cmd.patterns,
    environment,
    plugin_resolver,
    ResolvePluginsScopeAndPathsOptions { skip_traversal: false },
  )
  .await?;
  scopes.ensure_valid_for_cli_args(args)?;

  let formatted_files_count = Arc::new(AtomicCounter::default());
  for scope_and_paths in scopes.into_iter() {
    let incremental_file = scope_and_paths
      .scope
      .config
      .as_ref()
      .and_then(|config| get_incremental_file(cmd.incremental, config, &scope_and_paths.scope, environment))
      .map(Arc::new);
    let output_diff = cmd.diff;

    run_parallelized(
      scope_and_paths,
      environment,
      incremental_file.clone(),
      EnsureStableFormat(cmd.enable_stable_format),
      {
        let formatted_files_count = formatted_files_count.clone();
        let incremental_file = incremental_file.clone();
        move |file_path, file_bytes, formatted_bytes, _, environment| {
          if let Some(incremental_file) = &incremental_file {
            incremental_file.update_file(&formatted_bytes);
          }

          if formatted_bytes != file_bytes {
            if output_diff {
              output_difference(&file_path, &file_bytes, &formatted_bytes, &environment);
            }

            formatted_files_count.inc();
            environment.write_file_bytes(file_path, &formatted_bytes)?;
          }

          Ok(())
        }
      },
    )
    .await?;

    if let Some(incremental_file) = &incremental_file {
      incremental_file.write();
    }
  }

  let formatted_files_count = formatted_files_count.get();
  if formatted_files_count > 0 {
    let suffix = if formatted_files_count == 1 { "file" } else { "files" };
    log_stdout_info!(environment, "Formatted {} {}.", colors::bold(formatted_files_count.to_string()), suffix);
    if cmd.fail_on_change {
      return Err(CheckError::Files { count: formatted_files_count }.into());
    }
  }

  Ok(())
}

#[cfg(test)]
mod test {
  use deno_terminal::colors;
  use pretty_assertions::assert_eq;

  use crate::environment::Environment;
  use crate::environment::TestEnvironment;
  use crate::environment::TestEnvironmentBuilder;
  use crate::test_helpers;
  use crate::test_helpers::PROCESS_PLUGIN_ZIP_CHECKSUM;
  use crate::test_helpers::TestAppError;
  use crate::test_helpers::TestProcessPluginFile;
  use crate::test_helpers::TestProcessPluginFileBuilder;
  use crate::test_helpers::get_plural_check_text;
  use crate::test_helpers::get_plural_formatted_text;
  use crate::test_helpers::get_singular_check_text;
  use crate::test_helpers::get_singular_formatted_text;
  use crate::test_helpers::run_test_cli;
  use crate::test_helpers::run_test_cli_with_stdin;
  use crate::utils::TestStdInReader;
  use crate::utils::get_difference;

  #[test]
  fn should_output_format_times() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file("/file.txt", "const t=4;")
      .write_file("/file2.txt", "const t=4;")
      .write_file("/file3.txt_ps", "const t=4;")
      .build();
    run_test_cli(vec!["output-format-times", "**/*.*"], &environment).unwrap();
    let logged_messages = environment.take_stdout_messages();
    assert_eq!(logged_messages.len(), 3); // good enough
  }

  #[test]
  fn should_format_single_file() {
    let file_path1 = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(file_path1, "text")
      .build();
    run_test_cli(vec!["fmt", "/file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_formatted");
  }

  #[test]
  fn should_format_files() {
    let file_path1 = "/file.txt";
    let file_path2 = "/file.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .build();
    environment.set_max_threads(1); // ensure files are still formatted with only 1 core
    run_test_cli(vec!["fmt", "/file.*"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_formatted_process");
  }

  #[test]
  fn should_format_files_from_stdin_files() {
    let file_path1 = "/file.txt";
    let file_path2 = "/sub dir/file with space.txt";
    let file_path3 = "/not-included.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .write_file(&file_path3, "text3")
      .build();
    // include a blank line and a path containing spaces to ensure both are handled
    let test_std_in = TestStdInReader::from(format!("{}\n\n{}\n", file_path1, file_path2));
    run_test_cli_with_stdin(vec!["fmt", "--stdin-files"], &environment, test_std_in).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_formatted");
    // wasn't in the list, so it's left untouched
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text3");
  }

  #[test]
  fn should_check_files_from_stdin_files() {
    let file_path1 = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path1, "text")
      .build();
    let test_std_in = TestStdInReader::from(format!("{}\n", file_path1));
    let error_message = run_test_cli_with_stdin(vec!["check", "--stdin-files", "--list-different"], &environment, test_std_in)
      .err()
      .unwrap();
    error_message.assert_exit_code(20);
    assert_eq!(environment.take_stdout_messages(), vec![file_path1.to_string()]);
  }

  #[test]
  fn should_format_only_staged_files() {
    let file_path1 = "/file.txt";
    let file_path2 = "/file.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file(&file_path1, "text_1")
      .write_file(&file_path2, "text_2")
      .add_staged_file(file_path1)
      .build();

    environment.set_max_threads(1);
    run_test_cli(vec!["fmt", "--staged"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_1_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text_2");
  }

  #[test]
  fn should_format_only_dirty_files() {
    let file_path1 = "/file.txt";
    let file_path2 = "/file.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file(&file_path1, "text_1")
      .write_file(&file_path2, "text_2")
      .add_dirty_file(file_path1)
      .build();

    environment.set_max_threads(1);
    run_test_cli(vec!["fmt", "--dirty"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_1_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text_2");
  }

  #[test]
  fn should_format_only_dirty_files_while_respecting_includes() {
    let file_path1 = "/file.txt";
    let file_path2 = "/file.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("./dprint.json", |c| {
        c.add_includes("**/*.txt_ps").add_remote_wasm_plugin().add_remote_process_plugin();
      })
      .write_file(&file_path1, "text_1")
      .write_file(&file_path2, "text_2")
      .add_dirty_file(file_path1)
      .add_dirty_file(file_path2)
      .build();

    environment.set_max_threads(1);
    run_test_cli(vec!["fmt", "--dirty"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_1");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text_2_formatted_process");
  }

  #[test]
  fn should_format_only_staged_files_while_respecting_includes() {
    let file_path1 = "/file.txt";
    let file_path2 = "/file.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("./dprint.json", |c| {
        c.add_includes("**/*.txt_ps").add_remote_wasm_plugin().add_remote_process_plugin();
      })
      .write_file(&file_path1, "text_1")
      .write_file(&file_path2, "text_2")
      .add_staged_file(file_path1)
      .add_staged_file(file_path2)
      .build();

    environment.set_max_threads(1);
    run_test_cli(vec!["fmt", "--staged"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_1");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text_2_formatted_process");
  }

  #[test]
  fn should_format_plugin_explicitly_specified_files() {
    // this file name is mentioned in test-process-plugin's PluginInfo
    let file_path1 = "/test-process-plugin-exact-file";
    let environment = TestEnvironmentBuilder::with_initialized_remote_process_plugin()
      .write_file(&file_path1, "text")
      .build();
    run_test_cli(vec!["fmt", "*"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_formatted_process");
  }

  #[test]
  fn should_format_files_with_local_plugin() {
    let file_path = "/file.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_local_wasm_plugin()
      .with_default_config(|c| {
        c.add_local_wasm_plugin();
      })
      .write_file(&file_path, "text")
      .initialize()
      .build();
    run_test_cli(vec!["fmt", "/file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path).unwrap(), "text_formatted");
  }

  #[test]
  fn should_handle_wasm_plugin_erroring() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/file.txt", "should_error") // special text that makes the plugin error
      .build();
    let error_message = run_test_cli(vec!["fmt", "/file.txt"], &environment).err().unwrap();
    assert_eq!(
      environment.take_stderr_messages(),
      vec![String::from("Error formatting /file.txt. Message: Did error.")]
    );
    assert_eq!(error_message.to_string(), "Had 1 error formatting.");
  }

  #[test]
  fn should_handle_process_plugin_erroring() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_process_plugin()
      .write_file("/file.txt_ps", "should_error") // special text that makes the plugin error
      .build();
    let error_message = run_test_cli(vec!["fmt", "/file.txt_ps"], &environment).err().unwrap();
    assert_eq!(
      environment.take_stderr_messages(),
      vec![String::from("Error formatting /file.txt_ps. Message: Did error.")]
    );
    assert_eq!(error_message.to_string(), "Had 1 error formatting.");
  }

  #[test]
  fn should_handle_wasm_plugin_panicking() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file("/file1.txt", "should_panic") // special text to make it panic
      .write_file("/file2.txt_ps", "test")
      .build();
    let error_message = run_test_cli(vec!["fmt", "**.{txt,txt_ps}"], &environment).err().unwrap();
    let logged_errors = environment.take_stderr_messages();
    assert_eq!(logged_errors.len(), 1);
    let expected_start_text = concat!(
      "Critical error formatting /file1.txt. Cannot continue. ",
      "Message: Originally panicked in test-plugin, then failed reinitialize. ",
      "This may be a bug in the plugin, the dprint cli is out of date, or the plugin is out of date.",
    );
    assert_eq!(&logged_errors[0][..expected_start_text.len()], expected_start_text);
    assert_eq!(error_message.to_string(), "Had 1 error formatting.");
    // should still format with the other plugin
    assert_eq!(environment.read_file("/file2.txt_ps").unwrap(), "test_formatted_process");
  }

  #[test]
  fn should_format_calling_process_plugin_with_wasm_plugin_and_no_plugin_exists() {
    let file_path = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path, "plugin: format this text")
      .build();
    run_test_cli(vec!["fmt", "/file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path).unwrap(), "plugin: format this text_formatted");
  }

  #[test]
  fn should_format_calling_process_plugin_with_wasm_plugin_and_process_plugin_exists() {
    let file_path = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file(&file_path, "plugin: format this text")
      .build();
    run_test_cli(vec!["fmt", "/file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(
      environment.read_file(&file_path).unwrap(),
      "plugin: format this text_formatted_process_formatted"
    );
  }

  #[test]
  fn should_format_calling_process_plugin_with_wasm_plugin_using_additional_plugin_specified_config() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file(&file_path1, "plugin-config: format this text")
      .write_file(&file_path2, "plugin: format this text")
      .build();
    run_test_cli(vec!["fmt", "/*.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(
      environment.read_file(&file_path1).unwrap(),
      "plugin-config: format this text_custom_config_formatted"
    );
    assert_eq!(
      environment.read_file(&file_path2).unwrap(),
      "plugin: format this text_formatted_process_formatted"
    );
  }

  #[test]
  fn should_error_calling_process_plugin_with_wasm_plugin_and_process_plugin_errors() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file("/file.txt", "plugin: should_error")
      .build();
    let error_message = run_test_cli(vec!["fmt", "/file.txt"], &environment).err().unwrap();
    assert_eq!(error_message.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![String::from("Error formatting /file.txt. Message: Did error.")]
    );
  }

  #[test]
  fn should_format_calling_other_plugin_with_process_plugin_and_no_plugin_exists() {
    let file_path = "/file.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_process_plugin()
      .write_file(&file_path, "plugin: format this text")
      .build();
    run_test_cli(vec!["fmt", "/file.txt_ps"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path).unwrap(), "plugin: format this text_formatted_process");
  }

  #[test]
  fn should_format_calling_wasm_plugin_with_process_plugin_and_wasm_plugin_exists() {
    let file_path = "/file.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file(&file_path, "plugin: format this text")
      .build();
    run_test_cli(vec!["fmt", "/file.txt_ps"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(
      environment.read_file(&file_path).unwrap(),
      "plugin: format this text_formatted_formatted_process"
    );
  }

  #[test]
  fn should_format_calling_wasm_plugin_with_process_plugin_using_additional_plugin_specified_config() {
    let file_path1 = "/file1.txt_ps";
    let file_path2 = "/file2.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file(&file_path1, "plugin-config: format this text")
      .write_file(&file_path2, "plugin: format this text")
      .build();
    run_test_cli(vec!["fmt", "*.txt_ps"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(
      environment.read_file(&file_path1).unwrap(),
      "plugin-config: format this text_custom_config_formatted_process"
    );
    assert_eq!(
      environment.read_file(&file_path2).unwrap(),
      "plugin: format this text_formatted_formatted_process"
    );
  }

  #[test]
  fn should_error_calling_wasm_plugin_with_process_plugin_and_wasm_plugin_errors() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .write_file("/file.txt_ps", "plugin: should_error")
      .build();
    let error_message = run_test_cli(vec!["fmt", "/file.txt_ps"], &environment).err().unwrap();
    assert_eq!(error_message.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![String::from("Error formatting /file.txt_ps. Message: Did error.")]
    );
  }

  #[test]
  fn should_format_when_specifying_dot_slash_paths() {
    let file_path = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path, "text")
      .build();
    run_test_cli(vec!["fmt", "./file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path).unwrap(), "text_formatted");
  }

  #[test]
  fn should_exclude_a_specified_dot_slash_path() {
    let file_path = "/file.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path, "text")
      .write_file(&file_path2, "text")
      .build();
    run_test_cli(vec!["fmt", "./**/*.txt", "--excludes", "./file2.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path).unwrap(), "text_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text");
  }

  #[test]
  fn should_ignore_files_in_node_modules_by_default() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/node_modules/file.txt", "")
      .write_file("/test/node_modules/file.txt", "")
      .write_file("/file.txt", "")
      .build();
    run_test_cli(vec!["fmt", "**/*.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
  }

  #[test]
  fn should_not_ignore_files_in_node_modules_when_allowed() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/node_modules/file.txt", "const t=4;")
      .write_file("/test/node_modules/file.txt", "const t=4;")
      .build();
    run_test_cli(vec!["fmt", "--allow-node-modules", "**/*.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
  }

  #[test]
  fn should_format_files_with_config() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/config.json", |c| {
        c.add_remote_wasm_plugin()
          .add_remote_process_plugin()
          .add_config_section(
            "test-plugin",
            r#"{
                        "ending": "custom-formatted"
                    }"#,
          )
          .add_config_section(
            "testProcessPlugin",
            r#"{
                        "ending": "custom-formatted2"
                    }"#,
          );
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .build();

    run_test_cli(vec!["fmt", "--config", "/config.json", "/file1.txt", "/file2.txt_ps"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_custom-formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_custom-formatted2");
  }

  #[test]
  fn should_format_files_with_config_sub_dir_auto_discoverable_name() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt_ps";
    let file_path3 = "/other_dir/file1.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/sub_dir/dprint.json", |c| {
        c.add_remote_wasm_plugin()
          .add_remote_process_plugin()
          .add_config_section(
            "test-plugin",
            r#"{
                        "ending": "custom-formatted"
                    }"#,
          )
          .add_config_section(
            "testProcessPlugin",
            r#"{
                        "ending": "custom-formatted2"
                    }"#,
          )
          .add_excludes("./excludes");
      })
      .with_local_config("/other_dir/dprint.json", |c| {
        c.add_remote_wasm_plugin().add_config_section(
          "test-plugin",
          r#"{
              "ending": "other-ending"
            }"#,
        );
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .write_file(&file_path3, "text3")
      .write_file("./excludes/file1.txt", "text4")
      .build();

    run_test_cli(vec!["fmt", "--config", "/sub_dir/dprint.json"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(3)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_custom-formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_custom-formatted2");
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text3_other-ending");
  }

  #[test]
  fn should_format_files_with_config_using_c() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(file_path1, "text")
      .write_file(
        "/config.json",
        r#"{
                "test-plugin": { "ending": "custom-formatted" },
                "plugins": ["https://plugins.dprint.dev/test-plugin.wasm"]
            }"#,
      )
      .build();

    run_test_cli(vec!["fmt", "-c", "/config.json", "/file1.txt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_custom-formatted");
  }

  #[test]
  fn should_error_when_config_file_does_not_exist() {
    let environment = TestEnvironment::new();
    environment.write_file("/test.txt", "test").unwrap();

    let err = run_test_cli(vec!["fmt", "**/*.txt"], &environment).err().unwrap();
    err.assert_exit_code(11);

    assert_eq!(
      err.to_string(),
      format!(
        concat!(
          "No config file found at /dprint.json. ",
          "Did you mean to create (dprint init) or specify one (--config <path>)?",
          "\n\n{}",
        ),
        colors::gray(concat!(
          "Note: dprint now supports global configuration. Set it up with ",
          "`dprint init --global` then edit with `dprint config edit --global`",
        ))
      )
    );
  }

  #[test]
  fn should_support_config_file_urls() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_remote_config("https://dprint.dev/test.json", |c| {
        c.add_remote_wasm_plugin()
          .add_config_section("test-plugin", r#"{ "ending": "custom-formatted" }"#);
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .build();

    run_test_cli(
      vec!["fmt", "--config", "https://dprint.dev/test.json", "/file1.txt", "/file2.txt"],
      &environment,
    )
    .unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(
      environment.take_stderr_messages(),
      vec!["Compiling https://plugins.dprint.dev/test-plugin.wasm"]
    );
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_custom-formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_custom-formatted");
  }

  #[test]
  fn should_format_files_with_config_associations() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt_ps";
    let file_path3 = "/file2.other";
    let file_path4 = "/src/some_file_name";
    let file_path5 = "/src/sub-dir/test-process-plugin-exact-file";
    let file_path6 = "/file6.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/config.json", |c| {
        c.add_remote_wasm_plugin()
          .add_remote_process_plugin()
          .add_config_section(
            "test-plugin",
            r#"{
              "associations": [
                "**/*.txt_ps",
                "test-process-plugin-exact-file"
              ],
              "ending": "wasm"
            }"#,
          )
          .add_config_section(
            "testProcessPlugin",
            r#"{
              "associations": [
                "**/*.other",
                "some_file_name",
              ]
              "ending": "ps"
            }"#,
          );
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .write_file(&file_path3, "text3")
      .write_file(&file_path4, "text4")
      .write_file(&file_path5, "text5")
      .write_file(&file_path6, "plugin: text6")
      .build();

    run_test_cli(vec!["fmt", "--config", "/config.json"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(6)]);
    // associations are additive, so the test plugin still matches its default `.txt` extension
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_wasm");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_wasm");
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text3_ps");
    assert_eq!(environment.read_file(&file_path4).unwrap(), "text4_ps");
    assert_eq!(environment.read_file(&file_path5).unwrap(), "text5_wasm");
    // this will request formatting a .txt_ps file, but should be caught be the associations
    assert_eq!(environment.read_file(&file_path6).unwrap(), "plugin: text6_wasm_wasm");
  }

  #[test]
  fn should_use_request_override_config_over_config_file_overrides() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin()
          .add_remote_process_plugin()
          .add_config_section(
            "test-plugin",
            r#"{
              "associations": ["**/*.txt"],
              "ending": "wasm"
            }"#,
          )
          .add_config_section(
            "testProcessPlugin",
            r#"{
              "associations": ["**/*.txt_ps"],
              "ending": "process",
              "overrides": {
                "files": "**/test.txt_ps",
                "ending": "config_file"
              }
            }"#,
          );
      })
      .write_file("/file.txt", "plugin-config: text")
      .build();

    run_test_cli(vec!["fmt", "/file.txt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file("/file.txt").unwrap(), "plugin-config: text_custom_config_wasm");
  }

  #[test]
  fn should_format_files_with_config_associations_multiple_plugins_same_files() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt_ps";
    let file_path3 = "/file2.other";
    let file_path4 = "/src/some_file_name";
    let file_path5 = "/src/sub-dir/test-process-plugin-exact-file";
    let file_path6 = "/file6.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/config.json", |c| {
        c.set_incremental(false)
          .add_remote_wasm_plugin()
          .add_remote_process_plugin()
          .add_config_section(
            "test-plugin",
            r#"{
              "associations": [
                "**/*.{txt,txt_ps,other}",
                "some_file_name",
                "test-process-plugin-exact-file"
              ],
              "ending": "wasm"
            }"#,
          )
          .add_config_section(
            "testProcessPlugin",
            r#"{
              "associations": [
                "**/*.{txt,txt_ps,other}",
                "some_file_name",
                "test-process-plugin-exact-file"
              ],
              "ending": "ps"
            }"#,
          );
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .write_file(&file_path3, "text3")
      .write_file(&file_path4, "text4")
      .write_file(&file_path5, "text5")
      .write_file(&file_path6, "plugin: text6")
      .build();

    run_test_cli(vec!["fmt", "--config", "/config.json", "--skip-stable-format"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(6)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_wasm_ps");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_wasm_ps");
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text3_wasm_ps");
    assert_eq!(environment.read_file(&file_path4).unwrap(), "text4_wasm_ps");
    assert_eq!(environment.read_file(&file_path5).unwrap(), "text5_wasm_ps");
    assert_eq!(environment.read_file(&file_path6).unwrap(), "plugin: text6_wasm_ps_wasm_ps_ps");
  }

  #[test]
  fn should_format_files_with_config_overrides() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin().add_config_section(
          "test-plugin",
          r#"{
            "ending": "base",
            "overrides": {
              "files": "**/package.txt",
              "ending": "package"
            }
          }"#,
        );
      })
      .write_file("/file.txt", "text")
      .write_file("/package.txt", "text")
      .build();

    run_test_cli(vec!["fmt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file("/file.txt").unwrap(), "text_base");
    assert_eq!(environment.read_file("/package.txt").unwrap(), "text_package");
  }

  #[test]
  fn should_format_files_with_multiple_matching_config_overrides_in_order() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin().add_config_section(
          "test-plugin",
          r#"{
            "ending": "base",
            "overrides": [
              {
                "files": "**/*.txt",
                "ending": "txt"
              },
              {
                "files": "**/package.txt",
                "ending": "package"
              }
            ]
          }"#,
        );
      })
      .write_file("/file.txt", "text")
      .write_file("/package.txt", "text")
      .build();

    run_test_cli(vec!["fmt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file("/file.txt").unwrap(), "text_txt");
    assert_eq!(environment.read_file("/package.txt").unwrap(), "text_package");
  }

  #[test]
  fn should_format_files_all_negated_associations_no_config_excludes() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/config.json", |c| {
        c.add_remote_wasm_plugin().add_config_section(
          "test-plugin",
          r#"{
              "associations": [
                "!**/file2.txt",
                "!**/file3.txt",
              ],
              "ending": "wasm"
            }"#,
        );
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .build();

    run_test_cli(vec!["fmt", "--config", "/config.json"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_wasm");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2"); // ignored
  }

  #[test]
  fn should_format_files_associations_are_additive_to_defaults() {
    let file_path1 = "/file1.txt"; // matched by default extension
    let file_path2 = "/file2.other"; // added via associations
    let file_path3 = "/file3.asdf"; // not matched by anything
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/config.json", |c| {
        c.add_remote_wasm_plugin().add_config_section(
          "test-plugin",
          r#"{
              "associations": [
                "**/*.other"
              ],
              "ending": "wasm"
            }"#,
        );
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .write_file(&file_path3, "text3")
      .build();

    run_test_cli(vec!["fmt", "--config", "/config.json"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_wasm"); // default extension kept
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_wasm"); // added via associations
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text3"); // not matched
  }

  #[test]
  fn should_cancel_default_extension_with_negated_association() {
    let file_path1 = "/file1.txt"; // default extension, but cancelled by the negated glob
    let file_path2 = "/file2.other"; // added via associations
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/config.json", |c| {
        c.add_remote_wasm_plugin().add_config_section(
          "test-plugin",
          r#"{
              "associations": [
                "**/*.other",
                "!**/*.txt"
              ],
              "ending": "wasm"
            }"#,
        );
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .build();

    run_test_cli(vec!["fmt", "--config", "/config.json"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text"); // default `.txt` cancelled by `!**/*.txt`
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_wasm"); // added via associations
  }

  #[test]
  fn should_allow_using_config_to_get_file_matching_info() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt_ps";
    let file_path3 = "/file1.other";
    let file_path4 = "/file2.other_ps";
    let file_path5 = "/format_wasm";
    let file_path6 = "/format_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/config.json", |c| {
        c.add_remote_wasm_plugin()
          .add_remote_process_plugin()
          .add_config_section(
            "test-plugin",
            r#"{
              "file_extensions": ["other"],
              "file_names": ["format_wasm"]
            }"#,
          )
          .add_config_section(
            "testProcessPlugin",
            r#"{
              "file_extensions": ["other_ps"],
              "file_names": ["format_ps"]
            }"#,
          );
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text2")
      .write_file(&file_path3, "text3")
      .write_file(&file_path4, "text4")
      .write_file(&file_path5, "text5")
      .write_file(&file_path6, "text6")
      .build();

    run_test_cli(vec!["fmt", "--config", "/config.json"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(4)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2");
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text3_formatted");
    assert_eq!(environment.read_file(&file_path4).unwrap(), "text4_formatted_process");
    assert_eq!(environment.read_file(&file_path5).unwrap(), "text5_formatted");
    assert_eq!(environment.read_file(&file_path6).unwrap(), "text6_formatted_process");
  }

  #[test]
  fn should_error_on_wasm_plugin_config_diagnostic() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_config_section("test-plugin", r#"{ "non-existent": 25 }"#);
      })
      .write_file("/test.txt", "test")
      .build();

    let error_message = run_test_cli(vec!["fmt", "**/*.txt"], &environment).err().unwrap();

    assert_eq!(error_message.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![
        "[test-plugin]: Unknown property in configuration (non-existent)",
        "[test-plugin]: Error initializing from configuration file. Had 1 diagnostic(s)."
      ]
    );
  }

  #[test]
  fn should_error_on_process_plugin_config_diagnostic() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_process_plugin()
      .with_default_config(|c| {
        // Add this same plugin a few times in the configuration file for
        // some additional testing. It should handle it gracefully.
        c.add_remote_process_plugin();
        c.add_remote_process_plugin();

        c.add_config_section(
          "testProcessPlugin",
          r#"{
                    "non-existent": 25
                }"#,
        );
      })
      .write_file("/test.txt_ps", "test")
      .build();

    let error_message = run_test_cli(vec!["fmt", "**/*.txt_ps"], &environment).err().unwrap();

    assert_eq!(error_message.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![
        "[test-process-plugin]: Unknown property in configuration (non-existent)",
        "[test-process-plugin]: Error initializing from configuration file. Had 1 diagnostic(s)."
      ]
    );
  }

  #[test]
  fn should_error_config_diagnostic_multiple_plugins_same_file_via_associations() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/config.json", |c| {
        c.add_remote_wasm_plugin()
          .add_remote_process_plugin()
          .add_config_section(
            "test-plugin",
            r#"{
              "associations": [
                "shared_file"
              ],
              "ending": "wasm"
            }"#,
          )
          .add_config_section(
            "testProcessPlugin",
            r#"{
              "associations": [
                "shared_file"
              ],
              "non-existent": "value"
            }"#,
          );
      })
      // `.asdf` isn't matched by any plugin's defaults or associations
      .write_file("/test.asdf", "text")
      .write_file("/shared_file", "text")
      .build();

    let error_message = run_test_cli(vec!["fmt", "--config", "/config.json"], &environment).err().unwrap();

    assert_eq!(error_message.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![
        "[test-process-plugin]: Unknown property in configuration (non-existent)",
        "[test-process-plugin]: Error initializing from configuration file. Had 1 diagnostic(s)."
      ]
    );
    assert_eq!(environment.read_file("/test.asdf").unwrap(), "text");
    assert_eq!(environment.read_file("/shared_file").unwrap(), "text");
  }

  #[test]
  fn should_error_when_no_plugins_specified() {
    let environment = TestEnvironmentBuilder::new()
      .with_default_config(|c| {
        c.ensure_plugins_section();
      })
      .write_file("/test.txt", "test")
      .build();

    let err = run_test_cli(vec!["fmt", "**/*.txt"], &environment).err().unwrap();
    err.assert_exit_code(13);

    assert_eq!(
      err.to_string(),
      "No formatting plugins found. Ensure at least one is specified in the 'plugins' array of the configuration file."
    );
  }

  #[test]
  fn should_use_plugins_specified_in_cli_args() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_plugin("https://plugins.dprint.dev/other.wasm");
      })
      .write_file("/test.txt", "test")
      .build();

    run_test_cli(
      vec!["fmt", "**/*.txt", "--plugins", "https://plugins.dprint.dev/test-plugin.wasm"],
      &environment,
    )
    .unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
  }

  #[test]
  fn should_allow_using_no_config_when_plugins_specified() {
    let environment = TestEnvironmentBuilder::new().add_remote_wasm_plugin().write_file("/test.txt", "test").build();

    run_test_cli(
      vec!["fmt", "**/*.txt", "--plugins", "https://plugins.dprint.dev/test-plugin.wasm"],
      &environment,
    )
    .unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(
      environment.take_stderr_messages(),
      vec!["Compiling https://plugins.dprint.dev/test-plugin.wasm"]
    );
  }

  #[test]
  fn should_not_do_excess_object_property_diagnostics_when_plugins_cli_specified() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_default_config(|c| {
        c.add_config_section("excess-object", "{}").add_remote_process_plugin();
      })
      .write_file("/test.txt", "test")
      .build();

    run_test_cli(
      vec!["fmt", "**/*.txt", "--plugins", "https://plugins.dprint.dev/test-plugin.wasm"],
      &environment,
    )
    .unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);

    // now it errors because no --plugins specified
    let err = run_test_cli(vec!["fmt", "**/*.txt"], &environment).err().unwrap();
    err.assert_exit_code(11);
    assert_eq!(
      err.to_string(),
      "* Unexpected non-string, boolean, or int property (excess-object)\n\nHad 1 config diagnostic(s) in /dprint.json"
    );
  }

  #[test]
  fn should_not_do_excess_primitive_property_diagnostics_when_plugins_cli_specified() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_default_config(|c| {
        c.add_config_section("excess-primitive", "true").add_remote_process_plugin();
      })
      .write_file("/test.txt", "test")
      .build();

    run_test_cli(
      vec!["fmt", "**/*.txt", "--plugins", "https://plugins.dprint.dev/test-plugin.wasm"],
      &environment,
    )
    .unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);

    // now it errors because no --plugins specified
    let err = run_test_cli(vec!["fmt", "**/*.txt"], &environment).err().unwrap();
    err.assert_exit_code(11);
    assert_eq!(
      err.to_string(),
      "* Unknown property in configuration (excess-primitive)\n\nHad 1 config diagnostic(s) in /dprint.json"
    );
  }

  #[test]
  fn should_error_when_no_files_match_glob() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin().build();
    let error = run_test_cli(vec!["fmt", "**/*.txt"], &environment).err().unwrap();

    assert_no_files_found(&error, &environment);
  }

  #[test]
  fn should_not_error_when_no_files_match_allow_no_files_output() {
    run_allow_no_files_test("fmt");
    run_allow_no_files_test("check");
    run_allow_no_files_test("output-format-times");

    fn run_allow_no_files_test(sub_command: &str) {
      // with
      {
        let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin().build();
        assert!(run_test_cli(vec![sub_command, "--allow-no-files", "**/*.txt"], &environment).is_ok());
      }
      // without
      {
        let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin().build();
        let error = run_test_cli(vec![sub_command, "**/*.txt"], &environment).err().unwrap();
        assert_no_files_found(&error, &environment);
      }
    }
  }

  #[track_caller]
  fn assert_no_files_found(error: &TestAppError, _environment: &TestEnvironment) {
    assert_eq!(
      error.to_string(),
      concat!(
        "No files found to format with the specified plugins at /. ",
        "You may want to try using `dprint output-file-paths` to see which files it's finding or run with `--allow-no-files`."
      )
    );
    error.assert_exit_code(14);
  }

  #[cfg(target_os = "windows")]
  #[test]
  fn should_format_absolute_paths_on_windows() {
    let file_path = "D:\\test\\other\\asdf\\file1.txt"; // needs to be in the base directory
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_local_config("D:\\test\\other\\dprint.json", |c| {
        c.add_includes("asdf/**/*.txt").add_remote_wasm_plugin();
      })
      .write_file(file_path, "text1")
      .set_cwd("D:\\test\\other\\")
      .initialize()
      .build();

    // formats because the file path is explicitly provided
    run_test_cli(vec!["fmt", "--", file_path], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path).unwrap(), "text1_formatted");
  }

  #[cfg(unix)]
  #[test]
  fn should_format_absolute_paths_on_unix() {
    let file_path = "/test/other/asdf/file1.txt"; // needs to be in the base directory
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_local_config("/test/other/dprint.json", |c| {
        c.add_includes("asdf/**/*.txt").add_remote_wasm_plugin();
      })
      .write_file(&file_path, "text1")
      .set_cwd("/test/other/")
      .initialize()
      .build();

    run_test_cli(vec!["fmt", "--", "/test/other/asdf/file1.txt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path).unwrap(), "text1_formatted");
  }

  #[test]
  fn should_format_files_with_specific_config_includes() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(file_path1, "text1")
      .write_file(file_path2, "text2")
      .with_default_config(|c| {
        c.add_includes("**/file2.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();

    run_test_cli(vec!["fmt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_formatted");
  }

  #[test]
  fn should_format_files_with_and_without_config_includes() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    for includes in [Some("**/*.txt"), None] {
      let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
        .write_file(file_path1, "text1")
        .write_file(file_path2, "text2")
        .with_default_config(|c| {
          c.add_remote_wasm_plugin();
          if let Some(includes) = includes {
            c.add_includes(includes);
          }
        })
        .initialize()
        .build();

      run_test_cli(vec!["fmt"], &environment).unwrap();

      assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
      assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");
      assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_formatted");
    }
  }

  #[cfg(target_os = "windows")]
  #[test]
  fn should_format_files_with_config_includes_when_using_back_slashes() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_includes("**\\\\*.txt") // escape for the json
          .add_remote_wasm_plugin();
      })
      .write_file(file_path1, "text1")
      .initialize()
      .build();

    run_test_cli(vec!["fmt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");
  }

  #[test]
  fn should_override_config_includes_with_cli_includes() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    for includes in [Some("**/*.txt"), None] {
      let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
        .write_file(&file_path1, "text1")
        .write_file(&file_path2, "text2")
        .with_default_config(|c| {
          c.add_remote_wasm_plugin();
          if let Some(includes) = includes {
            c.add_includes(includes);
          }
        })
        .build();

      run_test_cli(vec!["fmt", "/file1.txt"], &environment).unwrap();

      assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
      assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");
      assert_eq!(environment.read_file(&file_path2).unwrap(), "text2");
    }
  }

  #[test]
  fn should_combine_config_excludes_with_cli_excludes() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let file_path3 = "/file3.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(&file_path1, "text1")
      .write_file(&file_path2, "text2")
      .write_file(&file_path3, "text3")
      .with_default_config(|c| {
        c.add_excludes("/file1.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();

    run_test_cli(vec!["fmt", "--excludes", "/file2.txt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2");
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text3_formatted");
  }

  #[test]
  fn should_override_config_excludes_with_cli_excludes_override() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(&file_path1, "text1")
      .write_file(&file_path2, "text2")
      .with_default_config(|c| {
        c.add_excludes("/file1.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();

    run_test_cli(vec!["fmt", "--excludes-override", "/file2.txt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2");
  }

  #[test]
  fn should_support_clearing_config_excludes_with_cli_excludes_override_arg() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(&file_path1, "text1")
      .with_default_config(|c| {
        c.add_excludes("/file1.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();

    run_test_cli(vec!["fmt", "--excludes-override="], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");
  }

  #[test]
  fn should_not_format_explicitly_specified_file_when_excluded() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(&file_path1, "text1")
      .with_default_config(|c| {
        c.add_excludes("/file1.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();

    // this is done for tools like lint staged
    let error = run_test_cli(vec!["fmt", "file1.txt"], &environment).err().unwrap();
    assert_no_files_found(&error, &environment);
  }

  #[test]
  fn should_combine_config_excludes_with_cli_args() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/sub/file2.txt";
    let file_path3 = "/sub/file3.txt";
    let file_path4 = "/sub/file4.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(&file_path1, "text1")
      .write_file(&file_path2, "text2")
      .write_file(&file_path3, "text3")
      .write_file(&file_path4, "text4")
      .with_default_config(|c| {
        c.add_includes("/sub/**/*.txt").add_excludes("/sub/file4.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();
    run_test_cli(vec!["fmt", "--excludes", "/sub/file3.txt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_formatted");
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text3");
    assert_eq!(environment.read_file(&file_path4).unwrap(), "text4");
  }

  #[test]
  fn should_format_intersect_of_config_includes_and_cli_includes() {
    let file_path1 = "/sub/file1.txt";
    let file_path2 = "/sub/file2.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(&file_path1, "text1")
      .write_file(&file_path2, "text2")
      .with_default_config(|c| {
        c.add_includes("/sub/**/*.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();
    run_test_cli(vec!["fmt", "/sub/file2.txt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2_formatted");
  }

  #[test]
  fn should_override_config_includes_and_excludes_with_cli_overrides() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(&file_path1, "text1")
      .write_file(&file_path2, "text2")
      .with_default_config(|c| {
        c.add_includes("/file2.txt").add_excludes("/file1.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();
    run_test_cli(
      vec!["fmt", "--includes-override", "/file1.txt", "--excludes-override", "/file2.txt"],
      &environment,
    )
    .unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text2");
  }

  #[test]
  fn should_format_files_with_config_excludes() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let file_path3 = "/file3.txt";
    let sub_dir_file_path2 = "/sub-dir/file2.txt";
    let sub_dir_file_path3 = "/sub-dir/file3.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(file_path1, "text")
      .write_file(file_path2, "text")
      .write_file(file_path3, "text")
      .write_file(sub_dir_file_path2, "text")
      .write_file(sub_dir_file_path3, "text")
      .with_default_config(|c| {
        c.add_excludes("/file2.txt").add_excludes("file3.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();

    run_test_cli(vec!["fmt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text");
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text");
    assert_eq!(environment.read_file(&sub_dir_file_path2).unwrap(), "text_formatted");
    assert_eq!(environment.read_file(&sub_dir_file_path3).unwrap(), "text");
  }

  #[test]
  fn should_ignore_path_in_config_includes_if_not_exists() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(file_path1, "text1")
      .write_file(file_path2, "text2")
      .with_default_config(|c| {
        c.add_includes("/file2.txt").add_includes("/file3.txt").add_remote_wasm_plugin();
      })
      .initialize()
      .build();

    run_test_cli(vec!["fmt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
  }

  #[test]
  fn should_ignore_path_in_cli_includes_arg_even_if_not_exists_since_pattern() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .write_file(file_path1, "text1")
      .write_file(file_path2, "text2")
      .with_default_config(|c| {
        c.add_remote_wasm_plugin();
      })
      .initialize()
      .build();

    run_test_cli(vec!["fmt", "/file2.txt", "/file3.txt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
  }

  #[test]
  fn should_format_using_hidden_config_file_name() {
    let file_path = "/test/other/file.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_local_config("/.dprint.json", |c| {
        c.add_remote_wasm_plugin();
      })
      .write_file(file_path, "text")
      .set_cwd("/test/other/")
      .build();

    run_test_cli(vec!["fmt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(
      environment.take_stderr_messages(),
      vec!["Compiling https://plugins.dprint.dev/test-plugin.wasm"]
    );
    assert_eq!(environment.read_file(&file_path).unwrap(), "text_formatted");
  }

  #[test]
  fn should_format_using_config_in_ancestor_directory() {
    let config_file_names = vec!["dprint.json", "dprint.jsonc", ".dprint.json", ".dprint.jsonc"];
    for config_file_name in config_file_names {
      let file_path = "/test/other/file.txt";
      let environment = TestEnvironmentBuilder::new()
        .add_remote_wasm_plugin()
        .with_local_config(format!("/{}", config_file_name), |config_file| {
          config_file.add_remote_wasm_plugin().add_includes("**/*.txt");
        })
        .initialize()
        .write_file(&file_path, "text")
        .build();
      environment.set_cwd("/test/other/");
      run_test_cli(vec!["fmt"], &environment).unwrap();
      assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
      assert_eq!(environment.take_stderr_messages(), Vec::<String>::new());
      assert_eq!(environment.read_file(&file_path).unwrap(), "text_formatted");
    }
  }

  #[test]
  fn should_format_incrementally_when_specified_on_cli() {
    let file_path1 = "/subdir/file1.txt";
    let no_change_msg = "No change: /subdir/file1.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin();
      })
      .write_file(&file_path1, "text1")
      .initialize()
      .build();

    // this is now the default
    run_test_cli(vec!["fmt", "--incremental"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");

    environment.clear_logs();
    run_test_cli(vec!["fmt", "--incremental", "--log-level=debug"], &environment).unwrap();
    assert_eq!(environment.take_stderr_messages().iter().any(|msg| msg.contains(no_change_msg)), true);

    // update the file and ensure it's formatted
    environment.write_file(&file_path1, "asdf").unwrap();
    environment.clear_logs();
    run_test_cli(vec!["fmt", "--incremental"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "asdf_formatted");

    // update the global config and ensure it's formatted
    environment
      .write_file(
        "./dprint.json",
        r#"{
            "indentWidth": 2,
            "plugins": ["https://plugins.dprint.dev/test-plugin.wasm"]
        }"#,
      )
      .unwrap();
    environment.clear_logs();
    run_test_cli(vec!["fmt", "--incremental", "--log-level=debug"], &environment).unwrap();
    assert_eq!(environment.take_stderr_messages().iter().any(|msg| msg.contains(no_change_msg)), false);

    // update the plugin config and ensure it's formatted
    environment
      .write_file(
        "./dprint.json",
        r#"{
            "indentWidth": 2,
            "test-plugin": {
                "ending": "custom-formatted",
                "line_width": 80
            },
            "plugins": ["https://plugins.dprint.dev/test-plugin.wasm"]
        }"#,
      )
      .unwrap();
    environment.clear_logs();
    run_test_cli(vec!["fmt", "--incremental"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "asdf_formatted_custom-formatted");

    // Try this a few times. There was a bug where the config hashmap was being serialized causing
    // random order and the hash to be new each time.
    for _ in 1..4 {
      environment.clear_logs();
      run_test_cli(vec!["fmt", "--incremental", "--log-level=debug"], &environment).unwrap();
      assert_eq!(environment.take_stderr_messages().iter().any(|msg| msg.contains(no_change_msg)), true);
    }

    // change the cwd and ensure it's not formatted again
    environment.clear_logs();
    environment.set_cwd("/subdir");
    run_test_cli(vec!["fmt", "--incremental", "--log-level=debug"], &environment).unwrap();
    assert_eq!(
      environment
        .take_stderr_messages()
        .iter()
        .any(|msg| msg.contains("No change: /subdir/file1.txt")),
      true
    );
  }

  #[test]
  fn should_check_config_diagnostics_when_config_changes_with_incremental() {
    // regression test for https://github.com/dprint/dprint/issues/403
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin();
      })
      .write_file(&file_path1, "text1")
      .build();

    // first run with a valid config -- caches the file as formatted
    run_test_cli(vec!["fmt", "--incremental"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");

    // now introduce a config diagnostic (unknown property)
    environment
      .write_file(
        "./dprint.json",
        r#"{
          "test-plugin": { "non-existent": 25 },
          "plugins": ["https://plugins.dprint.dev/test-plugin.wasm"]
        }"#,
      )
      .unwrap();

    // the diagnostic should be reported even though the file is unchanged and cached
    let err = run_test_cli(vec!["fmt", "--incremental"], &environment).err().unwrap();
    assert_eq!(err.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![
        "[test-plugin]: Unknown property in configuration (non-existent)",
        "[test-plugin]: Error initializing from configuration file. Had 1 diagnostic(s)."
      ]
    );

    // running again with the same invalid config should report the diagnostic again
    // (the invalid config must never get cached as "formatted")
    let err = run_test_cli(vec!["fmt", "--incremental"], &environment).err().unwrap();
    assert_eq!(err.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![
        "[test-plugin]: Unknown property in configuration (non-existent)",
        "[test-plugin]: Error initializing from configuration file. Had 1 diagnostic(s)."
      ]
    );
  }

  #[test]
  fn should_format_incrementally_when_specified_via_config() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin().set_incremental(true);
      })
      .initialize()
      .write_file(&file_path1, "text1")
      .build();

    run_test_cli(vec!["fmt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");

    environment.clear_logs();
    run_test_cli(vec!["fmt", "--log-level=debug"], &environment).unwrap();
    assert_eq!(environment.take_stderr_messages().iter().any(|msg| msg.contains("No change: /file1.txt")), true);
  }

  #[test]
  fn should_format_incrementally_when_config_override_changes() {
    let file_path = "/package.txt";
    let no_change_msg = "No change: /package.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin().add_config_section(
          "test-plugin",
          r#"{
            "ending": "base",
            "overrides": {
              "files": "**/package.txt",
              "ending": "package"
            }
          }"#,
        );
      })
      .write_file(file_path, "text")
      .build();

    run_test_cli(vec!["fmt", "--incremental"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(file_path).unwrap(), "text_package");

    environment.clear_logs();
    run_test_cli(vec!["fmt", "--incremental", "--log-level=debug"], &environment).unwrap();
    assert_eq!(environment.take_stderr_messages().iter().any(|msg| msg.contains(no_change_msg)), true);

    environment
      .write_file(
        "./dprint.json",
        r#"{
          "test-plugin": {
            "ending": "base",
            "overrides": {
              "files": "**/package.txt",
              "ending": "updated"
            }
          },
          "plugins": ["https://plugins.dprint.dev/test-plugin.wasm"]
        }"#,
      )
      .unwrap();
    environment.clear_logs();
    run_test_cli(vec!["fmt", "--incremental", "--log-level=debug"], &environment).unwrap();
    assert_eq!(environment.take_stderr_messages().iter().any(|msg| msg.contains(no_change_msg)), false);
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(file_path).unwrap(), "text_package_updated");
  }

  #[test]
  fn incremental_should_error_for_unstable_format() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path1, "unstable_fmt_true")
      .build();

    let result = run_test_cli(vec!["fmt", "--incremental"], &environment).err().unwrap();

    assert_eq!(result.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![
        concat!(
          "Error formatting /file1.txt. Message: Formatting not stable. Bailed after 5 tries. ",
          "This indicates a bug in the plugin where it formats the file differently each time."
        )
        .to_string()
      ]
    );
  }

  #[test]
  fn incremental_should_error_for_unstable_format_that_errors() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path1, "unstable_fmt_then_error")
      .build();

    let result = run_test_cli(vec!["fmt", "--incremental"], &environment).err().unwrap();

    assert_eq!(result.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![
        concat!(
          "Error formatting /file1.txt. Message: Formatting succeeded initially, but failed when ensuring a ",
          "stable format. This is most likely a bug in the plugin where the text it produces is not ",
          "syntatically correct. Please report this as a bug to the plugin that formatted this file.\n\n",
          "Did error."
        )
        .to_string()
      ]
    );
  }

  #[test]
  fn should_format_incrementally_with_check() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin();
      })
      .write_file(&file_path1, "text1_formatted")
      .initialize()
      .build();

    run_test_cli(vec!["check", "--incremental"], &environment).unwrap();
    run_test_cli(vec!["check", "--incremental"], &environment).unwrap();

    environment.write_file(file_path1, "text1").unwrap();
    let err = run_test_cli(vec!["check", "--incremental"], &environment).unwrap_err();
    err.assert_exit_code(20);

    environment.write_file(file_path1, "text1_formatted").unwrap();
    run_test_cli(vec!["check", "--incremental"], &environment).unwrap();
    run_test_cli(vec!["check", "--incremental"], &environment).unwrap();
    environment.clear_logs();
  }

  #[test]
  fn should_format_without_incremental_when_specified() {
    let file_path1 = "/subdir/file1.txt";
    let no_change_msg = "No change:";
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin();
      })
      .write_file(&file_path1, "text1")
      .initialize()
      .build();

    run_test_cli(vec!["fmt", "--incremental=false"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text1_formatted");

    environment.clear_logs();
    run_test_cli(vec!["fmt", "--incremental=false", "--log-level=debug"], &environment).unwrap();
    assert!(!environment.take_stderr_messages().iter().any(|msg| msg.contains(no_change_msg)));
  }

  #[test]
  fn allow_skipping_stable_format() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path1, "unstable_fmt_true")
      .build();

    run_test_cli(vec!["fmt", "--skip-stable-format", "*.txt"], &environment).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "unstable_fmt_false_formatted");
  }

  #[test]
  fn should_not_output_when_no_files_need_formatting() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/file.txt", "text_formatted")
      .build();
    run_test_cli(vec!["fmt", "/file.txt"], &environment).unwrap();
  }

  #[test]
  fn should_format_with_diff() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/file.txt", "const t=4;")
      .build();
    run_test_cli(vec!["fmt", "--diff", "/file.txt"], &environment).unwrap();
    assert_eq!(
      environment.take_stdout_messages(),
      vec![
        format!(
          "{}\n{}\n--",
          format!("{} /file.txt:", colors::red_bold("from").to_string()),
          get_difference("const t=4;", "const t=4;_formatted"),
        ),
        get_singular_formatted_text()
      ]
    );
  }

  #[test]
  fn should_not_output_when_no_files_need_formatting_for_check() {
    let file_path = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path, "text_formatted")
      .build();
    run_test_cli(vec!["check", "/file.txt"], &environment).unwrap();
  }

  #[test]
  fn should_output_when_a_file_need_formatting_for_check() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/file.txt", "const t=4;")
      .build();
    let err = run_test_cli(vec!["check", "/file.txt"], &environment).unwrap_err();
    err.assert_exit_code(20);
    assert_eq!(err.to_string(), get_singular_check_text());
    assert_eq!(
      environment.take_stdout_messages(),
      vec![format!(
        "{}\n{}\n--",
        format!("{} /file.txt:", colors::red_bold("from").to_string()),
        get_difference("const t=4;", "const t=4;_formatted"),
      ),]
    );
  }

  #[test]
  fn should_output_when_files_need_formatting_for_check() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/file1.txt", "const t=4;")
      .write_file("/file2.txt", "const t=5;")
      .build();

    let err = run_test_cli(vec!["check", "/file1.txt", "/file2.txt"], &environment).unwrap_err();
    err.assert_exit_code(20);
    assert_eq!(err.to_string(), get_plural_check_text(2));
    let mut logged_messages = environment.take_stdout_messages();
    logged_messages.sort(); // the order is not deterministic
    assert_eq!(
      logged_messages,
      vec![
        format!(
          "{}\n{}\n--",
          format!("{} /file1.txt:", colors::red_bold("from").to_string()),
          get_difference("const t=4;", "const t=4;_formatted"),
        ),
        format!(
          "{}\n{}\n--",
          format!("{} /file2.txt:", colors::red_bold("from").to_string()),
          get_difference("const t=5;", "const t=5;_formatted"),
        ),
      ]
    );
  }

  #[test]
  fn should_output_list_different_when_files_need_formatting_for_check() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/file1.txt", "const t=4;")
      .write_file("/file2.txt", "const t=5;")
      .build();

    let err = run_test_cli(vec!["check", "--list-different", "/file1.txt", "/file2.txt"], &environment).unwrap_err();
    err.assert_exit_code(20);
    assert_eq!(err.to_string(), ""); // no output because we outputted the files
    let mut logged_messages = environment.take_stdout_messages();
    logged_messages.sort(); // the order is not deterministic
    assert_eq!(logged_messages, vec!["/file1.txt", "/file2.txt",]);
  }

  #[test]
  fn should_exit_on_first_unformatted_file_with_fail_fast() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/file1.txt", "const t=4;")
      .write_file("/file2.txt", "const t=5;")
      .write_file("/file3.txt", "const t=6;")
      .build();
    environment.set_max_threads(1); // ensure files are checked sequentially

    let err = run_test_cli(vec!["check", "--fail-fast", "/file1.txt", "/file2.txt", "/file3.txt"], &environment).unwrap_err();
    err.assert_exit_code(20);
    // With fail-fast, only one file should be output (the first unformatted one found)
    let logged_messages = environment.take_stdout_messages();
    assert_eq!(logged_messages.len(), 1);
    // Verify the message contains the expected format
    assert!(logged_messages[0].contains("from"));
    assert!(logged_messages[0].contains("/file"));
  }

  #[test]
  fn should_exit_on_first_unformatted_file_with_fail_fast_and_list_different() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file("/file1.txt", "const t=4;")
      .write_file("/file2.txt", "const t=5;")
      .write_file("/file3.txt", "const t=6;")
      .build();
    environment.set_max_threads(1); // ensure files are checked sequentially

    let err = run_test_cli(
      vec!["check", "--fail-fast", "--list-different", "/file1.txt", "/file2.txt", "/file3.txt"],
      &environment,
    )
    .unwrap_err();
    err.assert_exit_code(20);
    // With fail-fast and list-different, only one file path should be output
    let logged_messages = environment.take_stdout_messages();
    assert_eq!(logged_messages.len(), 1);
    assert!(logged_messages[0].contains("/file"));
  }

  #[test]
  fn should_fail_on_change_when_files_formatted() {
    let file_path = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path, "const t=4;")
      .build();
    let err = run_test_cli(vec!["fmt", "--fail-on-change", "/file.txt"], &environment).unwrap_err();
    err.assert_exit_code(20);
    assert_eq!(err.to_string(), get_singular_check_text());
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    // file should still be formatted
    assert_eq!(environment.read_file(&file_path).unwrap(), "const t=4;_formatted");
  }

  #[test]
  fn should_not_fail_on_change_when_no_files_formatted() {
    let file_path = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path, "text_formatted")
      .build();
    run_test_cli(vec!["fmt", "--fail-on-change", "/file.txt"], &environment).unwrap();
    assert!(environment.take_stdout_messages().is_empty());
  }

  #[test]
  fn should_handle_bom() {
    let file_path = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .write_file(&file_path, "\u{FEFF}text")
      .build();
    run_test_cli(vec!["fmt", "/file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path).unwrap(), "\u{FEFF}text_formatted");
  }

  #[test]
  fn should_format_for_stdin_fmt_with_file_name() {
    // it should not output anything when downloading plugins
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_includes("/test/**.txt").add_remote_wasm_plugin();
      })
      .build();

    let test_std_in = TestStdInReader::from("text");
    run_test_cli_with_stdin(vec!["fmt", "--stdin", "file.txt"], &environment, test_std_in).unwrap();
    // should format even though it wasn't matched because an absolute path wasn't provided
    assert_eq!(environment.take_stdout_messages(), vec!["text_formatted"]);
    assert_eq!(
      environment.take_stderr_messages(),
      vec!["Compiling https://plugins.dprint.dev/test-plugin.wasm"]
    );
  }

  #[test]
  fn should_format_stdin_with_config_overrides() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin().add_config_section(
          "test-plugin",
          r#"{
            "ending": "base",
            "overrides": {
              "files": "**/package.txt",
              "ending": "package"
            }
          }"#,
        );
      })
      .build();
    let test_std_in = TestStdInReader::from("text");

    run_test_cli_with_stdin(vec!["fmt", "--stdin", "package.txt"], &environment, test_std_in).unwrap();

    assert_eq!(environment.take_stdout_messages(), vec!["text_package"]);
  }

  #[test]
  fn should_format_for_stdin_fmt_with_extension() {
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_includes("/test/**.txt").add_remote_wasm_plugin();
      })
      .build();

    let test_std_in = TestStdInReader::from("text");
    run_test_cli_with_stdin(vec!["fmt", "--stdin", "txt"], &environment, test_std_in).unwrap();
    // should format even though it wasn't matched because an absolute path wasn't provided
    assert_eq!(environment.take_stdout_messages(), vec!["text_formatted"]);
    assert_eq!(
      environment.take_stderr_messages(),
      vec!["Compiling https://plugins.dprint.dev/test-plugin.wasm"]
    );

    // now try with a leading period
    let test_std_in = TestStdInReader::from("text");
    run_test_cli_with_stdin(vec!["fmt", "--stdin", ".txt"], &environment, test_std_in).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec!["text_formatted"]);
  }

  #[test]
  fn should_stdin_fmt_calling_other_plugin() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin().build();
    let test_std_in = TestStdInReader::from("plugin: format this text");
    run_test_cli_with_stdin(vec!["fmt", "--stdin", "file.txt"], &environment, test_std_in).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec!["plugin: format this text_formatted_process_formatted"]);
  }

  #[test]
  fn should_handle_error_for_stdin_fmt() {
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin();
      })
      .build(); // don't initialize
    let test_std_in = TestStdInReader::from("should_error");
    let error_message = run_test_cli_with_stdin(vec!["fmt", "--stdin", "file.txt"], &environment, test_std_in)
      .err()
      .unwrap();
    assert_eq!(error_message.to_string(), "Did error.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec!["Compiling https://plugins.dprint.dev/test-plugin.wasm"]
    );
  }

  #[test]
  fn should_format_for_stdin_with_absolute_paths() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_includes("/src/**.*").add_remote_wasm_plugin();
      })
      .write_file("/file.txt", "")
      .write_file("/src/file.txt", "")
      .build();
    // not matching file
    let test_std_in = TestStdInReader::from("text");
    run_test_cli_with_stdin(vec!["fmt", "--stdin", "/file.txt"], &environment, test_std_in.clone()).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec!["text"]);

    // matching file
    run_test_cli_with_stdin(vec!["fmt", "--stdin", "/src/file.txt"], &environment, test_std_in.clone()).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec!["text_formatted"]);

    // override what's in the config when overriding
    run_test_cli_with_stdin(
      vec!["fmt", "--stdin", "/file.txt", "--includes-override", "**/*.txt"],
      &environment,
      test_std_in,
    )
    .unwrap();
    assert_eq!(environment.take_stdout_messages(), vec!["text_formatted"]);
  }

  #[test]
  fn should_not_format_stdin_resolving_config_file_from_provided_path_when_relative() {
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_includes("./**/*.txt").add_remote_wasm_plugin();
      })
      .with_local_config("./sub-dir/dprint.json", |c| {
        c.add_includes("./**/*.txt")
          .add_remote_wasm_plugin()
          .add_config_section("test-plugin", r#"{ "ending": "new_ending" }"#);
      })
      .build();
    let test_std_in = TestStdInReader::from("text");
    run_test_cli_with_stdin(vec!["fmt", "--stdin", "sub-dir/file.txt"], &environment, test_std_in).unwrap();
    // Should use cwd since the absolute path wasn't provided. In order to use the proper config file,
    // the absolute path must be provided instead of a relative one in order to properly pick up
    // inclusion/exclusion rules and the proper configuration file.
    assert_eq!(environment.take_stdout_messages(), vec!["text_formatted"]);
    assert_eq!(
      environment.take_stderr_messages(),
      vec!["Compiling https://plugins.dprint.dev/test-plugin.wasm"]
    );
  }

  #[test]
  fn should_format_stdin_resolving_config_file_from_provided_path_when_absolute() {
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_includes("./**/*.txt").add_remote_wasm_plugin();
      })
      .with_local_config("/sub-dir/dprint.json", |c| {
        c.add_includes("./**/*.txt")
          .add_remote_wasm_plugin()
          .add_config_section("test-plugin", r#"{ "ending": "new_ending" }"#);
      })
      .write_file("/sub-dir/file.txt", "test")
      .initialize()
      .build();
    let test_std_in = TestStdInReader::from("text");
    run_test_cli_with_stdin(vec!["fmt", "--stdin", "/sub-dir/file.txt"], &environment, test_std_in).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec!["text_new_ending"]);
  }

  #[test]
  fn should_error_if_process_plugin_has_no_checksum_in_config() {
    let environment = TestEnvironmentBuilder::new()
      .add_remote_process_plugin()
      .with_default_config(|c| {
        c.clear_plugins().add_plugin("https://plugins.dprint.dev/test-process.json");
      })
      .write_file("/test.txt_ps", "")
      .build();
    let err = run_test_cli(vec!["fmt", "*.*"], &environment).err().unwrap();
    err.assert_exit_code(12);
    let actual_plugin_file_checksum = TestProcessPluginFile::default().checksum();

    assert_eq!(
      err.to_string(),
      format!(
        concat!(
          "Error resolving plugin https://plugins.dprint.dev/test-process.json: The plugin must have a checksum specified ",
          "for security reasons since it is not a Wasm plugin. Check the plugin's release notes for what the checksum is or if ",
          "you trust the source, you may specify: https://plugins.dprint.dev/test-process.json@{}"
        ),
        actual_plugin_file_checksum,
      ),
    );
  }

  #[test]
  fn should_resolve_wasm_plugin_behind_redirect() {
    // Regression test: GitHub Releases redirects .wasm URLs to a CDN URL without
    // the .wasm extension. Plugin type detection must use the original URL.
    let original_url = "https://plugins.dprint.dev/test-plugin.wasm";
    let redirected_url = "https://release-assets.githubusercontent.com/github-production-release-asset/abc123";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin_at_url(redirected_url)
      .with_default_config(|c| {
        c.add_plugin(original_url);
      })
      .write_file("/test.txt", "text")
      .build();
    environment.add_remote_file_redirect(original_url, redirected_url);
    run_test_cli(vec!["fmt", "*.*"], &environment).unwrap();
    assert_eq!(environment.take_stderr_messages(), vec![format!("Compiling {}", redirected_url)]);
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
  }

  #[test]
  fn should_error_if_process_plugin_has_wrong_checksum_in_config() {
    let environment = TestEnvironmentBuilder::with_remote_process_plugin()
      .with_default_config(|c| {
        c.add_remote_process_plugin_with_checksum("asdf");
      })
      .write_file("/test.txt_ps", "")
      .build();
    let actual_plugin_file_checksum = TestProcessPluginFile::default().checksum();
    let err = run_test_cli(vec!["fmt", "*.*"], &environment).err().unwrap();
    err.assert_exit_code(12);

    assert_eq!(
      err.to_string(),
      format!(
        concat!(
          "Error resolving plugin https://plugins.dprint.dev/test-process.json: Invalid checksum specified ",
          "in configuration file. Check the plugin's release notes for what the expected checksum is.\n\n",
          "The checksum did not match the expected checksum.\n\n",
          "Actual: {}\n",
          "Expected: asdf"
        ),
        actual_plugin_file_checksum,
      )
    );
  }

  #[test]
  fn should_error_if_wasm_plugin_has_wrong_checksum_in_config() {
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin_with_checksum("asdf");
      })
      .write_file("/test.txt", "")
      .build();
    let actual_plugin_file_checksum = test_helpers::get_test_wasm_plugin_checksum();
    let err = run_test_cli(vec!["fmt", "*.*"], &environment).err().unwrap();
    err.assert_exit_code(12);

    assert_eq!(
      err.to_string(),
      format!(
        concat!(
          "Error resolving plugin https://plugins.dprint.dev/test-plugin.wasm: Invalid checksum specified ",
          "in configuration file. Check the plugin's release notes for what the expected checksum is.\n\n",
          "The checksum did not match the expected checksum.\n\n",
          "Actual: {}\n",
          "Expected: asdf"
        ),
        actual_plugin_file_checksum,
      )
    );
  }

  #[test]
  fn should_not_error_if_wasm_plugin_has_correct_checksum_in_config() {
    let actual_plugin_file_checksum = test_helpers::get_test_wasm_plugin_checksum();
    let environment = TestEnvironmentBuilder::with_remote_wasm_plugin()
      .with_default_config(|c| {
        c.add_remote_wasm_plugin_with_checksum(&actual_plugin_file_checksum);
      })
      .write_file("/test.txt", "text")
      .build();
    run_test_cli(vec!["fmt", "*.*"], &environment).unwrap();

    assert_eq!(environment.read_file("/test.txt").unwrap(), "text_formatted");
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(
      environment.take_stderr_messages(),
      vec!["Compiling https://plugins.dprint.dev/test-plugin.wasm"]
    );
  }

  #[test]
  fn should_error_if_process_plugin_has_wrong_checksum_in_file_for_zip() {
    let environment = TestEnvironmentBuilder::with_remote_process_plugin()
      .add_remote_process_plugin_at_url(
        "https://plugins.dprint.dev/test-process.json",
        &TestProcessPluginFileBuilder::default().zip_checksum("asdf").build(),
      )
      .with_default_config(|c| {
        c.add_remote_process_plugin();
      })
      .write_file("/test.txt_ps", "")
      .build();
    let actual_plugin_zip_file_checksum = &*PROCESS_PLUGIN_ZIP_CHECKSUM;
    let err = run_test_cli(vec!["fmt", "*.*"], &environment).err().unwrap();
    err.assert_exit_code(12);

    assert_eq!(
      err.to_string(),
      format!(
        concat!(
          "Error resolving plugin https://plugins.dprint.dev/test-process.json: Invalid checksum found ",
          "within process plugin's manifest file for 'https://github.com/dprint/test-process-plugin/releases/0.1.0/test-process-plugin.zip'. ",
          "This is likely a bug in the process plugin. Please report it.\n\n",
          "The checksum did not match the expected checksum.\n\n",
          "Actual: {}\n",
          "Expected: asdf"
        ),
        actual_plugin_zip_file_checksum,
      )
    );
  }

  #[test]
  fn should_format_many_files() {
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin().build();
    for i in 0..100 {
      let file_path = format!("/file{}.txt", i);
      environment.write_file(&file_path, &format!("text{}", i)).unwrap();
    }
    for i in 0..100 {
      let file_path = format!("/file{}.txt_ps", i);
      environment.write_file(&file_path, &format!("text{}", i)).unwrap();
    }

    run_test_cli(vec!["fmt", "/*.*"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(200)]);

    for i in 0..100 {
      let file_path = format!("/file{}.txt", i);
      assert_eq!(environment.read_file(&file_path).unwrap(), format!("text{}_formatted", i));
    }
    for i in 0..100 {
      let file_path = format!("/file{}.txt_ps", i);
      assert_eq!(environment.read_file(&file_path).unwrap(), format!("text{}_formatted_process", i));
    }
  }

  #[test]
  fn should_error_once_on_config_diagnostic_many_files() {
    // configuration diagnostic should only be shown by one thread
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin().build();
    environment
      .write_file(
        "./dprint.json",
        r#"{
            "test-plugin": { "non-existent": 25 },
            "plugins": ["https://plugins.dprint.dev/test-plugin.wasm"]
        }"#,
      )
      .unwrap();

    for i in 0..100 {
      let file_path = format!("/file{}.txt", i);
      environment.write_file(&file_path, &format!("text{}", i)).unwrap();
    }

    let error_message = run_test_cli(vec!["fmt", "**/*.txt"], &environment).err().unwrap();

    assert_eq!(error_message.to_string(), "Had 1 error formatting.");
    assert_eq!(
      environment.take_stderr_messages(),
      vec![
        "[test-plugin]: Unknown property in configuration (non-existent)",
        "[test-plugin]: Error initializing from configuration file. Had 1 diagnostic(s)."
      ]
    );
  }

  #[test]
  fn should_format_with_nested_config_calling_other_plugin() {
    let file_path1 = "/file.txt";
    let file_path2 = "/sub_dir/file.txt";
    let file_path3 = "/sub_dir/ignored/file.txt";
    let file_path4 = "/sub_dir/more/file.txt";
    let file_path5 = "/sub_dir/more/ignored.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/sub_dir/dprint.json", |config| {
        config
          .add_remote_wasm_plugin()
          .add_remote_process_plugin()
          .add_excludes("./ignored")
          .add_config_section("test-plugin", r#"{ "ending": "custom-formatted1" }"#)
          .add_config_section("testProcessPlugin", r#"{ "ending": "custom-formatted2" }"#);
      })
      .with_local_config("/sub_dir/more/dprint.json", |config| {
        config.add_remote_wasm_plugin();
      })
      .write_file(&file_path1, "plugin: plugin: format this text")
      .write_file(&file_path2, "plugin: plugin: format this other text")
      .write_file(&file_path3, "ignored")
      .write_file(&file_path4, "text")
      .write_file(&file_path5, "ignored")
      .build();
    run_test_cli(vec!["fmt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(3)]);
    assert_eq!(
      environment.read_file(&file_path1).unwrap(),
      "plugin: plugin: format this text_formatted_formatted_process_formatted"
    );
    assert_eq!(
      environment.read_file(&file_path2).unwrap(),
      "plugin: plugin: format this other text_custom-formatted1_custom-formatted2_custom-formatted1"
    );
    assert_eq!(environment.read_file(&file_path3).unwrap(), "ignored");
    assert_eq!(environment.read_file(&file_path4).unwrap(), "text_formatted");
  }

  #[test]
  fn should_inherit_ancestor_config_in_nested_config() {
    let file_path1 = "/file.txt";
    let file_path2 = "/sub_dir/file.txt";
    let file_path3 = "/sub_dir/nested/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|config| {
        config.add_remote_wasm_plugin().add_config_section("test-plugin", r#"{ "ending": "root" }"#);
      })
      // inherits the ancestor's plugin, but overrides its configuration
      .with_local_config("/sub_dir/dprint.json", |config| {
        config.set_inherit(true).add_config_section("test-plugin", r#"{ "ending": "sub" }"#);
      })
      // inherits without overriding anything, so it uses the ancestor's configuration
      .with_local_config("/sub_dir/nested/dprint.json", |config| {
        config.set_inherit(true);
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text")
      .write_file(&file_path3, "text")
      .build();
    run_test_cli(vec!["fmt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(3)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_root");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text_sub");
    // the nested config inherits the merged ancestor config (sub_dir, then root)
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text_sub");
  }

  #[test]
  fn should_inherit_ancestor_excludes_in_nested_config() {
    let file_path1 = "/file.txt";
    let file_path2 = "/sub_dir/file.txt";
    let file_path3 = "/sub_dir/skip/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|config| {
        config.add_remote_wasm_plugin().add_excludes("**/skip");
      })
      .with_local_config("/sub_dir/dprint.json", |config| {
        config.set_inherit(true);
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text")
      .write_file(&file_path3, "text")
      .build();
    run_test_cli(vec!["fmt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text_formatted");
    // the ancestor's "**/skip" exclude is inherited and still matches in the nested scope
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text");
  }

  #[test]
  fn should_not_inherit_ancestor_plugins_without_inherit_true() {
    // inheriting is opt-in, so a nested config without `"inherit": true`
    // and without any plugins of its own should error
    let file_path1 = "/file.txt";
    let file_path2 = "/sub_dir/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_default_config(|config| {
        config.add_remote_wasm_plugin();
      })
      .with_local_config("/sub_dir/dprint.json", |config| {
        config.add_config_section("test-plugin", r#"{ "ending": "sub" }"#);
      })
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text")
      .build();
    let err = run_test_cli(vec!["fmt"], &environment).err().unwrap();
    assert_eq!(
      err.to_string(),
      "No formatting plugins found. Ensure at least one is specified in the 'plugins' array of the configuration file."
    );
    err.assert_exit_code(13);
  }

  #[test]
  fn should_combine_nested_config_plugins_with_inherited_plugins() {
    // a nested config that inherits gets the ancestor's plugins in addition
    // to any plugins it specifies itself (issue #711, project-c)
    let file_path1 = "/file.txt";
    let file_path2 = "/sub_dir/file.txt";
    let file_path3 = "/sub_dir/file.txt_ps";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .add_remote_process_plugin()
      .with_default_config(|config| {
        config.add_remote_wasm_plugin();
      })
      // additionally pulls in the process plugin while inheriting the wasm plugin
      .with_local_config("/sub_dir/dprint.json", |config| {
        config.set_inherit(true).add_remote_process_plugin();
      })
      .initialize()
      .write_file(&file_path1, "text")
      .write_file(&file_path2, "text")
      .write_file(&file_path3, "text")
      .build();
    run_test_cli(vec!["fmt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(3)]);
    // the process plugin is only used in the nested scope, so it's extracted lazily there
    assert_eq!(environment.take_stderr_messages(), vec!["Extracting zip for test-process-plugin"]);
    // only the wasm plugin in the root scope
    assert_eq!(environment.read_file(&file_path1).unwrap(), "text_formatted");
    // the nested scope inherits the wasm plugin
    assert_eq!(environment.read_file(&file_path2).unwrap(), "text_formatted");
    // ...and additionally has the process plugin it specified (handles .txt_ps)
    assert_eq!(environment.read_file(&file_path3).unwrap(), "text_formatted_process");
  }

  #[test]
  fn should_not_error_nested_config_no_matching_files_in_scope_cli_args() {
    let file_path1 = "/sub_dir/file.txt";
    let file_path2 = "/sub_dir/sub_dir/file.txt";
    let file_path3 = "/sub_dir/more/ignored.txt_ps";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/sub_dir/dprint.json", |config| {
        config.add_remote_wasm_plugin();
      })
      .with_local_config("/sub_dir/more/dprint.json", |config| {
        config.add_remote_process_plugin();
      })
      .write_file(&file_path1, "here1")
      .write_file(&file_path2, "here2")
      .write_file(&file_path3, "here3")
      .build();
    // previously this was erroring in the sub directory
    run_test_cli(vec!["fmt", "**/*.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "here1_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "here2_formatted");
    assert_eq!(environment.read_file(&file_path3).unwrap(), "here3");

    // now try with a pattern that doesn't match any file in any scope and it should error
    let err = run_test_cli(vec!["fmt", "**/*.no_matching"], &environment).unwrap_err();
    assert_no_files_found(&err, &environment);
  }

  #[test]
  fn should_error_no_files_sub_dir_config() {
    let file_path1 = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
      .with_local_config("/sub_dir/dprint.json", |config| {
        config.add_remote_wasm_plugin().add_remote_process_plugin();
      })
      .write_file(&file_path1, "format this text")
      .build();
    let err = run_test_cli(vec!["fmt"], &environment).err().unwrap();
    assert_eq!(
      err.to_string(),
      "No files found to format with the specified plugins at /sub_dir. You may want to try using `dprint output-file-paths` to see which files it's finding or run with `--allow-no-files`."
    );
    err.assert_exit_code(14);
  }

  #[test]
  fn should_error_format_to_empty() {
    // this is to prevent a plugin accidentally formatting a file to an empty file
    let file_path1 = "/file.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      .with_local_config("/dprint.json", |config| {
        config.add_remote_wasm_plugin();
      })
      .write_file(&file_path1, &format!("format_to_empty {}", "a".repeat(400)))
      .build();
    let err = run_test_cli(vec!["fmt"], &environment).err().unwrap();
    assert_eq!(err.to_string(), "Had 1 error formatting.");
    err.assert_exit_code(1);
    assert_eq!(
      environment.take_stderr_messages(),
      vec![
        concat!(
          "Error formatting /file.txt. Message: The original file text was greater than 300 characters, ",
          "but the formatted text was empty. Most likely this is a bug in the plugin and the dprint CLI has ",
          "prevented the plugin from formatting the file to an empty file. Please report this scenario."
        )
        .to_string()
      ]
    );
  }

  #[test]
  fn wasm_log_stderr() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/file2.txt";
    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
      // these will both go to stderr actually
      .write_file(&file_path1, "stdout: hi stdout")
      .write_file(&file_path2, "stderr: hi stderr")
      .build();
    run_test_cli(vec!["fmt", "*.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "stdout: hi stdout_formatted");
    assert_eq!(environment.read_file(&file_path2).unwrap(), "stderr: hi stderr_formatted");
    assert_eq!(
      {
        let mut messages = environment.take_stderr_messages();
        messages.sort();
        messages
      },
      // everything goes to stderr
      vec![" hi stderr", " hi stderr_formatted", " hi stdout", " hi stdout_formatted"]
    );
  }

  #[test]
  fn should_disable_config_discovery() {
    let file_path1 = "/file1.txt";
    {
      let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
        .write_file(&file_path1, "hello")
        .build();
      environment.set_env_var("DPRINT_CONFIG_DISCOVERY", Some("false"));
      {
        let err = run_test_cli(vec!["fmt"], &environment).unwrap_err();
        err.assert_exit_code(11);
        assert_eq!(
          err.to_string(),
          concat!("Config discovery was disabled and no plugins (--plugins <url/path>) and/or config (--config <path>) was specified.",)
        );
      }
      // override env
      {
        run_test_cli(vec!["fmt", "--config-discovery=default"], &environment).unwrap();
        assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
      }
    }
    // specified config
    {
      let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
        .write_file(&file_path1, "hello")
        .build();
      run_test_cli(vec!["fmt", "--config-discovery=false", "--config", "dprint.json"], &environment).unwrap();
      assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    }
    // specified plugins
    {
      let environment = TestEnvironmentBuilder::new()
        .add_remote_wasm_plugin()
        .with_default_config(|config_file| {
          config_file
            .add_remote_wasm_plugin()
            .add_config_section("test-plugin", r#"{ "ending": "custom-formatted1" }"#);
        })
        .initialize()
        .write_file(&file_path1, "hello")
        .build();
      run_test_cli(
        vec!["fmt", "--config-discovery=false", "--plugins", "https://plugins.dprint.dev/test-plugin.wasm"],
        &environment,
      )
      .unwrap();
      assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
      // should ignore the test-plugin config
      assert_eq!(environment.read_file(&file_path1).unwrap(), "hello_formatted");
    }
  }

  #[test]
  fn should_disable_discovering_descendants() {
    let file_path1 = "/file1.txt";
    let file_path2 = "/sub/file1.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .with_default_config(|config_file| {
        config_file.add_remote_wasm_plugin();
      })
      .with_local_config("sub/dprint.json", |config_file| {
        config_file
          .add_remote_wasm_plugin()
          .add_config_section("test-plugin", r#"{ "ending": "custom-formatted1" }"#);
      })
      .initialize()
      .write_file(&file_path1, "hello")
      .write_file(&file_path2, "hello2")
      .build();
    environment.set_env_var("DPRINT_CONFIG_DISCOVERY", Some("ignore-descendants"));
    run_test_cli(vec!["fmt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "hello_formatted");
    // should ignore the config in this sub folder
    assert_eq!(environment.read_file(&file_path2).unwrap(), "hello2_formatted");

    // now try via cli flag
    environment.set_env_var("DPRINT_CONFIG_DISCOVERY", None);
    run_test_cli(vec!["fmt", "--config-discovery=ignore-descendants"], &environment).unwrap();

    environment.write_file(&file_path2, "hello2").unwrap();
    run_test_cli(vec!["fmt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path2).unwrap(), "hello2_custom-formatted1");
  }

  #[test]
  fn should_format_with_global_config_discovery() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .with_local_config("/config/dprint/dprint.json", |config_file| {
        config_file.add_remote_wasm_plugin();
      })
      .initialize()
      .write_file(&file_path1, "hello")
      .build();
    run_test_cli(vec!["fmt", "--config-discovery=global"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "hello_formatted");
  }

  #[test]
  fn should_error_when_global_config_not_found() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .initialize()
      .write_file(&file_path1, "hello")
      .build();
    let err = run_test_cli(vec!["fmt", "--config-discovery=global"], &environment).unwrap_err();
    err.assert_exit_code(11);
    assert_eq!(
      err.to_string(),
      "Could not find global dprint.json file. Create one with `dprint init --global`"
    );
  }

  #[test]
  fn should_prompt_when_fmt_no_args_with_global_config_and_user_accepts() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .with_local_config("/config/dprint/dprint.json", |config_file| {
        config_file.add_remote_wasm_plugin().add_includes("**/*.txt");
      })
      .initialize()
      .write_file(&file_path1, "hello")
      .build();
    environment.set_confirm_results(vec![Ok(Some(true))]);
    run_test_cli(vec!["fmt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "hello_formatted");
    let stderr_messages = environment.take_stderr_messages();
    let prompt_message = stderr_messages.iter().find(|msg| msg.contains("You're not in a dprint project"));
    assert!(prompt_message.is_some(), "Expected prompt message not found in stderr");
    let prompt = prompt_message.unwrap();
    assert!(prompt.contains("Format '/' anyway?"));
    assert!(prompt.contains(" Y"));
  }

  #[test]
  fn should_prompt_when_fmt_no_args_with_global_config_and_user_rejects() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .with_local_config("/config/dprint/dprint.json", |config_file| {
        config_file.add_remote_wasm_plugin().add_includes("**/*.txt");
      })
      .initialize()
      .write_file(&file_path1, "hello")
      .build();
    environment.set_confirm_results(vec![Ok(Some(false))]);
    let err = run_test_cli(vec!["fmt"], &environment).unwrap_err();
    err.assert_exit_code(11);
    assert_eq!(err.to_string(), "Confirmation cancelled.");
    assert_eq!(environment.read_file(&file_path1).unwrap(), "hello");
    let stderr_messages = environment.take_stderr_messages();
    let prompt_message = stderr_messages.iter().find(|msg| msg.contains("You're not in a dprint project"));
    assert!(prompt_message.is_some(), "Expected prompt message not found in stderr");
    let prompt = prompt_message.unwrap();
    assert!(prompt.contains("Format '/' anyway?"));
    assert!(prompt.contains(" N"));
  }

  #[test]
  fn should_not_prompt_when_fmt_with_dot_directory_arg_and_global_config() {
    let file_path1 = "/dir/file1.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .with_local_config("/config/dprint/dprint.json", |config_file| {
        config_file.add_remote_wasm_plugin();
      })
      .initialize()
      .write_file(&file_path1, "hello")
      .build();
    run_test_cli(vec!["fmt", "."], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "hello_formatted");
  }

  #[test]
  fn should_not_prompt_when_fmt_with_directory_arg_and_global_config() {
    let file_path1 = "/dir/file1.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .with_local_config("/config/dprint/dprint.json", |config_file| {
        config_file.add_remote_wasm_plugin();
      })
      .initialize()
      .write_file(&file_path1, "hello")
      .build();
    run_test_cli(vec!["fmt", "**"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "hello_formatted");
  }

  #[test]
  fn should_error_when_fmt_no_args_with_global_config_in_non_interactive_terminal() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .with_local_config("/config/dprint/dprint.json", |config_file| {
        config_file.add_remote_wasm_plugin();
      })
      .initialize()
      .write_file(&file_path1, "hello")
      .build();
    environment.set_terminal_interactive(false);
    let err = run_test_cli(vec!["fmt"], &environment).unwrap_err();
    err.assert_exit_code(11);
    assert_eq!(
      err.to_string(),
      "Did not format directory without configuration file. Run `dprint fmt .` or `dprint fmt --config-discovery=global` to bypass this error."
    );
    assert_eq!(environment.read_file(&file_path1).unwrap(), "hello");
  }

  #[test]
  fn should_not_prompt_when_fmt_no_args_with_global_config_and_config_discovery_flag() {
    let file_path1 = "/file1.txt";
    let environment = TestEnvironmentBuilder::new()
      .add_remote_wasm_plugin()
      .with_local_config("/config/dprint/dprint.json", |config_file| {
        config_file.add_remote_wasm_plugin();
      })
      .initialize()
      .write_file(&file_path1, "hello")
      .build();
    run_test_cli(vec!["fmt", "--config-discovery=global"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file(&file_path1).unwrap(), "hello_formatted");
  }

  // ---- npm: plugin specifiers ----

  #[test]
  fn should_format_with_versioned_npm_wasm_plugin() {
    use crate::test_helpers::WASM_PLUGIN_BYTES;
    use crate::test_helpers::create_test_npm_tarball;

    let environment = TestEnvironmentBuilder::new()
      .with_local_config("/dprint.json", |c| {
        c.add_plugin("npm:test-plugin@1.0.0");
      })
      .write_file("/file.txt", "text")
      .build();

    // mock the npm registry: packument + tarball
    let packument = serde_json::json!({
      "versions": {
        "1.0.0": { "dist": { "tarball": "https://registry.npmjs.org/test-plugin/-/test-plugin-1.0.0.tgz" } }
      }
    });
    environment.add_remote_file_bytes("https://registry.npmjs.org/test-plugin", packument.to_string().into_bytes());
    environment.add_remote_file_bytes(
      "https://registry.npmjs.org/test-plugin/-/test-plugin-1.0.0.tgz",
      create_test_npm_tarball(&[("package/plugin.wasm", WASM_PLUGIN_BYTES)]),
    );

    run_test_cli(vec!["fmt", "/file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file("/file.txt").unwrap(), "text_formatted");

    // tarball should be extracted under the registry-scoped cache dir
    let extract_dir = environment.get_cache_dir().join("npm").join("registry.npmjs.org").join("test-plugin@1.0.0");
    assert!(environment.path_exists(&extract_dir.join("plugin.wasm")));

    let _ = environment.take_stderr_messages(); // drain wasm-compile progress
  }

  #[test]
  fn should_format_with_unversioned_npm_wasm_plugin_from_node_modules() {
    use crate::test_helpers::WASM_PLUGIN_BYTES;

    let environment = TestEnvironmentBuilder::new()
      .with_local_config("/dprint.json", |c| {
        c.add_plugin("npm:test-plugin");
      })
      .write_file("/node_modules/test-plugin/plugin.wasm", WASM_PLUGIN_BYTES)
      .write_file("/file.txt", "text")
      .build();

    run_test_cli(vec!["fmt", "/file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file("/file.txt").unwrap(), "text_formatted");

    let _ = environment.take_stderr_messages(); // drain wasm-compile progress
  }

  #[test]
  fn should_resolve_unversioned_npm_plugin_from_config_dir_node_modules() {
    // dprint.json lives below the workspace root and the dependency is
    // installed beside it. The npm resolver must start at the config
    // directory itself, not at the config directory's parent.
    use crate::test_helpers::WASM_PLUGIN_BYTES;

    let environment = TestEnvironmentBuilder::new()
      .with_local_config("/repo/packages/web/dprint.json", |c| {
        c.add_plugin("npm:test-plugin");
      })
      .write_file("/repo/packages/web/node_modules/test-plugin/plugin.wasm", WASM_PLUGIN_BYTES)
      .write_file("/repo/packages/web/file.txt", "text")
      .set_cwd("/repo/packages/web")
      .build();

    run_test_cli(vec!["fmt", "/repo/packages/web/file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file("/repo/packages/web/file.txt").unwrap(), "text_formatted");

    let _ = environment.take_stderr_messages();
  }

  #[test]
  fn should_walk_past_child_node_modules_lacking_plugin_to_reach_workspace_root() {
    // monorepo layout: dprint.json lives in a child workspace whose own
    // node_modules doesn't have the plugin (only a different package). The
    // resolver must walk past it and pick up the plugin from the workspace
    // root's node_modules, matching how npm itself resolves dependencies.
    use crate::test_helpers::WASM_PLUGIN_BYTES;

    let environment = TestEnvironmentBuilder::new()
      .with_local_config("/repo/packages/web/dprint.json", |c| {
        c.add_plugin("npm:test-plugin");
      })
      // child node_modules exists but lacks the plugin — must not stop the walk here
      .write_file("/repo/packages/web/node_modules/other-package/index.js", "// noise")
      .write_file("/repo/node_modules/test-plugin/plugin.wasm", WASM_PLUGIN_BYTES)
      .write_file("/repo/packages/web/file.txt", "text")
      .set_cwd("/repo/packages/web")
      .build();

    run_test_cli(vec!["fmt", "/repo/packages/web/file.txt"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file("/repo/packages/web/file.txt").unwrap(), "text_formatted");

    let _ = environment.take_stderr_messages();
  }

  #[test]
  fn should_format_with_versioned_npm_process_plugin() {
    // a self-contained npm-published process plugin: the npm tarball ships
    // both `plugin.json` and `bin.zip`; the manifest references the zip with
    // a relative path that resolves against the extracted package dir.
    use crate::test_helpers::PROCESS_PLUGIN_ZIP_BYTES;
    use crate::test_helpers::create_test_npm_tarball;
    use crate::utils::get_sha256_checksum;

    let zip_bytes: &[u8] = &PROCESS_PLUGIN_ZIP_BYTES;
    let zip_checksum = get_sha256_checksum(zip_bytes);
    let plugin_json = format!(
      r#"{{
  "schemaVersion": 2,
  "name": "test-process-plugin",
  "version": "0.1.0",
  "linux-x86_64":    {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "linux-aarch64":   {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "darwin-x86_64":   {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "darwin-aarch64":  {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "windows-x86_64":  {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "windows-aarch64": {{ "reference": "./bin.zip", "checksum": "{0}" }}
}}"#,
      zip_checksum
    );
    let tarball = create_test_npm_tarball(&[("package/plugin.json", plugin_json.as_bytes()), ("package/bin.zip", zip_bytes)]);
    let tarball_checksum = get_sha256_checksum(&tarball);

    let environment = TestEnvironmentBuilder::new()
      .with_local_config("/dprint.json", |c| {
        c.add_plugin(&format!("npm:test-process@1.0.0/plugin.json@{}", tarball_checksum));
      })
      .write_file("/file.txt_ps", "text")
      .build();

    let packument = serde_json::json!({
      "versions": {
        "1.0.0": { "dist": { "tarball": "https://registry.npmjs.org/test-process/-/test-process-1.0.0.tgz" } }
      }
    });
    environment.add_remote_file_bytes("https://registry.npmjs.org/test-process", packument.to_string().into_bytes());
    environment.add_remote_file_bytes("https://registry.npmjs.org/test-process/-/test-process-1.0.0.tgz", tarball);

    run_test_cli(vec!["fmt", "/file.txt_ps"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file("/file.txt_ps").unwrap(), "text_formatted_process");

    let _ = environment.take_stderr_messages(); // drain process-plugin extract progress
  }

  #[test]
  fn should_format_with_versioned_npm_process_plugin_with_npm_per_platform_reference() {
    // a versioned (registry) top-level process plugin whose plugin.json
    // points each per-platform entry at another versioned npm package via
    // `npm:`. Exercises the per-platform tarball fetch flow from the
    // registry top-level path (not just the node_modules path).
    use crate::test_helpers::PROCESS_PLUGIN_BINARY_BYTES;
    use crate::test_helpers::create_test_npm_tarball;
    use crate::test_helpers::process_plugin_binary_filename;
    use crate::utils::get_sha256_checksum;

    let binary_bytes: &[u8] = &PROCESS_PLUGIN_BINARY_BYTES;
    let binary_filename = process_plugin_binary_filename();
    let per_platform_tarball = create_test_npm_tarball(&[(&format!("package/{}", binary_filename), binary_bytes)]);
    let per_platform_checksum = get_sha256_checksum(&per_platform_tarball);

    let plugin_json = format!(
      r#"{{
  "schemaVersion": 2,
  "name": "test-process-plugin",
  "version": "0.1.0",
  "linux-x86_64":    {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "linux-aarch64":   {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "darwin-x86_64":   {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "darwin-aarch64":  {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "windows-x86_64":  {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "windows-aarch64": {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }}
}}"#,
      per_platform_checksum, binary_filename,
    );
    let top_tarball = create_test_npm_tarball(&[("package/plugin.json", plugin_json.as_bytes())]);
    let top_checksum = get_sha256_checksum(&top_tarball);

    let environment = TestEnvironmentBuilder::new()
      .with_local_config("/dprint.json", |c| {
        c.add_plugin(&format!("npm:test-process@1.0.0/plugin.json@{}", top_checksum));
      })
      .write_file("/file.txt_ps", "text")
      .build();

    let top_packument = serde_json::json!({
      "versions": {
        "1.0.0": { "dist": { "tarball": "https://registry.npmjs.org/test-process/-/test-process-1.0.0.tgz" } }
      }
    });
    let per_platform_packument = serde_json::json!({
      "versions": {
        "0.1.0": { "dist": { "tarball": "https://registry.npmjs.org/test-process-bin/-/test-process-bin-0.1.0.tgz" } }
      }
    });
    environment.add_remote_file_bytes("https://registry.npmjs.org/test-process", top_packument.to_string().into_bytes());
    environment.add_remote_file_bytes("https://registry.npmjs.org/test-process/-/test-process-1.0.0.tgz", top_tarball);
    environment.add_remote_file_bytes("https://registry.npmjs.org/test-process-bin", per_platform_packument.to_string().into_bytes());
    environment.add_remote_file_bytes("https://registry.npmjs.org/test-process-bin/-/test-process-bin-0.1.0.tgz", per_platform_tarball);

    run_test_cli(vec!["fmt", "/file.txt_ps"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file("/file.txt_ps").unwrap(), "text_formatted_process");

    let _ = environment.take_stderr_messages();
  }

  #[test]
  fn should_reject_versioned_npm_process_plugin_with_https_reference() {
    // mirror of the test above, except the manifest references the platform
    // binary over the network — must be rejected before any http fetch.
    use crate::test_helpers::TestProcessPluginFile;
    use crate::test_helpers::create_test_npm_tarball;
    use crate::utils::get_sha256_checksum;

    let plugin_file = TestProcessPluginFile::default(); // uses an https:// reference
    let tarball = create_test_npm_tarball(&[("package/plugin.json", plugin_file.text().as_bytes())]);
    let tarball_checksum = get_sha256_checksum(&tarball);

    let environment = TestEnvironmentBuilder::new()
      .with_local_config("/dprint.json", |c| {
        c.add_plugin(&format!("npm:test-process@1.0.0/plugin.json@{}", tarball_checksum));
      })
      .write_file("/file.txt_ps", "text")
      .build();

    let packument = serde_json::json!({
      "versions": {
        "1.0.0": { "dist": { "tarball": "https://registry.npmjs.org/test-process/-/test-process-1.0.0.tgz" } }
      }
    });
    environment.add_remote_file_bytes("https://registry.npmjs.org/test-process", packument.to_string().into_bytes());
    environment.add_remote_file_bytes("https://registry.npmjs.org/test-process/-/test-process-1.0.0.tgz", tarball);

    let err = run_test_cli(vec!["fmt", "/file.txt_ps"], &environment).err().expect("expected an error");
    err.assert_exit_code(12);
    let msg = format!("{err:#}");
    assert!(msg.contains("Network references aren't allowed"), "got: {msg}");
    // file should not have been formatted
    assert_eq!(environment.read_file("/file.txt_ps").unwrap(), "text");

    let _ = environment.take_stderr_messages();
  }

  #[test]
  fn should_format_with_npm_process_plugin_using_relative_zip_reference() {
    // a self-contained npm-installed process plugin: a single package ships
    // both `plugin.json` and `bin.zip`, with the manifest referencing the zip
    // by relative path. Must resolve against the package directory in
    // node_modules (no separate per-arch npm package needed).
    use crate::test_helpers::PROCESS_PLUGIN_ZIP_BYTES;
    use crate::utils::get_sha256_checksum;

    let zip_bytes: &[u8] = &PROCESS_PLUGIN_ZIP_BYTES;
    let zip_checksum = get_sha256_checksum(zip_bytes);

    // every-platform reference is `./bin.zip` (resolved against
    // /node_modules/test-process/ where plugin.json lives)
    let plugin_json = format!(
      r#"{{
  "schemaVersion": 2,
  "name": "test-process-plugin",
  "version": "0.1.0",
  "linux-x86_64":    {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "linux-aarch64":   {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "darwin-x86_64":   {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "darwin-aarch64":  {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "windows-x86_64":  {{ "reference": "./bin.zip", "checksum": "{0}" }},
  "windows-aarch64": {{ "reference": "./bin.zip", "checksum": "{0}" }}
}}"#,
      zip_checksum
    );

    let environment = TestEnvironmentBuilder::new()
      .with_local_config("/dprint.json", |c| {
        c.add_plugin("npm:test-process/plugin.json");
      })
      .write_file("/node_modules/test-process/plugin.json", plugin_json.as_bytes())
      .write_file("/node_modules/test-process/bin.zip", zip_bytes)
      .write_file("/file.txt_ps", "text")
      .build();

    run_test_cli(vec!["fmt", "/file.txt_ps"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file("/file.txt_ps").unwrap(), "text_formatted_process");

    let _ = environment.take_stderr_messages(); // drain process-plugin extract progress
  }

  #[test]
  fn should_format_with_unversioned_npm_process_plugin_from_node_modules() {
    // unversioned top-level process plugin: plugin.json lives in node_modules,
    // and each per-platform entry references a versioned `npm:` package. The
    // per-platform package's tarball is fetched from the registry and its
    // SHA-256 verified against plugin.json's checksum (covering every file in
    // the tarball, not just the binary).
    use crate::test_helpers::PROCESS_PLUGIN_BINARY_BYTES;
    use crate::test_helpers::create_test_npm_tarball;
    use crate::test_helpers::process_plugin_binary_filename;
    use crate::utils::get_sha256_checksum;

    let binary_bytes: &[u8] = &PROCESS_PLUGIN_BINARY_BYTES;
    let binary_filename = process_plugin_binary_filename();

    // build the per-platform npm tarball with the binary inside it
    let tarball_inner_path = format!("package/{}", binary_filename);
    let tarball = create_test_npm_tarball(&[(&tarball_inner_path, binary_bytes)]);
    let tarball_checksum = get_sha256_checksum(&tarball);

    // craft a plugin.json whose every-platform reference is `npm:` and points
    // at the same dep package (so this test is platform-independent)
    let plugin_json = format!(
      r#"{{
  "schemaVersion": 2,
  "name": "test-process-plugin",
  "version": "0.1.0",
  "linux-x86_64":    {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "linux-aarch64":   {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "darwin-x86_64":   {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "darwin-aarch64":  {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "windows-x86_64":  {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }},
  "windows-aarch64": {{ "reference": "npm:test-process-bin@0.1.0/{1}", "checksum": "{0}" }}
}}"#,
      tarball_checksum, binary_filename,
    );

    let packument = serde_json::json!({
      "versions": {
        "0.1.0": { "dist": { "tarball": "https://registry.npmjs.org/test-process-bin/-/test-process-bin-0.1.0.tgz" } }
      }
    });

    let environment = TestEnvironmentBuilder::new()
      .with_local_config("/dprint.json", |c| {
        c.add_plugin("npm:test-process/plugin.json");
      })
      .write_file("/node_modules/test-process/plugin.json", plugin_json.as_bytes())
      .add_remote_file("https://registry.npmjs.org/test-process-bin", &packument.to_string())
      .add_remote_file_bytes("https://registry.npmjs.org/test-process-bin/-/test-process-bin-0.1.0.tgz", tarball)
      .write_file("/file.txt_ps", "text")
      .build();

    run_test_cli(vec!["fmt", "/file.txt_ps"], &environment).unwrap();
    assert_eq!(environment.take_stdout_messages(), vec![get_singular_formatted_text()]);
    assert_eq!(environment.read_file("/file.txt_ps").unwrap(), "text_formatted_process");

    let _ = environment.take_stderr_messages(); // drain process-plugin extract progress
  }
}
