use crate::error::{ConfigError, Result};
use serde::Deserialize;
use std::fmt;
use std::path::{Path, PathBuf};
pub fn parse_bytes(input: &str) -> std::result::Result<u64, String> {
let s = input.trim();
if s.is_empty() {
return Err("empty byte size".into());
}
let split = s
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(s.len());
let num_part = &s[..split];
let suffix = s[split..].trim().to_ascii_lowercase();
let (int_part, frac_part) = match num_part.split_once('.') {
Some((i, f)) => (i, f),
None => (num_part, ""),
};
if int_part.is_empty() && frac_part.is_empty() {
return Err(format!("`{s}` is not a number"));
}
if !int_part.chars().all(|c| c.is_ascii_digit())
|| !frac_part.chars().all(|c| c.is_ascii_digit())
{
return Err(format!("`{s}` is not a number"));
}
let mult: u128 = match suffix.as_str() {
"" | "b" => 1,
"k" | "kb" | "kib" => 1024,
"m" | "mb" | "mib" => 1024 * 1024,
"g" | "gb" | "gib" => 1024 * 1024 * 1024,
other => return Err(format!("unknown byte-size suffix `{other}`")),
};
let int_val: u128 = int_part.parse().unwrap_or(0);
let total = int_val
.checked_mul(mult)
.ok_or_else(|| "byte size too large".to_string())?;
let frac_val: u128 = if frac_part.is_empty() {
0
} else {
frac_part.parse().unwrap_or(0)
};
let denom = 10u128
.checked_pow(u32::try_from(frac_part.len()).unwrap_or(0))
.ok_or_else(|| "byte size too large".to_string())?;
let frac_contrib = frac_val
.checked_mul(mult)
.ok_or_else(|| "byte size too large".to_string())?
.checked_div(denom)
.unwrap_or(0);
let bytes = total
.checked_add(frac_contrib)
.ok_or_else(|| "byte size too large".to_string())?;
u64::try_from(bytes).map_err(|_| "byte size too large".to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[non_exhaustive]
#[serde(rename_all = "lowercase")]
pub enum Theme {
#[default]
Auto,
Dark,
Light,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct RecordConfig {
pub max_file_size: u64,
pub record_input: bool,
pub record_binary: bool,
pub ignore: Vec<String>,
}
impl Default for RecordConfig {
fn default() -> Self {
Self {
max_file_size: 4 * 1024 * 1024,
record_input: false,
record_binary: false,
ignore: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct StorageConfig {
pub data_dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct ReplayConfig {
pub theme: Theme,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RedactionRule {
pub name: String,
pub pattern: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RedactionConfig {
pub at_record: bool,
pub entropy: bool,
pub rules: Vec<RedactionRule>,
}
impl Default for RedactionConfig {
fn default() -> Self {
Self {
at_record: false,
entropy: true,
rules: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct Config {
pub record: RecordConfig,
pub storage: StorageConfig,
pub replay: ReplayConfig,
pub redaction: RedactionConfig,
}
impl Config {
pub fn load(config_path: &Path) -> Result<Self> {
let mut cfg = Self::default();
let table = match read_or_default_config(config_path)? {
Some(table) => Some(table),
None => match legacy_fallback_path(config_path) {
Some(legacy) => {
crate::deprecation::warn_deprecated(
"legacy-config-filename",
&format!("the config filename `{}`", legacy.display()),
&format!(
"rename it to `{}` (loading `{}` for now so its settings still take effect)",
config_path.display(),
legacy.display(),
),
);
read_or_default_config(&legacy)?
}
None => None,
},
};
let Some(table) = table else {
return Ok(cfg);
};
warn_unknown_keys(&table);
merge_table(&mut cfg, &table)?;
Ok(cfg)
}
}
const NONCANONICAL_CONFIG_NAMES: &[&str] = &["halfhand.toml", "hh.toml"];
fn legacy_fallback_path(config_path: &Path) -> Option<PathBuf> {
let dir = config_path.parent()?;
NONCANONICAL_CONFIG_NAMES
.iter()
.map(|name| dir.join(name))
.find(|c| c.exists())
}
pub fn warn_on_ignored_config_files(config_path: &Path) {
for candidate in ignored_noncanonical_config_files(config_path) {
eprintln!(
"hh: warning: found {cand} but Halfhand reads {canonical}; ignoring {cand} \
— move its contents into {canonical} so they take effect",
cand = candidate.display(),
canonical = config_path.display(),
);
}
}
#[must_use]
pub fn ignored_noncanonical_config_files(config_path: &Path) -> Vec<PathBuf> {
if !config_path.exists() {
return Vec::new();
}
let Some(dir) = config_path.parent() else {
return Vec::new();
};
NONCANONICAL_CONFIG_NAMES
.iter()
.map(|name| dir.join(name))
.filter(|c| c.exists())
.collect()
}
fn read_or_default_config(path: &Path) -> Result<Option<toml::Table>> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let table: toml::Table = toml::from_str(&contents).map_err(|e| ConfigError::Parse {
path: path.to_path_buf(),
source: e,
})?;
Ok(Some(table))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ConfigError::Read {
path: path.to_path_buf(),
source: e,
}
.into()),
}
}
const KNOWN: &[(&str, &[&str])] = &[
(
"record",
&["max_file_size", "record_input", "record_binary", "ignore"],
),
("storage", &["data_dir"]),
("replay", &["theme"]),
("redaction", &["at_record", "entropy", "rules"]),
];
fn warn_unknown_keys(table: &toml::Table) {
for (key, value) in table {
let known_keys = KNOWN
.iter()
.find_map(|(k, keys)| (*k == key).then_some(*keys));
match known_keys {
None => eprintln!("warn: config: unknown top-level key `{key}` ignored"),
Some(allowed) => {
if let Some(sub) = value.as_table() {
for (subkey, _) in sub {
if !allowed.contains(&subkey.as_str()) {
eprintln!("warn: config: unknown key `{key}.{subkey}` ignored");
}
}
}
}
}
}
}
fn merge_table(cfg: &mut Config, table: &toml::Table) -> Result<()> {
if let Some(record) = table.get("record").and_then(toml::Value::as_table) {
if let Some(v) = record.get("max_file_size") {
cfg.record.max_file_size = value_to_bytes(v)?;
}
if let Some(v) = record.get("record_input").and_then(toml::Value::as_bool) {
cfg.record.record_input = v;
}
if let Some(v) = record.get("record_binary").and_then(toml::Value::as_bool) {
cfg.record.record_binary = v;
}
if let Some(v) = record.get("ignore").and_then(toml::Value::as_array) {
cfg.record.ignore = v
.iter()
.filter_map(toml::Value::as_str)
.map(String::from)
.collect();
}
}
if let Some(storage) = table.get("storage").and_then(toml::Value::as_table) {
if let Some(v) = storage.get("data_dir").and_then(toml::Value::as_str) {
cfg.storage.data_dir = PathBuf::from(v);
}
}
if let Some(replay) = table.get("replay").and_then(toml::Value::as_table) {
if let Some(v) = replay.get("theme").and_then(toml::Value::as_str) {
cfg.replay.theme = match v {
"auto" => Theme::Auto,
"dark" => Theme::Dark,
"light" => Theme::Light,
other => {
return Err(ConfigError::Value(format!(
"replay.theme `{other}` not one of auto|dark|light"
))
.into())
}
};
}
}
if let Some(redaction) = table.get("redaction").and_then(toml::Value::as_table) {
if let Some(v) = redaction.get("at_record").and_then(toml::Value::as_bool) {
cfg.redaction.at_record = v;
}
if let Some(v) = redaction.get("entropy").and_then(toml::Value::as_bool) {
cfg.redaction.entropy = v;
}
if let Some(v) = redaction.get("rules") {
cfg.redaction.rules = parse_redaction_rules(v)?;
}
}
Ok(())
}
fn parse_redaction_rules(v: &toml::Value) -> Result<Vec<RedactionRule>> {
let Some(arr) = v.as_array() else {
return Err(ConfigError::Value(
"redaction.rules must be an array of { name, pattern } tables, e.g. \
rules = [{ name = \"acme\", pattern = \"ACME-[0-9A-F]{16}\" }]"
.into(),
)
.into());
};
let mut rules = Vec::with_capacity(arr.len());
for (i, entry) in arr.iter().enumerate() {
let table = entry.as_table().ok_or_else(|| {
ConfigError::Value(format!(
"redaction.rules[{i}] must be a table with string `name` and `pattern` keys"
))
})?;
let name = table
.get("name")
.and_then(toml::Value::as_str)
.ok_or_else(|| {
ConfigError::Value(format!("redaction.rules[{i}] is missing a string `name`"))
})?;
let pattern = table
.get("pattern")
.and_then(toml::Value::as_str)
.ok_or_else(|| {
ConfigError::Value(format!(
"redaction.rules[{i}] (`{name}`) is missing a string `pattern`"
))
})?;
rules.push(RedactionRule {
name: name.to_string(),
pattern: pattern.to_string(),
});
}
Ok(rules)
}
fn value_to_bytes(v: &toml::Value) -> Result<u64> {
match v {
toml::Value::Integer(n) => u64::try_from(*n).map_err(|_| {
ConfigError::Value(format!("max_file_size cannot be negative: {n}")).into()
}),
toml::Value::String(s) => Ok(parse_bytes(s).map_err(ConfigError::Value)?),
other => Err(ConfigError::Value(format!(
"max_file_size must be a string or integer, got {}",
other.type_str()
))
.into()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Paths {
pub data_dir: PathBuf,
pub config_path: PathBuf,
pub db_path: PathBuf,
pub blobs_dir: PathBuf,
}
impl Paths {
pub fn resolve(config: &Config) -> Result<Self> {
let env_dir = std::env::var_os("HH_DATA_DIR").filter(|s| !s.is_empty());
let data_dir = if let Some(d) = env_dir {
PathBuf::from(d)
} else if !config.storage.data_dir.as_os_str().is_empty() {
config.storage.data_dir.clone()
} else {
platform_data_dir()?
};
Ok(Self {
db_path: data_dir.join("hh.db"),
blobs_dir: data_dir.join("blobs"),
config_path: platform_config_path()?,
data_dir,
})
}
pub fn with_data_dir(data_dir: PathBuf) -> Self {
Self {
db_path: data_dir.join("hh.db"),
blobs_dir: data_dir.join("blobs"),
config_path: data_dir.join("config.toml"),
data_dir,
}
}
}
fn platform_dirs() -> Result<directories::ProjectDirs> {
directories::ProjectDirs::from("", "", "halfhand").ok_or_else(|| {
ConfigError::Value("cannot determine platform config/data directories (no HOME?)".into())
.into()
})
}
fn platform_data_dir() -> Result<PathBuf> {
Ok(platform_dirs()?.data_dir().to_path_buf())
}
fn platform_config_path() -> Result<PathBuf> {
Ok(platform_dirs()?.config_dir().join("config.toml"))
}
impl fmt::Display for Theme {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Theme::Auto => "auto",
Theme::Dark => "dark",
Theme::Light => "light",
};
f.write_str(s)
}
}
#[cfg(feature = "fuzzing")]
pub mod fuzzing {
use super::{merge_table, warn_unknown_keys, Config};
pub fn fuzz_parse(s: &str) {
let Ok(table) = toml::from_str::<toml::Table>(s) else {
return;
};
warn_unknown_keys(&table);
let mut cfg = Config::default();
let _ = merge_table(&mut cfg, &table);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use tempfile::TempDir;
fn write_config(dir: &Path, body: &str) -> PathBuf {
let p = dir.join("config.toml");
let mut f = fs::File::create(&p).unwrap();
f.write_all(body.as_bytes()).unwrap();
p
}
#[test]
fn parse_bytes_accepts_suffixes() {
assert_eq!(parse_bytes("4MiB").unwrap(), 4 * 1024 * 1024);
assert_eq!(parse_bytes("512KiB").unwrap(), 512 * 1024);
assert_eq!(parse_bytes("100B").unwrap(), 100);
assert_eq!(parse_bytes("2048").unwrap(), 2048);
assert!(parse_bytes("nope").is_err());
assert!(parse_bytes("4PiB").is_err());
}
#[test]
fn config_defaults() {
let cfg = Config::default();
assert_eq!(cfg.record.max_file_size, 4 * 1024 * 1024);
assert!(!cfg.record.record_input);
assert!(!cfg.record.record_binary);
assert!(cfg.record.ignore.is_empty());
assert_eq!(cfg.replay.theme, Theme::Auto);
assert!(cfg.storage.data_dir.as_os_str().is_empty());
}
#[test]
fn config_loads_known_keys() {
let tmp = TempDir::new().unwrap();
let path = write_config(
tmp.path(),
"\
[record]
max_file_size = \"1MiB\"
record_input = true
ignore = [\"dist/\", \"*.lock\"]
[storage]
data_dir = \"/tmp/hh-from-file\"
[replay]
theme = \"dark\"
",
);
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.record.max_file_size, 1024 * 1024);
assert!(cfg.record.record_input);
assert_eq!(cfg.record.ignore, vec!["dist/", "*.lock"]);
assert_eq!(cfg.storage.data_dir, PathBuf::from("/tmp/hh-from-file"));
assert_eq!(cfg.replay.theme, Theme::Dark);
}
#[test]
fn config_unknown_keys_warn_but_load() {
let tmp = TempDir::new().unwrap();
let path = write_config(
tmp.path(),
"\
[record]
max_file_size = \"2MiB\"
mystery = true
[storage]
data_dir = \"/tmp/hh-unknown\"
[experimental]
feature = \"x\"
",
);
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.record.max_file_size, 2 * 1024 * 1024);
assert_eq!(cfg.storage.data_dir, PathBuf::from("/tmp/hh-unknown"));
}
#[test]
fn config_loads_redaction_section() {
let tmp = TempDir::new().unwrap();
let path = write_config(
tmp.path(),
"\
[redaction]
at_record = true
entropy = false
rules = [{ name = \"acme\", pattern = \"ACME-[0-9A-F]{16}\" }]
",
);
let cfg = Config::load(&path).unwrap();
assert!(cfg.redaction.at_record);
assert!(!cfg.redaction.entropy);
assert_eq!(
cfg.redaction.rules,
vec![RedactionRule {
name: "acme".into(),
pattern: "ACME-[0-9A-F]{16}".into(),
}]
);
let d = RedactionConfig::default();
assert!(!d.at_record);
assert!(d.entropy);
assert!(d.rules.is_empty());
}
#[test]
fn config_malformed_redaction_rules_error_actionably() {
let tmp = TempDir::new().unwrap();
let path = write_config(tmp.path(), "[redaction]\nrules = [{ name = \"acme\" }]\n");
let err = Config::load(&path).unwrap_err().to_string();
assert!(err.contains("acme"), "must name the rule: {err}");
let path2 = write_config(tmp.path(), "[redaction]\nrules = \"nope\"\n");
let err2 = Config::load(&path2).unwrap_err().to_string();
assert!(err2.contains("array"), "must explain the shape: {err2}");
}
#[test]
fn config_missing_file_is_default() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("does-not-exist.toml");
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg, Config::default());
}
#[test]
fn config_malformed_toml_errors() {
let tmp = TempDir::new().unwrap();
let path = write_config(tmp.path(), "this is = = not toml");
assert!(Config::load(&path).is_err());
}
#[test]
fn paths_precedence_default_file_env() {
std::env::remove_var("HH_DATA_DIR");
let cfg = Config::default();
let default_paths = Paths::resolve(&cfg).unwrap();
assert!(default_paths.data_dir.ends_with("halfhand"));
let cfg_file = Config {
storage: StorageConfig {
data_dir: PathBuf::from("/tmp/hh-file-wins"),
},
..Config::default()
};
let file_paths = Paths::resolve(&cfg_file).unwrap();
assert_eq!(file_paths.data_dir, PathBuf::from("/tmp/hh-file-wins"));
std::env::set_var("HH_DATA_DIR", "/tmp/hh-env-wins");
let env_paths = Paths::resolve(&cfg_file).unwrap();
assert_eq!(env_paths.data_dir, PathBuf::from("/tmp/hh-env-wins"));
std::env::remove_var("HH_DATA_DIR");
}
#[test]
fn paths_components_are_under_data_dir() {
let p = Paths::with_data_dir(PathBuf::from("/tmp/hh-test"));
assert_eq!(p.db_path, PathBuf::from("/tmp/hh-test/hh.db"));
assert_eq!(p.blobs_dir, PathBuf::from("/tmp/hh-test/blobs"));
}
#[test]
fn warn_on_ignored_halfhand_toml() {
let tmp = TempDir::new().unwrap();
let canonical = tmp.path().join("config.toml");
std::fs::write(&canonical, "[record]\nignore = [\"canonical\"]\n").unwrap();
std::fs::write(
tmp.path().join("halfhand.toml"),
"[record]\nignore = [\"x\"]\n",
)
.unwrap();
let ignored = ignored_noncanonical_config_files(&canonical);
assert_eq!(ignored, vec![tmp.path().join("halfhand.toml")]);
warn_on_ignored_config_files(&canonical);
let tmp2 = TempDir::new().unwrap();
let canonical_absent = tmp2.path().join("config.toml");
std::fs::write(
tmp2.path().join("halfhand.toml"),
"[record]\nignore = [\"y\"]\n",
)
.unwrap();
assert!(!canonical_absent.exists());
assert!(ignored_noncanonical_config_files(&canonical_absent).is_empty());
warn_on_ignored_config_files(&canonical_absent);
warn_on_ignored_config_files(Path::new("/no/such/dir/config.toml"));
}
#[test]
fn config_load_falls_back_to_halfhand_toml() {
let tmp = TempDir::new().unwrap();
let canonical = tmp.path().join("config.toml");
std::fs::write(
tmp.path().join("halfhand.toml"),
"[storage]\ndata_dir = \"/tmp/hh-from-legacy\"\n",
)
.unwrap();
assert!(!canonical.exists());
let cfg = Config::load(&canonical).unwrap();
assert_eq!(cfg.storage.data_dir, PathBuf::from("/tmp/hh-from-legacy"));
}
#[test]
fn config_load_canonical_wins_over_halfhand_toml() {
let tmp = TempDir::new().unwrap();
let canonical = write_config(tmp.path(), "[storage]\ndata_dir = \"/tmp/hh-canonical\"\n");
std::fs::write(
tmp.path().join("halfhand.toml"),
"[storage]\ndata_dir = \"/tmp/hh-legacy\"\n",
)
.unwrap();
let cfg = Config::load(&canonical).unwrap();
assert_eq!(cfg.storage.data_dir, PathBuf::from("/tmp/hh-canonical"));
}
}