blob: a4abbbfc90687701487acd783577dce527fbcc75 [file]
//! Per-user, per-workspace runtime settings for the rust-analyzer
//! integration. Lives in `<launcher_dir>/user_config.json` (inside
//! the gitignored launcher dir). `setup` writes it; `discover` and
//! `flycheck` read it. Malformed → defaults + warning, so an
//! unreadable file doesn't blow up the LSP.
use std::fs;
use anyhow::{Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
/// Filename inside `<launcher_dir>` that holds the per-user config.
pub const USER_CONFIG_FILENAME: &str = "user_config.json";
impl Default for UserConfig {
fn default() -> Self {
Self {
clippy: false,
// Whole-workspace mode blew RA memory to 46 GB on a
// 713-crate workspace; per-package keeps it cargo-scoped.
per_package_workspaces: true,
output_base: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct UserConfig {
/// Run clippy on save alongside rustc. See `bin/flycheck.rs`.
pub clippy: bool,
/// Focus discover on the saved file's package instead of the whole
/// workspace. See `Default` for why this defaults to `true`.
pub per_package_workspaces: bool,
/// Override for flycheck's `--output_base`. `None` derives the
/// server location from the sidecar's `output_base` with an
/// `_rra` suffix, so flycheck's server sits next to the primary
/// one under the same `output_user_root`. The `--output_base` CLI
/// flag wins for one-off overrides.
#[serde(skip_serializing_if = "Option::is_none")]
pub output_base: Option<Utf8PathBuf>,
}
/// Read `<launcher_dir>/user_config.json`. Missing or malformed →
/// defaults (with a warning for malformed).
pub fn load(launcher_dir: &Utf8Path) -> UserConfig {
let path = launcher_dir.join(USER_CONFIG_FILENAME);
let text = match fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return UserConfig::default(),
Err(e) => {
log::warn!("user_config: reading {path}: {e}; falling back to defaults");
return UserConfig::default();
}
};
match serde_json::from_str::<UserConfig>(&text) {
Ok(config) => config,
Err(e) => {
log::warn!("user_config: parsing {path}: {e}; falling back to defaults");
UserConfig::default()
}
}
}
/// Write `<launcher_dir>/user_config.json`, creating the launcher
/// dir if it's missing.
pub fn save(launcher_dir: &Utf8Path, config: &UserConfig) -> Result<()> {
fs::create_dir_all(launcher_dir)
.with_context(|| format!("creating launcher dir {launcher_dir}"))?;
let path = launcher_dir.join(USER_CONFIG_FILENAME);
let mut text = serde_json::to_string_pretty(config)
.with_context(|| format!("serializing user_config for {path}"))?;
text.push('\n');
fs::write(&path, text).with_context(|| format!("writing {path}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
// Edition 2018 doesn't put `TryFrom` in the prelude.
use std::convert::TryFrom;
use super::*;
use camino::Utf8PathBuf;
fn tmp_dir(tag: &str) -> Utf8PathBuf {
let dir = std::env::temp_dir().join(format!(
"rules_rust_ra_user_config_{}_{}",
tag,
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
Utf8PathBuf::try_from(dir).unwrap()
}
#[test]
fn missing_file_returns_defaults() {
let dir = tmp_dir("missing");
let config = load(&dir);
assert_eq!(config, UserConfig::default());
assert!(!config.clippy);
assert!(config.per_package_workspaces);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn roundtrip_preserves_all_fields() {
let dir = tmp_dir("roundtrip");
let original = UserConfig {
clippy: true,
per_package_workspaces: true,
output_base: Some(Utf8PathBuf::from("/tmp/custom_flycheck_base")),
};
save(&dir, &original).unwrap();
let loaded = load(&dir);
assert_eq!(loaded, original);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn absent_output_base_stays_none() {
// `skip_serializing_if` keeps the key out of the default file.
let dir = tmp_dir("absent_output_base");
save(&dir, &UserConfig::default()).unwrap();
let text = fs::read_to_string(dir.join(USER_CONFIG_FILENAME)).unwrap();
assert!(
!text.contains("output_base"),
"unset field should be omitted, got:\n{}",
text,
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn malformed_file_falls_back_to_defaults() {
let dir = tmp_dir("malformed");
fs::write(dir.join(USER_CONFIG_FILENAME), b"{not valid json").unwrap();
let config = load(&dir);
assert_eq!(config, UserConfig::default());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn partial_file_uses_defaults_for_missing_keys() {
// Older file that only has `clippy` set must not fail when a
// new key is added, and picks up the new default.
let dir = tmp_dir("partial");
fs::write(dir.join(USER_CONFIG_FILENAME), br#"{"clippy": true}"#).unwrap();
let config = load(&dir);
assert!(config.clippy);
assert!(config.per_package_workspaces);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn explicit_false_in_file_is_preserved() {
// `--no-per-package-workspaces` written before the default
// flipped must still deserialize as `false`.
let dir = tmp_dir("explicit_false");
fs::write(
dir.join(USER_CONFIG_FILENAME),
br#"{"per_package_workspaces": false}"#,
)
.unwrap();
let config = load(&dir);
assert!(!config.per_package_workspaces);
let _ = fs::remove_dir_all(&dir);
}
}