1use crate::error::{ConfigError, Result};
8use serde::Deserialize;
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12pub 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
81#[non_exhaustive]
82#[serde(rename_all = "lowercase")]
83pub enum Theme {
84 #[default]
86 Auto,
87 Dark,
89 Light,
91}
92
93#[derive(Debug, Clone, PartialEq)]
95#[non_exhaustive]
96pub struct RecordConfig {
97 pub max_file_size: u64,
99 pub record_input: bool,
101 pub record_binary: bool,
103 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#[derive(Debug, Clone, Default, PartialEq)]
120#[non_exhaustive]
121pub struct StorageConfig {
122 pub data_dir: PathBuf,
124}
125
126#[derive(Debug, Clone, PartialEq, Default)]
128#[non_exhaustive]
129pub struct ReplayConfig {
130 pub theme: Theme,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
139#[non_exhaustive]
140pub struct RedactionRule {
141 pub name: String,
143 pub pattern: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149#[non_exhaustive]
150pub struct RedactionConfig {
151 pub at_record: bool,
155 pub entropy: bool,
157 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#[derive(Debug, Clone, Default, PartialEq)]
173#[non_exhaustive]
174pub struct Config {
175 pub record: RecordConfig,
177 pub storage: StorageConfig,
179 pub replay: ReplayConfig,
181 pub redaction: RedactionConfig,
183}
184
185impl Config {
186 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
223const NONCANONICAL_CONFIG_NAMES: &[&str] = &["halfhand.toml", "hh.toml"];
232
233fn 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
246pub 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#[must_use]
273pub fn ignored_noncanonical_config_files(config_path: &Path) -> Vec<PathBuf> {
274 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
290fn 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
310const 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
321fn 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
342fn 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
396fn 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#[derive(Debug, Clone, PartialEq, Eq)]
458#[non_exhaustive]
459pub struct Paths {
460 pub data_dir: PathBuf,
462 pub config_path: PathBuf,
464 pub db_path: PathBuf,
466 pub blobs_dir: PathBuf,
468}
469
470impl Paths {
471 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 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#[cfg(feature = "fuzzing")]
535pub mod fuzzing {
536 use super::{merge_table, warn_unknown_keys, Config};
537
538 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 assert_eq!(cfg.record.max_file_size, 2 * 1024 * 1024);
632 assert_eq!(cfg.storage.data_dir, PathBuf::from("/tmp/hh-unknown"));
633 }
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 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 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 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 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 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 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 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 warn_on_ignored_config_files(&canonical);
743
744 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 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 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 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}