use std::path::Path;
use crate::cli_args::FuzzyMode;
use crate::error::AtomwriteError;
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AtomwriteConfig {
pub version: u32,
pub defaults: DefaultsSection,
pub write: WriteSection,
pub watch: WatchSection,
pub fuzzy: FuzzySection,
pub search: SearchSection,
pub storage: StorageSection,
pub log: LogSection,
pub concurrency: ConcurrencySection,
pub lock: LockSection,
pub retry: RetrySection,
pub extract: ExtractSection,
pub summary: SummarySection,
}
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LogSection {
pub file: bool,
pub rotation: String,
}
impl Default for LogSection {
fn default() -> Self {
Self {
file: false,
rotation: crate::constants::LOG_ROTATION_DEFAULT.into(),
}
}
}
pub fn validate_log(cfg: &LogSection) -> Result<(), AtomwriteError> {
match cfg.rotation.as_str() {
"daily" | "hourly" => Ok(()),
other => Err(AtomwriteError::ConfigInvalid {
reason: format!(
"config [log] rotation = \"{other}\" is invalid; use \"daily\" or \"hourly\""
),
}),
}
}
pub fn startup_log_section(
workspace: &Path,
explicit_path: Option<&Path>,
) -> Result<LogSection, AtomwriteError> {
if let Some(path) = explicit_path {
let log = parse_config(path)?.log;
validate_log(&log)?;
return Ok(log);
}
let global = match global_text() {
Some(g) => match &g.text {
Ok(text) => Some(parse_layer(&g.path, text)?),
Err(e) => {
return Err(AtomwriteError::ConfigInvalid {
reason: format!("cannot read config {}: {e}", g.path.display()),
});
}
},
None => None,
};
let local_path = workspace.join(crate::constants::WORKSPACE_CONFIG_FILE);
let local = if local_path.is_file() {
let text =
std::fs::read_to_string(&local_path).map_err(|e| AtomwriteError::ConfigInvalid {
reason: format!("cannot read config {}: {e}", local_path.display()),
})?;
Some(parse_layer(&local_path, &text)?)
} else {
None
};
let log = merge_layers(global, local)?.log;
validate_log(&log)?;
Ok(log)
}
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WatchSection {
pub idle_exit_ms: u64,
pub debounce_ms: u64,
}
impl Default for WatchSection {
fn default() -> Self {
Self {
idle_exit_ms: crate::constants::DEFAULT_WATCH_IDLE_EXIT_MS,
debounce_ms: crate::constants::DEFAULT_WATCH_DEBOUNCE_MS,
}
}
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WriteSection {
pub confirm_large_bytes: u64,
pub shrink_block_percent: u8,
pub auto_rotate_max_age_secs: u64,
#[serde(default)]
pub content_risk_patterns: Vec<String>,
}
impl Default for WriteSection {
fn default() -> Self {
Self {
confirm_large_bytes: crate::constants::CONFIRM_LARGE_FILE_BYTES,
shrink_block_percent: crate::constants::SHRINK_BLOCK_PERCENT,
auto_rotate_max_age_secs: crate::constants::AUTO_ROTATE_MAX_AGE_SECS,
content_risk_patterns: Vec::new(),
}
}
}
pub fn validate_write(cfg: &WriteSection) -> Result<(), AtomwriteError> {
if cfg.confirm_large_bytes == 0 {
return Err(AtomwriteError::InvalidInput {
reason: "config [write] confirm_large_bytes must be >= 1".into(),
});
}
if !(crate::constants::SHRINK_BLOCK_PERCENT_MIN..=crate::constants::SHRINK_BLOCK_PERCENT_MAX)
.contains(&cfg.shrink_block_percent)
{
return Err(AtomwriteError::InvalidInput {
reason: format!(
"config [write] shrink_block_percent = {} is out of range; must be 1..=99",
cfg.shrink_block_percent
),
});
}
if cfg.auto_rotate_max_age_secs == 0 {
return Err(AtomwriteError::InvalidInput {
reason: "config [write] auto_rotate_max_age_secs must be >= 1".into(),
});
}
Ok(())
}
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DefaultsSection {
pub backup: bool,
pub retention: u8,
pub line_ending: String,
pub max_filesize: u64,
}
impl Default for DefaultsSection {
fn default() -> Self {
Self {
backup: true,
retention: crate::constants::DEFAULT_BACKUP_RETENTION,
line_ending: "auto".into(),
max_filesize: crate::constants::DEFAULT_MAX_FILESIZE,
}
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FuzzySection {
pub mode: String,
pub threshold: f64,
#[serde(default = "default_fuzzy_aggressive")]
pub threshold_aggressive: f64,
#[serde(default = "default_fuzzy_context")]
pub threshold_context: f64,
#[serde(default = "default_fuzzy_jw")]
pub threshold_jw: f64,
#[serde(default = "default_fuzzy_soft_floor")]
pub soft_floor_delta: f64,
#[serde(default = "default_fuzzy_max_content_bytes")]
pub max_content_bytes: u64,
}
fn default_fuzzy_aggressive() -> f64 {
crate::constants::FUZZY_THRESHOLD_AGGRESSIVE
}
fn default_fuzzy_context() -> f64 {
crate::constants::FUZZY_THRESHOLD_CONTEXT
}
fn default_fuzzy_jw() -> f64 {
crate::constants::FUZZY_THRESHOLD_JW
}
fn default_fuzzy_soft_floor() -> f64 {
crate::constants::FUZZY_CONTEXT_SOFT_FLOOR_DELTA
}
fn default_fuzzy_max_content_bytes() -> u64 {
crate::constants::FUZZY_MAX_CONTENT_BYTES
}
impl Default for FuzzySection {
fn default() -> Self {
Self {
mode: "auto".into(),
threshold: crate::constants::FUZZY_THRESHOLD_AUTO,
threshold_aggressive: crate::constants::FUZZY_THRESHOLD_AGGRESSIVE,
threshold_context: crate::constants::FUZZY_THRESHOLD_CONTEXT,
threshold_jw: crate::constants::FUZZY_THRESHOLD_JW,
soft_floor_delta: crate::constants::FUZZY_CONTEXT_SOFT_FLOOR_DELTA,
max_content_bytes: crate::constants::FUZZY_MAX_CONTENT_BYTES,
}
}
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StorageSection {
pub home: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SearchSection {
pub context: u32,
pub smart_case: bool,
pub max_filesize: u64,
pub max_columns: usize,
pub max_inflight_bytes: u64,
}
impl Default for SearchSection {
fn default() -> Self {
Self {
context: 0,
smart_case: true,
max_filesize: crate::constants::DEFAULT_SEARCH_MAX_FILESIZE_BYTES,
max_columns: crate::constants::DEFAULT_SEARCH_MAX_COLUMNS,
max_inflight_bytes: crate::constants::SEARCH_MAX_INFLIGHT_BYTES,
}
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SummarySection {
pub errors_max_entries: usize,
}
impl Default for SummarySection {
fn default() -> Self {
Self {
errors_max_entries: crate::constants::SUMMARY_ERRORS_MAX_ENTRIES,
}
}
}
pub fn validate_search_section(cfg: &SearchSection) -> Result<(), AtomwriteError> {
if cfg.max_filesize == 0 {
return Err(AtomwriteError::InvalidInput {
reason: "config [search] max_filesize must be >= 1".into(),
});
}
if cfg.max_columns == 0 {
return Err(AtomwriteError::InvalidInput {
reason: "config [search] max_columns must be >= 1".into(),
});
}
require_in_range(
"search",
"max_inflight_bytes",
cfg.max_inflight_bytes,
crate::constants::SEARCH_MIN_INFLIGHT_BYTES,
crate::constants::SEARCH_MAX_INFLIGHT_CEILING_BYTES,
)?;
Ok(())
}
pub fn validate_summary_section(cfg: &SummarySection) -> Result<(), AtomwriteError> {
require_in_range(
"summary",
"errors_max_entries",
cfg.errors_max_entries as u64,
crate::constants::SUMMARY_ERRORS_MIN_ENTRIES as u64,
crate::constants::SUMMARY_ERRORS_CEILING as u64,
)?;
Ok(())
}
pub fn load_config(
workspace: &Path,
explicit_path: Option<&Path>,
) -> Result<AtomwriteConfig, AtomwriteError> {
if let Some(path) = explicit_path {
return parse_config(path);
}
let global = match global_text() {
Some(g) => match &g.text {
Ok(text) => Some(parse_layer(&g.path, text)?),
Err(e) => {
return Err(AtomwriteError::ConfigInvalid {
reason: format!("cannot read config {}: {e}", g.path.display()),
});
}
},
None => None,
};
let local_path = workspace.join(crate::constants::WORKSPACE_CONFIG_FILE);
let local = if local_path.is_file() {
Some(read_layer(&local_path)?)
} else {
None
};
merge_layers(global, local)
}
fn merge_layers(
global: Option<Layer>,
local: Option<Layer>,
) -> Result<AtomwriteConfig, AtomwriteError> {
match (global, local) {
(None, None) => Ok(AtomwriteConfig::default()),
(Some(only), None) | (None, Some(only)) => Ok(only.config),
(Some(global), Some(local)) => {
let merged = merge_tables(global.table, local.table);
toml::Value::Table(merged)
.try_into()
.map_err(|e| AtomwriteError::ConfigInvalid {
reason: format!(
"merged config of {} and {} is invalid: {e}",
crate::constants::GLOBAL_CONFIG_FILE,
crate::constants::WORKSPACE_CONFIG_FILE
),
})
}
}
}
fn parse_config(path: &Path) -> Result<AtomwriteConfig, AtomwriteError> {
read_layer(path).map(|layer| layer.config)
}
pub fn validate_fuzzy(cfg: &FuzzySection) -> Result<(), AtomwriteError> {
let mode = cfg.mode.trim().to_ascii_lowercase();
match mode.as_str() {
"auto" | "aggressive" | "off" | "exact" => {}
"disabled" | "false" | "0" => {
return Err(AtomwriteError::InvalidInput {
reason: format!(
"config [fuzzy] mode = \"{}\" is invalid; use \"auto\", \"aggressive\", or \"off\" (exact-only)",
cfg.mode
),
});
}
other => {
return Err(AtomwriteError::InvalidInput {
reason: format!(
"config [fuzzy] mode = \"{other}\" is invalid; use \"auto\", \"aggressive\", or \"off\""
),
});
}
}
if !(crate::constants::SIMILARITY_MIN..=crate::constants::SIMILARITY_MAX)
.contains(&cfg.threshold)
{
return Err(AtomwriteError::InvalidInput {
reason: format!(
"config [fuzzy] threshold = {} is out of range; must be between 0.0 and 1.0",
cfg.threshold
),
});
}
if cfg.max_content_bytes == 0 {
return Err(AtomwriteError::InvalidInput {
reason: "config [fuzzy] max_content_bytes = 0 is invalid; must be greater than 0"
.into(),
});
}
Ok(())
}
pub fn resolve_fuzzy(
cli_mode: FuzzyMode,
cli_threshold: Option<f64>,
cfg: &FuzzySection,
) -> Result<(FuzzyMode, Option<f64>), AtomwriteError> {
validate_fuzzy(cfg)?;
let cfg_mode = match cfg.mode.trim().to_ascii_lowercase().as_str() {
"aggressive" => FuzzyMode::Aggressive,
"off" => FuzzyMode::Off,
_ => FuzzyMode::Auto,
};
let mode = match cli_mode {
FuzzyMode::Aggressive => FuzzyMode::Aggressive,
FuzzyMode::Off => FuzzyMode::Off,
FuzzyMode::Auto => cfg_mode,
};
let threshold = match cli_threshold {
Some(t) => Some(t),
None if (cfg.threshold - crate::constants::FUZZY_THRESHOLD_AUTO).abs() > f64::EPSILON => {
Some(cfg.threshold)
}
None => None,
};
Ok((mode, threshold))
}
include!("config_tuning.inc.rs");
include!("config_tests.inc.rs");