use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use face_core::{FaceError, OutputFormat};
use serde::Deserialize;
const DEFAULT_PER_PAGE: u32 = 20;
const DEFAULT_COLOR: &str = "auto";
const DEFAULT_ENUM_MAX_CARDINALITY: usize = 20;
const DEFAULT_ENUM_COVERAGE_THRESHOLD: f64 = 0.80;
const DEFAULT_PATH_LIKE_SLASH_RATIO: f64 = 0.50;
const DEFAULT_SCORE_CANDIDATES: &[&str] = &[
"score",
"rank",
"relevance",
"confidence",
"bm25",
"distance",
];
const DEFAULT_ITEMS_CANDIDATES: &[&str] =
&["items", "results", "data", "hits", "matches", "scopes"];
const DEFAULT_GROUP_PREFERENCE: &[&str] = &[
"data.path.text",
"path",
"file",
"module",
"kind",
"status",
"type",
];
const DEFAULT_BANDS: u8 = 5;
const DEFAULT_SAVE_THRESHOLD: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SettingSource {
BuiltIn,
Config,
Env,
Cli,
}
impl SettingSource {
pub(crate) fn label(self) -> &'static str {
match self {
SettingSource::BuiltIn => "default",
SettingSource::Config => "config",
SettingSource::Env => "env",
SettingSource::Cli => "explicit",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RuntimeSources {
pub(crate) default_format: SettingSource,
pub(crate) per_page: SettingSource,
pub(crate) color: SettingSource,
pub(crate) default_bands: SettingSource,
pub(crate) save_threshold: SettingSource,
}
impl Default for RuntimeSources {
fn default() -> Self {
Self {
default_format: SettingSource::BuiltIn,
per_page: SettingSource::BuiltIn,
color: SettingSource::BuiltIn,
default_bands: SettingSource::BuiltIn,
save_threshold: SettingSource::BuiltIn,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) struct RuntimeSettings {
pub(crate) output: OutputSettings,
pub(crate) detect: DetectSettings,
pub(crate) strategy: StrategySettings,
pub(crate) interactive: InteractiveSettings,
pub(crate) presets: BTreeMap<String, UserPreset>,
pub(crate) sources: RuntimeSources,
pub(crate) config_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct OutputSettings {
pub(crate) default_format: OutputFormat,
pub(crate) per_page: u32,
pub(crate) color: String,
}
impl Default for OutputSettings {
fn default() -> Self {
Self {
default_format: OutputFormat::Human,
per_page: DEFAULT_PER_PAGE,
color: DEFAULT_COLOR.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct DetectSettings {
pub(crate) enum_max_cardinality: usize,
pub(crate) enum_coverage_threshold: f64,
pub(crate) path_like_slash_ratio: f64,
pub(crate) score_candidates: Vec<String>,
pub(crate) items_candidates: Vec<String>,
pub(crate) group_preference: Vec<String>,
}
impl Default for DetectSettings {
fn default() -> Self {
Self {
enum_max_cardinality: DEFAULT_ENUM_MAX_CARDINALITY,
enum_coverage_threshold: DEFAULT_ENUM_COVERAGE_THRESHOLD,
path_like_slash_ratio: DEFAULT_PATH_LIKE_SLASH_RATIO,
score_candidates: strings(DEFAULT_SCORE_CANDIDATES),
items_candidates: strings(DEFAULT_ITEMS_CANDIDATES),
group_preference: strings(DEFAULT_GROUP_PREFERENCE),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StrategySettings {
pub(crate) prefer_score_when_present: bool,
pub(crate) default_bands: u8,
}
impl Default for StrategySettings {
fn default() -> Self {
Self {
prefer_score_when_present: true,
default_bands: DEFAULT_BANDS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InteractiveSettings {
pub(crate) save_threshold: usize,
}
impl Default for InteractiveSettings {
fn default() -> Self {
Self {
save_threshold: DEFAULT_SAVE_THRESHOLD,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct UserPreset {
pub(crate) score: Option<String>,
pub(crate) invert: Option<bool>,
pub(crate) scale: Option<f64>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct FileConfig {
output: FileOutput,
detect: FileDetect,
strategy: FileStrategy,
interactive: FileInteractive,
presets: BTreeMap<String, UserPreset>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct FileOutput {
default_format: Option<OutputFormat>,
per_page: Option<u32>,
color: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct FileDetect {
enum_max_cardinality: Option<usize>,
enum_coverage_threshold: Option<f64>,
path_like_slash_ratio: Option<f64>,
score_candidates: Option<Vec<String>>,
items_candidates: Option<Vec<String>>,
group_preference: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct FileStrategy {
prefer_score_when_present: Option<bool>,
default_bands: Option<u8>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct FileInteractive {
save_threshold: Option<usize>,
}
pub(crate) fn load() -> Result<RuntimeSettings, FaceError> {
let mut settings = RuntimeSettings::default();
if let Some(path) = locate_config_path()
&& path.is_file()
{
settings.config_path = Some(path.clone());
apply_file(&path, &mut settings)?;
}
apply_env(&mut settings)?;
Ok(settings)
}
fn apply_file(path: &Path, settings: &mut RuntimeSettings) -> Result<(), FaceError> {
let text = std::fs::read_to_string(path).map_err(|source| FaceError::Config {
path: path.to_path_buf(),
message: source.to_string(),
})?;
let config: FileConfig = toml::from_str(&text).map_err(|error| FaceError::Config {
path: path.to_path_buf(),
message: error.to_string(),
})?;
if let Some(value) = config.output.default_format {
settings.output.default_format = value;
settings.sources.default_format = SettingSource::Config;
}
if let Some(value) = config.output.per_page {
require_positive_u32(value, path, "output.per_page")?;
settings.output.per_page = value;
settings.sources.per_page = SettingSource::Config;
}
if let Some(value) = config.output.color {
validate_color(&value, path, "output.color")?;
settings.output.color = value;
settings.sources.color = SettingSource::Config;
}
if let Some(value) = config.detect.enum_max_cardinality {
require_positive_usize(value, path, "detect.enum_max_cardinality")?;
settings.detect.enum_max_cardinality = value;
}
if let Some(value) = config.detect.enum_coverage_threshold {
validate_unit_interval(value, path, "detect.enum_coverage_threshold")?;
settings.detect.enum_coverage_threshold = value;
}
if let Some(value) = config.detect.path_like_slash_ratio {
validate_unit_interval(value, path, "detect.path_like_slash_ratio")?;
settings.detect.path_like_slash_ratio = value;
}
if let Some(value) = config.detect.score_candidates {
validate_string_list(&value, path, "detect.score_candidates")?;
settings.detect.score_candidates = value;
}
if let Some(value) = config.detect.items_candidates {
validate_string_list(&value, path, "detect.items_candidates")?;
settings.detect.items_candidates = value;
}
if let Some(value) = config.detect.group_preference {
validate_string_list(&value, path, "detect.group_preference")?;
settings.detect.group_preference = value;
}
if let Some(value) = config.strategy.prefer_score_when_present {
settings.strategy.prefer_score_when_present = value;
}
if let Some(value) = config.strategy.default_bands {
require_positive_u8(value, path, "strategy.default_bands")?;
settings.strategy.default_bands = value;
settings.sources.default_bands = SettingSource::Config;
}
if let Some(value) = config.interactive.save_threshold {
require_positive_usize(value, path, "interactive.save_threshold")?;
settings.interactive.save_threshold = value;
settings.sources.save_threshold = SettingSource::Config;
}
for (name, preset) in config.presets {
validate_preset(&name, &preset, path)?;
settings.presets.insert(name, preset);
}
Ok(())
}
fn apply_env(settings: &mut RuntimeSettings) -> Result<(), FaceError> {
if let Some((name, value)) = env_any(&["FACE_FORMAT", "FACE_DEFAULT_FORMAT"]) {
settings.output.default_format = parse_output_format(&value, &name)?;
settings.sources.default_format = SettingSource::Env;
}
if let Some((name, value)) = env_any(&["FACE_PER_PAGE"]) {
settings.output.per_page = parse_positive_u32(&value, &name)?;
settings.sources.per_page = SettingSource::Env;
}
if let Some((name, value)) = env_any(&["FACE_COLOR"]) {
validate_color(&value, &env_path(&name), &name)?;
settings.output.color = value;
settings.sources.color = SettingSource::Env;
}
if let Some((name, value)) = env_any(&["FACE_ENUM_MAX_CARDINALITY"]) {
settings.detect.enum_max_cardinality = parse_positive_usize(&value, &name)?;
}
if let Some((name, value)) = env_any(&["FACE_ENUM_COVERAGE_THRESHOLD"]) {
let parsed = parse_f64(&value, &name)?;
validate_unit_interval(parsed, &env_path(&name), &name)?;
settings.detect.enum_coverage_threshold = parsed;
}
if let Some((name, value)) = env_any(&["FACE_PATH_LIKE_SLASH_RATIO"]) {
let parsed = parse_f64(&value, &name)?;
validate_unit_interval(parsed, &env_path(&name), &name)?;
settings.detect.path_like_slash_ratio = parsed;
}
if let Some((name, value)) = env_any(&["FACE_SCORE_CANDIDATES"]) {
let parsed = parse_list(&value);
validate_string_list(&parsed, &env_path(&name), &name)?;
settings.detect.score_candidates = parsed;
}
if let Some((name, value)) = env_any(&["FACE_ITEMS_CANDIDATES"]) {
let parsed = parse_list(&value);
validate_string_list(&parsed, &env_path(&name), &name)?;
settings.detect.items_candidates = parsed;
}
if let Some((name, value)) = env_any(&["FACE_GROUP_PREFERENCE"]) {
let parsed = parse_list(&value);
validate_string_list(&parsed, &env_path(&name), &name)?;
settings.detect.group_preference = parsed;
}
if let Some((name, value)) = env_any(&["FACE_PREFER_SCORE_WHEN_PRESENT"]) {
settings.strategy.prefer_score_when_present = parse_bool(&value, &name)?;
}
if let Some((name, value)) = env_any(&["FACE_DEFAULT_BANDS"]) {
settings.strategy.default_bands = parse_positive_u8(&value, &name)?;
settings.sources.default_bands = SettingSource::Env;
}
if let Some((name, value)) = env_any(&["FACE_INTERACTIVE_SAVE_THRESHOLD"]) {
settings.interactive.save_threshold = parse_positive_usize(&value, &name)?;
settings.sources.save_threshold = SettingSource::Env;
}
Ok(())
}
fn locate_config_path() -> Option<PathBuf> {
if let Some(path) = nonempty_env_os("FACE_CONFIG") {
return Some(PathBuf::from(path));
}
if let Some(xdg) = nonempty_env_os("XDG_CONFIG_HOME") {
let path = PathBuf::from(xdg).join("face").join("config.toml");
if path.is_file() {
return Some(path);
}
}
let home = nonempty_env_os("HOME")?;
let path = PathBuf::from(home)
.join(".config")
.join("face")
.join("config.toml");
path.is_file().then_some(path)
}
fn nonempty_env_os(name: &str) -> Option<std::ffi::OsString> {
std::env::var_os(name).filter(|value| !value.is_empty())
}
fn env_any(names: &[&str]) -> Option<(String, String)> {
names.iter().find_map(|name| {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| ((*name).to_string(), value))
})
}
fn parse_output_format(raw: &str, name: &str) -> Result<OutputFormat, FaceError> {
serde_json::from_value(serde_json::Value::String(raw.to_string())).map_err(|error| {
FaceError::Config {
path: env_path(name),
message: format!("invalid output format `{raw}`: {error}"),
}
})
}
fn parse_positive_u32(raw: &str, name: &str) -> Result<u32, FaceError> {
let value = raw.parse::<u32>().map_err(|error| FaceError::Config {
path: env_path(name),
message: error.to_string(),
})?;
require_positive_u32(value, &env_path(name), name)?;
Ok(value)
}
fn parse_positive_u8(raw: &str, name: &str) -> Result<u8, FaceError> {
let value = raw.parse::<u8>().map_err(|error| FaceError::Config {
path: env_path(name),
message: error.to_string(),
})?;
require_positive_u8(value, &env_path(name), name)?;
Ok(value)
}
fn parse_positive_usize(raw: &str, name: &str) -> Result<usize, FaceError> {
let value = raw.parse::<usize>().map_err(|error| FaceError::Config {
path: env_path(name),
message: error.to_string(),
})?;
require_positive_usize(value, &env_path(name), name)?;
Ok(value)
}
fn parse_f64(raw: &str, name: &str) -> Result<f64, FaceError> {
raw.parse::<f64>().map_err(|error| FaceError::Config {
path: env_path(name),
message: error.to_string(),
})
}
fn parse_bool(raw: &str, name: &str) -> Result<bool, FaceError> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(FaceError::Config {
path: env_path(name),
message: format!("expected boolean, got `{raw}`"),
}),
}
}
fn parse_list(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn validate_color(value: &str, path: &Path, key: &str) -> Result<(), FaceError> {
match value {
"auto" | "always" | "never" => Ok(()),
other => Err(FaceError::Config {
path: path.to_path_buf(),
message: format!("{key} must be one of auto, always, never; got `{other}`"),
}),
}
}
fn validate_unit_interval(value: f64, path: &Path, key: &str) -> Result<(), FaceError> {
if value.is_finite() && (0.0..=1.0).contains(&value) {
Ok(())
} else {
Err(FaceError::Config {
path: path.to_path_buf(),
message: format!("{key} must be in [0, 1]; got `{value}`"),
})
}
}
fn validate_string_list(values: &[String], path: &Path, key: &str) -> Result<(), FaceError> {
if values.iter().all(|value| !value.trim().is_empty()) {
Ok(())
} else {
Err(FaceError::Config {
path: path.to_path_buf(),
message: format!("{key} cannot contain empty names"),
})
}
}
fn validate_preset(name: &str, preset: &UserPreset, path: &Path) -> Result<(), FaceError> {
if name.trim().is_empty() {
return Err(FaceError::Config {
path: path.to_path_buf(),
message: "preset names cannot be empty".to_string(),
});
}
if let Some(score) = &preset.score
&& score.trim().is_empty()
{
return Err(FaceError::Config {
path: path.to_path_buf(),
message: format!("presets.{name}.score cannot be empty"),
});
}
if let Some(scale) = preset.scale
&& !scale.is_finite()
{
return Err(FaceError::Config {
path: path.to_path_buf(),
message: format!("presets.{name}.scale must be finite"),
});
}
Ok(())
}
fn require_positive_u32(value: u32, path: &Path, key: &str) -> Result<(), FaceError> {
if value > 0 {
Ok(())
} else {
Err(FaceError::Config {
path: path.to_path_buf(),
message: format!("{key} must be greater than zero"),
})
}
}
fn require_positive_u8(value: u8, path: &Path, key: &str) -> Result<(), FaceError> {
if value > 0 {
Ok(())
} else {
Err(FaceError::Config {
path: path.to_path_buf(),
message: format!("{key} must be greater than zero"),
})
}
}
fn require_positive_usize(value: usize, path: &Path, key: &str) -> Result<(), FaceError> {
if value > 0 {
Ok(())
} else {
Err(FaceError::Config {
path: path.to_path_buf(),
message: format!("{key} must be greater than zero"),
})
}
}
fn env_path(name: &str) -> PathBuf {
PathBuf::from(format!("${name}"))
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_config_overrides_output_defaults() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"face-config-test-{}-{}.toml",
std::process::id(),
"output"
));
std::fs::write(
&path,
r#"
[output]
default_format = "json"
per_page = 7
color = "never"
[strategy]
default_bands = 3
"#,
)
.unwrap();
let mut settings = RuntimeSettings::default();
apply_file(&path, &mut settings).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(settings.output.default_format, OutputFormat::Json);
assert_eq!(settings.output.per_page, 7);
assert_eq!(settings.output.color, "never");
assert_eq!(settings.strategy.default_bands, 3);
assert_eq!(settings.sources.default_format, SettingSource::Config);
}
#[test]
fn user_preset_is_loaded() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"face-config-test-{}-{}.toml",
std::process::id(),
"preset"
));
std::fs::write(
&path,
r#"
[presets.my-search]
score = "relevance"
invert = true
scale = 1.0
"#,
)
.unwrap();
let mut settings = RuntimeSettings::default();
apply_file(&path, &mut settings).unwrap();
let _ = std::fs::remove_file(&path);
let preset = settings.presets.get("my-search").expect("preset loaded");
assert_eq!(preset.score.as_deref(), Some("relevance"));
assert_eq!(preset.invert, Some(true));
assert_eq!(preset.scale, Some(1.0));
}
}