Skip to main content

hh_core/
config.rs

1//! Configuration and path resolution (SRS §2.3, §4.2).
2//!
3//! Precedence is **default < config file < `HH_DATA_DIR` env**. Unknown config
4//! keys emit a single-line warning on stderr but never prevent startup, per
5//! SRS §4.2 ("All keys optional; unknown keys warn, never fail").
6
7use crate::error::{ConfigError, Result};
8use serde::Deserialize;
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12/// Byte-size parser for values like `4MiB`, `512KiB`, `100B`.
13///
14/// Accepts `B`, `KB`/`KiB`, `MB`/`MiB`, `GB`/`GiB` (case-insensitive). Binary
15/// (`KiB`) and decimal (`KB`) suffixes are both treated as binary multiples
16/// (1024-based) for simplicity, matching how the rest of the tool talks about
17/// file sizes; a bare integer is interpreted as bytes.
18pub fn parse_bytes(input: &str) -> std::result::Result<u64, String> {
19    let s = input.trim();
20    if s.is_empty() {
21        return Err("empty byte size".into());
22    }
23    // Split into numeric prefix and suffix at the first non-digit/non-dot.
24    let split = s
25        .find(|c: char| !c.is_ascii_digit() && c != '.')
26        .unwrap_or(s.len());
27    let num_part = &s[..split];
28    let suffix = s[split..].trim().to_ascii_lowercase();
29    // Integer + optional fractional part, parsed without f64 to stay exact.
30    let (int_part, frac_part) = match num_part.split_once('.') {
31        Some((i, f)) => (i, f),
32        None => (num_part, ""),
33    };
34    if int_part.is_empty() && frac_part.is_empty() {
35        return Err(format!("`{s}` is not a number"));
36    }
37    if !int_part.chars().all(|c| c.is_ascii_digit())
38        || !frac_part.chars().all(|c| c.is_ascii_digit())
39    {
40        return Err(format!("`{s}` is not a number"));
41    }
42    let mult: u128 = match suffix.as_str() {
43        "" | "b" => 1,
44        "k" | "kb" | "kib" => 1024,
45        "m" | "mb" | "mib" => 1024 * 1024,
46        "g" | "gb" | "gib" => 1024 * 1024 * 1024,
47        other => return Err(format!("unknown byte-size suffix `{other}`")),
48    };
49    let int_val: u128 = int_part.parse().unwrap_or(0);
50    let total = int_val
51        .checked_mul(mult)
52        .ok_or_else(|| "byte size too large".to_string())?;
53    let frac_val: u128 = if frac_part.is_empty() {
54        0
55    } else {
56        frac_part.parse().unwrap_or(0)
57    };
58    let denom = 10u128
59        .checked_pow(u32::try_from(frac_part.len()).unwrap_or(0))
60        .ok_or_else(|| "byte size too large".to_string())?;
61    let frac_contrib = frac_val
62        .checked_mul(mult)
63        .ok_or_else(|| "byte size too large".to_string())?
64        .checked_div(denom)
65        .unwrap_or(0);
66    let bytes = total
67        .checked_add(frac_contrib)
68        .ok_or_else(|| "byte size too large".to_string())?;
69    u64::try_from(bytes).map_err(|_| "byte size too large".to_string())
70}
71
72/// Replay color theme (SRS §4.2 `[replay] theme`).
73///
74/// `#[non_exhaustive]`: this and the other config types below are the crate's
75/// growth-prone public surface — new `[section]` keys/variants are expected
76/// over time (CLAUDE.md v1.0.0 addendum: additive-only). Marking them
77/// non-exhaustive up front means a future added key/variant stays additive
78/// under `cargo-semver-checks --release-type minor` instead of registering as
79/// a break, matching what the check is actually meant to enforce.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
81#[non_exhaustive]
82#[serde(rename_all = "lowercase")]
83pub enum Theme {
84    /// Follow the terminal's reported color scheme.
85    #[default]
86    Auto,
87    /// Force dark.
88    Dark,
89    /// Force light.
90    Light,
91}
92
93/// Record-time options (SRS §4.2 `[record]`).
94#[derive(Debug, Clone, PartialEq)]
95#[non_exhaustive]
96pub struct RecordConfig {
97    /// Max file size to capture, in bytes (default 4 MiB).
98    pub max_file_size: u64,
99    /// Whether to record user keystrokes (default off; NFR-4).
100    pub record_input: bool,
101    /// Whether to store binary file contents (default off).
102    pub record_binary: bool,
103    /// Extra ignore patterns extending the built-in list + .gitignore.
104    pub ignore: Vec<String>,
105}
106
107impl Default for RecordConfig {
108    fn default() -> Self {
109        Self {
110            max_file_size: 4 * 1024 * 1024,
111            record_input: false,
112            record_binary: false,
113            ignore: Vec::new(),
114        }
115    }
116}
117
118/// Storage options (SRS §4.2 `[storage]`).
119#[derive(Debug, Clone, Default, PartialEq)]
120#[non_exhaustive]
121pub struct StorageConfig {
122    /// Data directory override; empty means platform default.
123    pub data_dir: PathBuf,
124}
125
126/// Replay options (SRS §4.2 `[replay]`).
127#[derive(Debug, Clone, PartialEq, Default)]
128#[non_exhaustive]
129pub struct ReplayConfig {
130    /// Color theme.
131    pub theme: Theme,
132}
133
134/// One user-defined secret detector (`[redaction] rules`, see
135/// docs/redaction-design.md). The `pattern` is compiled by
136/// [`crate::redact::Detectors::new`]; an invalid regex is an actionable error
137/// there, not a silent no-op.
138#[derive(Debug, Clone, PartialEq, Eq)]
139#[non_exhaustive]
140pub struct RedactionRule {
141    /// Short rule name; findings report as `custom:<name>`.
142    pub name: String,
143    /// The regex to match (Rust `regex` syntax, linear-time).
144    pub pattern: String,
145}
146
147/// Redaction options (`[redaction]`, docs/redaction-design.md).
148#[derive(Debug, Clone, PartialEq, Eq)]
149#[non_exhaustive]
150pub struct RedactionConfig {
151    /// Redact matches *before they hit disk* during recording (opt-in;
152    /// default off — sessions record raw locally, and export-time redaction
153    /// guards what leaves the machine).
154    pub at_record: bool,
155    /// Enable the conservative high-entropy string detector (default on).
156    pub entropy: bool,
157    /// User-defined detectors, applied in addition to the built-ins.
158    pub rules: Vec<RedactionRule>,
159}
160
161impl Default for RedactionConfig {
162    fn default() -> Self {
163        Self {
164            at_record: false,
165            entropy: true,
166            rules: Vec::new(),
167        }
168    }
169}
170
171/// The full configuration.
172#[derive(Debug, Clone, Default, PartialEq)]
173#[non_exhaustive]
174pub struct Config {
175    /// `[record]`
176    pub record: RecordConfig,
177    /// `[storage]`
178    pub storage: StorageConfig,
179    /// `[replay]`
180    pub replay: ReplayConfig,
181    /// `[redaction]`
182    pub redaction: RedactionConfig,
183}
184
185impl Config {
186    /// Load configuration from `config_path`, falling back to [`Default`] when
187    /// the file does not exist. Unknown keys warn on stderr but never fail.
188    ///
189    /// When `config_path` (`config.toml`) is absent but a legacy config file
190    /// (`halfhand.toml` or `hh.toml`) exists in the same directory, that file
191    /// is loaded instead — a pre-rename config still takes effect and is never
192    /// silently ignored. A single one-line deprecation hint is emitted on stderr
193    /// pointing the user at the rename.
194    pub fn load(config_path: &Path) -> Result<Self> {
195        let mut cfg = Self::default();
196        let table = match read_or_default_config(config_path)? {
197            Some(table) => Some(table),
198            None => match legacy_fallback_path(config_path) {
199                Some(legacy) => {
200                    crate::deprecation::warn_deprecated(
201                        "legacy-config-filename",
202                        &format!("the config filename `{}`", legacy.display()),
203                        &format!(
204                            "rename it to `{}` (loading `{}` for now so its settings still take effect)",
205                            config_path.display(),
206                            legacy.display(),
207                        ),
208                    );
209                    read_or_default_config(&legacy)?
210                }
211                None => None,
212            },
213        };
214        let Some(table) = table else {
215            return Ok(cfg);
216        };
217        warn_unknown_keys(&table);
218        merge_table(&mut cfg, &table)?;
219        Ok(cfg)
220    }
221}
222
223/// Other filenames users commonly reach for, in addition to the canonical
224/// `config.toml`. When the canonical `config.toml` is *absent*, [`Config::load`]
225/// falls back to the first of these that exists in the same directory (so a
226/// pre-rename config still takes effect — it is never silently ignored). When
227/// `config.toml` *is* present, these are genuinely ignored (the canonical path
228/// wins), and silent misconfiguration (ignore globs never applied, a custom
229/// data dir never honored) is a bug, so we warn loudly and tell the user
230/// exactly what to move where.
231const NONCANONICAL_CONFIG_NAMES: &[&str] = &["halfhand.toml", "hh.toml"];
232
233/// The first existing legacy config file (e.g. `halfhand.toml`, `hh.toml`) in
234/// the same directory as `config_path`, in [`NONCANONICAL_CONFIG_NAMES`] order,
235/// or `None` when none is present. Callers are responsible for only treating
236/// this as a fallback when the canonical `config.toml` is absent (the canonical
237/// path always wins).
238fn legacy_fallback_path(config_path: &Path) -> Option<PathBuf> {
239    let dir = config_path.parent()?;
240    NONCANONICAL_CONFIG_NAMES
241        .iter()
242        .map(|name| dir.join(name))
243        .find(|c| c.exists())
244}
245
246/// Warn on stderr if a non-canonical config file (e.g. `halfhand.toml`) exists
247/// alongside the canonical `config_path` (`config.toml`) — i.e. it is genuinely
248/// being ignored because the canonical file is present. No warning is emitted
249/// when `config.toml` is absent: in that case [`Config::load`] falls back to the
250/// legacy file and loads it, so nothing is ignored. Idempotent and best-effort:
251/// a missing parent dir or an unreadable file is silently skipped. Called by the
252/// binary on every store open so the user learns their config is being ignored.
253pub fn warn_on_ignored_config_files(config_path: &Path) {
254    for candidate in ignored_noncanonical_config_files(config_path) {
255        eprintln!(
256            "hh: warning: found {cand} but Halfhand reads {canonical}; ignoring {cand} \
257             — move its contents into {canonical} so they take effect",
258            cand = candidate.display(),
259            canonical = config_path.display(),
260        );
261    }
262}
263
264/// Return the non-canonical config files (e.g. `halfhand.toml`, `hh.toml`) that
265/// are *genuinely ignored* alongside `config_path` — i.e. only when the
266/// canonical `config.toml` is present (it wins). Empty when `config.toml` is
267/// absent (then [`Config::load`] falls back to the legacy file and loads it, so
268/// nothing is ignored) or when no legacy file is present (the common,
269/// correctly-configured case). Used by `hh doctor` to report this class of
270/// silent misconfiguration in its structured output, where the stderr warning
271/// from [`warn_on_ignored_config_files`] would not be captured (e.g. `--json`).
272#[must_use]
273pub fn ignored_noncanonical_config_files(config_path: &Path) -> Vec<PathBuf> {
274    // Legacy files are only "ignored" when the canonical config is present
275    // (it wins). When it's absent, Config::load falls back to them, so they
276    // are not ignored and must not be reported here.
277    if !config_path.exists() {
278        return Vec::new();
279    }
280    let Some(dir) = config_path.parent() else {
281        return Vec::new();
282    };
283    NONCANONICAL_CONFIG_NAMES
284        .iter()
285        .map(|name| dir.join(name))
286        .filter(|c| c.exists())
287        .collect()
288}
289
290/// Read the config file into a TOML table, returning `Ok(None)` if it does not
291/// exist (not an error — the file is entirely optional per SRS §4.2).
292fn read_or_default_config(path: &Path) -> Result<Option<toml::Table>> {
293    match std::fs::read_to_string(path) {
294        Ok(contents) => {
295            let table: toml::Table = toml::from_str(&contents).map_err(|e| ConfigError::Parse {
296                path: path.to_path_buf(),
297                source: e,
298            })?;
299            Ok(Some(table))
300        }
301        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
302        Err(e) => Err(ConfigError::Read {
303            path: path.to_path_buf(),
304            source: e,
305        }
306        .into()),
307    }
308}
309
310/// Known top-level tables and their allowed keys.
311const KNOWN: &[(&str, &[&str])] = &[
312    (
313        "record",
314        &["max_file_size", "record_input", "record_binary", "ignore"],
315    ),
316    ("storage", &["data_dir"]),
317    ("replay", &["theme"]),
318    ("redaction", &["at_record", "entropy", "rules"]),
319];
320
321/// Walk the parsed table and warn on stderr about any key we do not recognize.
322fn warn_unknown_keys(table: &toml::Table) {
323    for (key, value) in table {
324        let known_keys = KNOWN
325            .iter()
326            .find_map(|(k, keys)| (*k == key).then_some(*keys));
327        match known_keys {
328            None => eprintln!("warn: config: unknown top-level key `{key}` ignored"),
329            Some(allowed) => {
330                if let Some(sub) = value.as_table() {
331                    for (subkey, _) in sub {
332                        if !allowed.contains(&subkey.as_str()) {
333                            eprintln!("warn: config: unknown key `{key}.{subkey}` ignored");
334                        }
335                    }
336                }
337            }
338        }
339    }
340}
341
342/// Merge a parsed TOML table into a [`Config`], interpreting known values.
343fn merge_table(cfg: &mut Config, table: &toml::Table) -> Result<()> {
344    if let Some(record) = table.get("record").and_then(toml::Value::as_table) {
345        if let Some(v) = record.get("max_file_size") {
346            cfg.record.max_file_size = value_to_bytes(v)?;
347        }
348        if let Some(v) = record.get("record_input").and_then(toml::Value::as_bool) {
349            cfg.record.record_input = v;
350        }
351        if let Some(v) = record.get("record_binary").and_then(toml::Value::as_bool) {
352            cfg.record.record_binary = v;
353        }
354        if let Some(v) = record.get("ignore").and_then(toml::Value::as_array) {
355            cfg.record.ignore = v
356                .iter()
357                .filter_map(toml::Value::as_str)
358                .map(String::from)
359                .collect();
360        }
361    }
362    if let Some(storage) = table.get("storage").and_then(toml::Value::as_table) {
363        if let Some(v) = storage.get("data_dir").and_then(toml::Value::as_str) {
364            cfg.storage.data_dir = PathBuf::from(v);
365        }
366    }
367    if let Some(replay) = table.get("replay").and_then(toml::Value::as_table) {
368        if let Some(v) = replay.get("theme").and_then(toml::Value::as_str) {
369            cfg.replay.theme = match v {
370                "auto" => Theme::Auto,
371                "dark" => Theme::Dark,
372                "light" => Theme::Light,
373                other => {
374                    return Err(ConfigError::Value(format!(
375                        "replay.theme `{other}` not one of auto|dark|light"
376                    ))
377                    .into())
378                }
379            };
380        }
381    }
382    if let Some(redaction) = table.get("redaction").and_then(toml::Value::as_table) {
383        if let Some(v) = redaction.get("at_record").and_then(toml::Value::as_bool) {
384            cfg.redaction.at_record = v;
385        }
386        if let Some(v) = redaction.get("entropy").and_then(toml::Value::as_bool) {
387            cfg.redaction.entropy = v;
388        }
389        if let Some(v) = redaction.get("rules") {
390            cfg.redaction.rules = parse_redaction_rules(v)?;
391        }
392    }
393    Ok(())
394}
395
396/// Parse `[redaction] rules` — an array of `{ name = "...", pattern = "..." }`
397/// tables. A malformed entry is an actionable error (silently dropping a
398/// user's detector would be a redaction hole, the opposite of "warn, never
399/// fail" — the *key* is known, its *value* is invalid).
400fn parse_redaction_rules(v: &toml::Value) -> Result<Vec<RedactionRule>> {
401    let Some(arr) = v.as_array() else {
402        return Err(ConfigError::Value(
403            "redaction.rules must be an array of { name, pattern } tables, e.g. \
404             rules = [{ name = \"acme\", pattern = \"ACME-[0-9A-F]{16}\" }]"
405                .into(),
406        )
407        .into());
408    };
409    let mut rules = Vec::with_capacity(arr.len());
410    for (i, entry) in arr.iter().enumerate() {
411        let table = entry.as_table().ok_or_else(|| {
412            ConfigError::Value(format!(
413                "redaction.rules[{i}] must be a table with string `name` and `pattern` keys"
414            ))
415        })?;
416        let name = table
417            .get("name")
418            .and_then(toml::Value::as_str)
419            .ok_or_else(|| {
420                ConfigError::Value(format!("redaction.rules[{i}] is missing a string `name`"))
421            })?;
422        let pattern = table
423            .get("pattern")
424            .and_then(toml::Value::as_str)
425            .ok_or_else(|| {
426                ConfigError::Value(format!(
427                    "redaction.rules[{i}] (`{name}`) is missing a string `pattern`"
428                ))
429            })?;
430        rules.push(RedactionRule {
431            name: name.to_string(),
432            pattern: pattern.to_string(),
433        });
434    }
435    Ok(rules)
436}
437
438fn value_to_bytes(v: &toml::Value) -> Result<u64> {
439    match v {
440        toml::Value::Integer(n) => u64::try_from(*n).map_err(|_| {
441            ConfigError::Value(format!("max_file_size cannot be negative: {n}")).into()
442        }),
443        toml::Value::String(s) => Ok(parse_bytes(s).map_err(ConfigError::Value)?),
444        other => Err(ConfigError::Value(format!(
445            "max_file_size must be a string or integer, got {}",
446            other.type_str()
447        ))
448        .into()),
449    }
450}
451
452/// Resolved on-disk locations for the Halfhand data directory.
453///
454/// `#[non_exhaustive]`: already only ever built via [`Paths::resolve`] /
455/// [`Paths::with_data_dir`], never a struct literal; this just makes that the
456/// enforced contract so a future resolved path is additive.
457#[derive(Debug, Clone, PartialEq, Eq)]
458#[non_exhaustive]
459pub struct Paths {
460    /// The data directory itself (`$XDG_DATA_HOME/halfhand` by default).
461    pub data_dir: PathBuf,
462    /// The config file (`$XDG_CONFIG_HOME/halfhand/config.toml`).
463    pub config_path: PathBuf,
464    /// The SQLite database file (`<data_dir>/hh.db`).
465    pub db_path: PathBuf,
466    /// The blob store root (`<data_dir>/blobs`).
467    pub blobs_dir: PathBuf,
468}
469
470impl Paths {
471    /// Resolve paths from a loaded [`Config`] and the process environment.
472    ///
473    /// Precedence (SRS §2.3): `HH_DATA_DIR` env > `[storage] data_dir` file >
474    /// platform default. The config file location is always the platform
475    /// default (not overridable via config, only via `XDG_CONFIG_HOME`).
476    pub fn resolve(config: &Config) -> Result<Self> {
477        let env_dir = std::env::var_os("HH_DATA_DIR").filter(|s| !s.is_empty());
478        let data_dir = if let Some(d) = env_dir {
479            PathBuf::from(d)
480        } else if !config.storage.data_dir.as_os_str().is_empty() {
481            config.storage.data_dir.clone()
482        } else {
483            platform_data_dir()?
484        };
485        Ok(Self {
486            db_path: data_dir.join("hh.db"),
487            blobs_dir: data_dir.join("blobs"),
488            config_path: platform_config_path()?,
489            data_dir,
490        })
491    }
492
493    /// Construct paths with an explicit data directory (used by tests so they
494    /// never touch the real data directory, per CLAUDE.md testing standards).
495    pub fn with_data_dir(data_dir: PathBuf) -> Self {
496        Self {
497            db_path: data_dir.join("hh.db"),
498            blobs_dir: data_dir.join("blobs"),
499            config_path: data_dir.join("config.toml"),
500            data_dir,
501        }
502    }
503}
504
505fn platform_dirs() -> Result<directories::ProjectDirs> {
506    directories::ProjectDirs::from("", "", "halfhand").ok_or_else(|| {
507        ConfigError::Value("cannot determine platform config/data directories (no HOME?)".into())
508            .into()
509    })
510}
511
512fn platform_data_dir() -> Result<PathBuf> {
513    Ok(platform_dirs()?.data_dir().to_path_buf())
514}
515
516fn platform_config_path() -> Result<PathBuf> {
517    Ok(platform_dirs()?.config_dir().join("config.toml"))
518}
519
520impl fmt::Display for Theme {
521    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522        let s = match self {
523            Theme::Auto => "auto",
524            Theme::Dark => "dark",
525            Theme::Light => "light",
526        };
527        f.write_str(s)
528    }
529}
530
531/// Fuzz-only entry point into the config.toml parser (`cargo fuzz` target
532/// `config_toml`). Gated behind the `fuzzing` feature so it never widens the
533/// crate's normal public API.
534#[cfg(feature = "fuzzing")]
535pub mod fuzzing {
536    use super::{merge_table, warn_unknown_keys, Config};
537
538    /// Mirrors [`Config::load`]'s parse+merge logic on arbitrary TOML text
539    /// (skipping the file-read step, which is plain `std::fs::read_to_string`
540    /// with no parsing of its own). Must never panic — only ever `Ok`/`Err`.
541    pub fn fuzz_parse(s: &str) {
542        let Ok(table) = toml::from_str::<toml::Table>(s) else {
543            return;
544        };
545        warn_unknown_keys(&table);
546        let mut cfg = Config::default();
547        let _ = merge_table(&mut cfg, &table);
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use std::fs;
555    use std::io::Write;
556    use tempfile::TempDir;
557
558    fn write_config(dir: &Path, body: &str) -> PathBuf {
559        let p = dir.join("config.toml");
560        let mut f = fs::File::create(&p).unwrap();
561        f.write_all(body.as_bytes()).unwrap();
562        p
563    }
564
565    #[test]
566    fn parse_bytes_accepts_suffixes() {
567        assert_eq!(parse_bytes("4MiB").unwrap(), 4 * 1024 * 1024);
568        assert_eq!(parse_bytes("512KiB").unwrap(), 512 * 1024);
569        assert_eq!(parse_bytes("100B").unwrap(), 100);
570        assert_eq!(parse_bytes("2048").unwrap(), 2048);
571        assert!(parse_bytes("nope").is_err());
572        assert!(parse_bytes("4PiB").is_err());
573    }
574
575    #[test]
576    fn config_defaults() {
577        let cfg = Config::default();
578        assert_eq!(cfg.record.max_file_size, 4 * 1024 * 1024);
579        assert!(!cfg.record.record_input);
580        assert!(!cfg.record.record_binary);
581        assert!(cfg.record.ignore.is_empty());
582        assert_eq!(cfg.replay.theme, Theme::Auto);
583        assert!(cfg.storage.data_dir.as_os_str().is_empty());
584    }
585
586    #[test]
587    fn config_loads_known_keys() {
588        let tmp = TempDir::new().unwrap();
589        let path = write_config(
590            tmp.path(),
591            "\
592[record]
593max_file_size = \"1MiB\"
594record_input = true
595ignore = [\"dist/\", \"*.lock\"]
596
597[storage]
598data_dir = \"/tmp/hh-from-file\"
599
600[replay]
601theme = \"dark\"
602",
603        );
604        let cfg = Config::load(&path).unwrap();
605        assert_eq!(cfg.record.max_file_size, 1024 * 1024);
606        assert!(cfg.record.record_input);
607        assert_eq!(cfg.record.ignore, vec!["dist/", "*.lock"]);
608        assert_eq!(cfg.storage.data_dir, PathBuf::from("/tmp/hh-from-file"));
609        assert_eq!(cfg.replay.theme, Theme::Dark);
610    }
611
612    #[test]
613    fn config_unknown_keys_warn_but_load() {
614        let tmp = TempDir::new().unwrap();
615        let path = write_config(
616            tmp.path(),
617            "\
618[record]
619max_file_size = \"2MiB\"
620mystery = true
621
622[storage]
623data_dir = \"/tmp/hh-unknown\"
624
625[experimental]
626feature = \"x\"
627",
628        );
629        let cfg = Config::load(&path).unwrap();
630        // Known keys still applied.
631        assert_eq!(cfg.record.max_file_size, 2 * 1024 * 1024);
632        assert_eq!(cfg.storage.data_dir, PathBuf::from("/tmp/hh-unknown"));
633        // Unknown keys were tolerated (no panic, no error).
634    }
635
636    #[test]
637    fn config_loads_redaction_section() {
638        let tmp = TempDir::new().unwrap();
639        let path = write_config(
640            tmp.path(),
641            "\
642[redaction]
643at_record = true
644entropy = false
645rules = [{ name = \"acme\", pattern = \"ACME-[0-9A-F]{16}\" }]
646",
647        );
648        let cfg = Config::load(&path).unwrap();
649        assert!(cfg.redaction.at_record);
650        assert!(!cfg.redaction.entropy);
651        assert_eq!(
652            cfg.redaction.rules,
653            vec![RedactionRule {
654                name: "acme".into(),
655                pattern: "ACME-[0-9A-F]{16}".into(),
656            }]
657        );
658        // Defaults: off, entropy on, no rules.
659        let d = RedactionConfig::default();
660        assert!(!d.at_record);
661        assert!(d.entropy);
662        assert!(d.rules.is_empty());
663    }
664
665    #[test]
666    fn config_malformed_redaction_rules_error_actionably() {
667        let tmp = TempDir::new().unwrap();
668        // A rules entry missing `pattern` must be an error (a silently dropped
669        // detector is a redaction hole), naming the rule.
670        let path = write_config(tmp.path(), "[redaction]\nrules = [{ name = \"acme\" }]\n");
671        let err = Config::load(&path).unwrap_err().to_string();
672        assert!(err.contains("acme"), "must name the rule: {err}");
673        // Non-array rules value.
674        let path2 = write_config(tmp.path(), "[redaction]\nrules = \"nope\"\n");
675        let err2 = Config::load(&path2).unwrap_err().to_string();
676        assert!(err2.contains("array"), "must explain the shape: {err2}");
677    }
678
679    #[test]
680    fn config_missing_file_is_default() {
681        let tmp = TempDir::new().unwrap();
682        let path = tmp.path().join("does-not-exist.toml");
683        let cfg = Config::load(&path).unwrap();
684        assert_eq!(cfg, Config::default());
685    }
686
687    #[test]
688    fn config_malformed_toml_errors() {
689        let tmp = TempDir::new().unwrap();
690        let path = write_config(tmp.path(), "this is = = not toml");
691        assert!(Config::load(&path).is_err());
692    }
693
694    #[test]
695    fn paths_precedence_default_file_env() {
696        // default: no file override, no env.
697        std::env::remove_var("HH_DATA_DIR");
698        let cfg = Config::default();
699        let default_paths = Paths::resolve(&cfg).unwrap();
700        assert!(default_paths.data_dir.ends_with("halfhand"));
701
702        // file: storage.data_dir set in config.
703        let cfg_file = Config {
704            storage: StorageConfig {
705                data_dir: PathBuf::from("/tmp/hh-file-wins"),
706            },
707            ..Config::default()
708        };
709        let file_paths = Paths::resolve(&cfg_file).unwrap();
710        assert_eq!(file_paths.data_dir, PathBuf::from("/tmp/hh-file-wins"));
711
712        // env overrides file.
713        std::env::set_var("HH_DATA_DIR", "/tmp/hh-env-wins");
714        let env_paths = Paths::resolve(&cfg_file).unwrap();
715        assert_eq!(env_paths.data_dir, PathBuf::from("/tmp/hh-env-wins"));
716        std::env::remove_var("HH_DATA_DIR");
717    }
718
719    #[test]
720    fn paths_components_are_under_data_dir() {
721        let p = Paths::with_data_dir(PathBuf::from("/tmp/hh-test"));
722        assert_eq!(p.db_path, PathBuf::from("/tmp/hh-test/hh.db"));
723        assert_eq!(p.blobs_dir, PathBuf::from("/tmp/hh-test/blobs"));
724    }
725
726    #[test]
727    fn warn_on_ignored_halfhand_toml() {
728        // When the canonical config.toml is present, a sibling halfhand.toml is
729        // genuinely ignored → reported as a non-canonical file.
730        let tmp = TempDir::new().unwrap();
731        let canonical = tmp.path().join("config.toml");
732        std::fs::write(&canonical, "[record]\nignore = [\"canonical\"]\n").unwrap();
733        std::fs::write(
734            tmp.path().join("halfhand.toml"),
735            "[record]\nignore = [\"x\"]\n",
736        )
737        .unwrap();
738        let ignored = ignored_noncanonical_config_files(&canonical);
739        assert_eq!(ignored, vec![tmp.path().join("halfhand.toml")]);
740        // No panic; the warning goes to stderr (not asserted here — behavior is
741        // covered by an integration assertion on captured stderr elsewhere).
742        warn_on_ignored_config_files(&canonical);
743
744        // When the canonical config.toml is ABSENT, halfhand.toml is loaded as a
745        // fallback (see Config::load) — it is NOT ignored, so neither function
746        // reports it.
747        let tmp2 = TempDir::new().unwrap();
748        let canonical_absent = tmp2.path().join("config.toml");
749        std::fs::write(
750            tmp2.path().join("halfhand.toml"),
751            "[record]\nignore = [\"y\"]\n",
752        )
753        .unwrap();
754        assert!(!canonical_absent.exists());
755        assert!(ignored_noncanonical_config_files(&canonical_absent).is_empty());
756        warn_on_ignored_config_files(&canonical_absent);
757
758        // Missing canonical parent dir: still no panic.
759        warn_on_ignored_config_files(Path::new("/no/such/dir/config.toml"));
760    }
761
762    #[test]
763    fn config_load_falls_back_to_halfhand_toml() {
764        // config.toml absent + halfhand.toml present → halfhand.toml is loaded
765        // (not ignored). The [storage] data_dir value takes effect.
766        let tmp = TempDir::new().unwrap();
767        let canonical = tmp.path().join("config.toml");
768        std::fs::write(
769            tmp.path().join("halfhand.toml"),
770            "[storage]\ndata_dir = \"/tmp/hh-from-legacy\"\n",
771        )
772        .unwrap();
773        assert!(!canonical.exists());
774        let cfg = Config::load(&canonical).unwrap();
775        assert_eq!(cfg.storage.data_dir, PathBuf::from("/tmp/hh-from-legacy"));
776    }
777
778    #[test]
779    fn config_load_canonical_wins_over_halfhand_toml() {
780        // Both present → canonical config.toml is read; halfhand.toml ignored.
781        let tmp = TempDir::new().unwrap();
782        let canonical = write_config(tmp.path(), "[storage]\ndata_dir = \"/tmp/hh-canonical\"\n");
783        std::fs::write(
784            tmp.path().join("halfhand.toml"),
785            "[storage]\ndata_dir = \"/tmp/hh-legacy\"\n",
786        )
787        .unwrap();
788        let cfg = Config::load(&canonical).unwrap();
789        assert_eq!(cfg.storage.data_dir, PathBuf::from("/tmp/hh-canonical"));
790    }
791}