use std::path::{Path, PathBuf};
use crate::app::{TempUnit, ViewMode, VisibleColumns, VIEW_MODE_NAMES};
use crate::tabs::{TabId, TAB_NAMES};
use crate::ui;
pub const KNOWN_KEYS: &[&str] = &[
"theme",
"graph",
"graph_fade",
"view",
"tab",
"smart_interval_secs",
"refresh_interval_ms",
"temp_unit",
"columns",
"watch_paths",
"extra_watch_paths",
];
const COLUMN_NAMES: &[(&str, u8)] = &[
("size", VisibleColumns::SIZE),
("free", VisibleColumns::FREE),
("used_pct", VisibleColumns::USED_PCT),
("temp", VisibleColumns::TEMP),
("smart", VisibleColumns::SMART),
];
#[derive(Debug, Default, Clone)]
pub struct Config {
pub theme: Option<String>,
pub graph: Option<String>,
pub graph_fade: Option<bool>,
pub view: Option<ViewMode>,
pub tab: Option<TabId>,
pub smart_interval_secs: Option<u64>,
pub refresh_interval_ms: Option<u64>,
pub temp_unit: Option<TempUnit>,
pub columns: Option<VisibleColumns>,
pub watch_paths: Option<Vec<PathBuf>>,
pub extra_watch_paths: Vec<PathBuf>,
pub source: Option<PathBuf>,
pub warnings: Vec<String>,
}
impl Config {
pub fn load() -> Self {
match config_path() {
Some(path) => Self::load_from(&path),
None => Config::default(),
}
}
pub fn load_from(path: &Path) -> Self {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let mut cfg = Config::default();
if std::env::var_os("DISKWATCH_CONFIG").is_some() {
cfg.warnings
.push(format!("config file not found: {}", path.display()));
}
return cfg;
}
Err(e) => {
let mut cfg = Config::default();
cfg.warnings
.push(format!("cannot read {}: {}", path.display(), e));
return cfg;
}
};
let mut cfg = Self::parse(&text);
cfg.source = Some(path.to_path_buf());
cfg
}
pub fn parse(text: &str) -> Self {
let mut cfg = Config::default();
for (lineno, raw) in text.lines().enumerate() {
let line = strip_comment(raw).trim();
if line.is_empty() {
continue;
}
let n = lineno + 1;
if line.starts_with('[') {
cfg.warn(n, "tables are not supported; write keys at the top level");
continue;
}
let Some((key, value)) = line.split_once('=') else {
cfg.warn(n, "expected `key = value`");
continue;
};
let key = key.trim();
let value = value.trim();
if value.is_empty() {
cfg.warn(n, &format!("`{key}` has no value"));
continue;
}
cfg.apply(n, key, value);
}
cfg
}
fn warn(&mut self, line: usize, msg: &str) {
self.warnings.push(format!("line {line}: {msg}"));
}
fn apply(&mut self, line: usize, key: &str, value: &str) {
match key {
"theme" => match parse_string(value).map(|s| validate_theme(&s)) {
Some(Ok(v)) => self.theme = Some(v),
Some(Err(e)) => self.warn(line, &e),
None => self.warn(line, &format!("`{key}` expects a quoted string")),
},
"graph" => match parse_string(value).map(|s| validate_graph(&s)) {
Some(Ok(v)) => self.graph = Some(v),
Some(Err(e)) => self.warn(line, &e),
None => self.warn(line, &format!("`{key}` expects a quoted string")),
},
"view" => match parse_string(value).map(|s| validate_view(&s)) {
Some(Ok(v)) => self.view = ViewMode::from_name(&v),
Some(Err(e)) => self.warn(line, &e),
None => self.warn(line, &format!("`{key}` expects a quoted string")),
},
"tab" => match parse_string(value).map(|s| validate_tab(&s)) {
Some(Ok(v)) => self.tab = TabId::from_str(&v),
Some(Err(e)) => self.warn(line, &e),
None => self.warn(line, &format!("`{key}` expects a quoted string")),
},
"graph_fade" => match parse_bool(value) {
Some(b) => self.graph_fade = Some(b),
None => self.warn(line, &format!("`{key}` expects true or false")),
},
"smart_interval_secs" => match parse_int(value) {
Some(n) if n >= crate::app::MIN_SMART_INTERVAL_SECS => {
self.smart_interval_secs = Some(n)
}
Some(n) => self.warn(
line,
&format!(
"`{key}` = {n} is below the {}s floor; polling faster than that \
issues more than one smartctl per second per disk",
crate::app::MIN_SMART_INTERVAL_SECS
),
),
None => self.warn(line, &format!("`{key}` expects a positive integer")),
},
"refresh_interval_ms" => match parse_int(value) {
Some(n) if n >= crate::app::MIN_REFRESH_INTERVAL_MS => {
self.refresh_interval_ms = Some(n)
}
Some(n) => self.warn(
line,
&format!(
"`{key}` = {n} is below the {}ms floor; refreshing faster than \
that re-polls sysinfo for no benefit",
crate::app::MIN_REFRESH_INTERVAL_MS
),
),
None => self.warn(line, &format!("`{key}` expects a positive integer")),
},
"temp_unit" => match parse_string(value).as_deref().map(str::to_ascii_lowercase) {
Some(v) => match v.as_str() {
"c" | "celsius" => self.temp_unit = Some(TempUnit::Celsius),
"f" | "fahrenheit" => self.temp_unit = Some(TempUnit::Fahrenheit),
other => self.warn(
line,
&format!("unknown temp_unit {other:?} (expected celsius or fahrenheit)"),
),
},
None => self.warn(line, &format!("`{key}` expects a quoted string")),
},
"columns" => match parse_array(value) {
Some(items) => {
let mut bits = 0u8;
for item in items {
let want = item.to_ascii_lowercase();
match COLUMN_NAMES.iter().find(|(n, _)| *n == want) {
Some((_, bit)) => bits |= bit,
None => self.warn(
line,
&format!(
"unknown column {item:?} (available: {})",
COLUMN_NAMES
.iter()
.map(|(n, _)| *n)
.collect::<Vec<_>>()
.join(", ")
),
),
}
}
self.columns = Some(VisibleColumns(bits));
}
None => self.warn(line, &format!("`{key}` expects an array of strings")),
},
"watch_paths" => match parse_array(value) {
Some(items) => self.watch_paths = Some(items.iter().map(expand_path).collect()),
None => self.warn(line, &format!("`{key}` expects an array of strings")),
},
"extra_watch_paths" => match parse_array(value) {
Some(items) => self.extra_watch_paths = items.iter().map(expand_path).collect(),
None => self.warn(line, &format!("`{key}` expects an array of strings")),
},
other => self.warn(
line,
&format!(
"unknown key {other:?} (known keys: {})",
KNOWN_KEYS.join(", ")
),
),
}
}
}
pub fn config_path() -> Option<PathBuf> {
if let Some(explicit) = std::env::var_os("DISKWATCH_CONFIG") {
return Some(PathBuf::from(explicit));
}
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("diskwatch").join("config.toml"))
}
pub fn default_file_contents() -> String {
format!(
"# diskwatch config — every key is optional, and every value below is\n\
# the built-in default. A CLI flag overrides anything written here.\n\
#\n\
# Format: flat `key = value`, `#` comments, no tables.\n\
\n\
# Color theme: {}.\n\
# \"terminal\" resolves every color through the palette your terminal\n\
# already defines, so a system-wide theme carries straight over.\n\
theme = \"{}\"\n\
\n\
# Chart style: {}.\n\
graph = \"{}\"\n\
\n\
# btop's gradient. Needs a theme with real RGB to fade through, so it\n\
# does nothing under theme = \"terminal\".\n\
graph_fade = false\n\
\n\
# View to open in: {}.\n\
view = \"full\"\n\
\n\
# Tab to open on: {}.\n\
tab = \"overview\"\n\
\n\
# SMART poll cadence, in seconds. Floor is {}s — below that we issue\n\
# more than one smartctl subprocess per second per disk.\n\
smart_interval_secs = {}\n\
\n\
# How often on-screen usage figures (device %, filesystem/volume\n\
# tables, capacity trends) refresh, in milliseconds. Floor is {}ms.\n\
# Also reachable from the settings overlay (,) as \"Refresh speed\".\n\
# Doesn't affect the io throughput graphs, which sample at their\n\
# own fixed rate regardless.\n\
refresh_interval_ms = {}\n\
\n\
# Temperature display unit: celsius or fahrenheit. Drive firmware\n\
# always reports Celsius; this governs the display layer only.\n\
temp_unit = \"celsius\"\n\
\n\
# Columns shown in the Overview tab's DEVICES table, in table order:\n\
# {}.\n\
columns = [{}]\n\
\n\
# Paths the Hot Files tab watches, replacing the defaults ($HOME plus\n\
# the OS log and tmp directories). A leading ~ expands to $HOME.\n\
# Each entry is watched recursively, so on Linux a large tree can\n\
# exhaust fs.inotify.max_user_watches — diskwatch says so if it does.\n\
# watch_paths = [\"~/src\", \"/var/log\"]\n\
\n\
# Paths added to whatever the roots already are, rather than\n\
# replacing them. Use this to keep the defaults and add one more.\n\
# extra_watch_paths = [\"/srv/data\"]\n",
ui::theme::THEME_NAMES.join(", "),
ui::theme::DEFAULT_THEME,
ui::graph::GRAPH_STYLE_NAMES.join(", "),
"bars",
VIEW_MODE_NAMES.join(", "),
TAB_NAMES.join(", "),
crate::app::MIN_SMART_INTERVAL_SECS,
crate::app::DEFAULT_SMART_INTERVAL_SECS,
crate::app::MIN_REFRESH_INTERVAL_MS,
crate::app::DEFAULT_REFRESH_INTERVAL_MS,
COLUMN_NAMES
.iter()
.map(|(n, _)| *n)
.collect::<Vec<_>>()
.join(", "),
COLUMN_NAMES
.iter()
.map(|(n, _)| format!("\"{n}\""))
.collect::<Vec<_>>()
.join(", "),
)
}
fn strip_comment(line: &str) -> &str {
let mut in_string = false;
let mut escaped = false;
for (i, c) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if in_string => escaped = true,
'"' => in_string = !in_string,
'#' if !in_string => return &line[..i],
_ => {}
}
}
line
}
fn parse_string(raw: &str) -> Option<String> {
let inner = raw.strip_prefix('"')?.strip_suffix('"')?;
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some(other) => out.push(other),
None => return None,
}
} else if c == '"' {
return None;
} else {
out.push(c);
}
}
Some(out)
}
fn parse_bool(raw: &str) -> Option<bool> {
match raw {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
fn parse_int(raw: &str) -> Option<u64> {
raw.parse::<u64>().ok()
}
fn parse_array(raw: &str) -> Option<Vec<String>> {
let inner = raw.strip_prefix('[')?.strip_suffix(']')?.trim();
if inner.is_empty() {
return Some(Vec::new());
}
let mut out = Vec::new();
for part in split_top_level(inner) {
out.push(parse_string(part.trim())?);
}
Some(out)
}
fn split_top_level(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut in_string = false;
let mut escaped = false;
let mut start = 0;
for (i, c) in s.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if in_string => escaped = true,
'"' => in_string = !in_string,
',' if !in_string => {
parts.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&s[start..]);
parts
}
pub fn expand_path(raw: impl AsRef<str>) -> PathBuf {
let raw = raw.as_ref();
let Some(home) = std::env::var_os("HOME") else {
return PathBuf::from(raw);
};
let home = PathBuf::from(home);
if raw == "~" || raw == "$HOME" {
return home;
}
for prefix in ["~/", "$HOME/"] {
if let Some(rest) = raw.strip_prefix(prefix) {
return home.join(rest);
}
}
PathBuf::from(raw)
}
pub fn validate_theme(raw: &str) -> Result<String, String> {
if ui::theme::by_name(raw).name == "dark" && !raw.eq_ignore_ascii_case("dark") {
return Err(format!(
"unknown theme {raw:?} (available: {})",
ui::theme::THEME_NAMES.join(", ")
));
}
Ok(raw.to_string())
}
pub fn validate_graph(raw: &str) -> Result<String, String> {
if ui::graph::by_name(raw) == ui::graph::GraphStyle::Bars && !raw.eq_ignore_ascii_case("bars") {
return Err(format!(
"unknown graph style {raw:?} (available: {})",
ui::graph::GRAPH_STYLE_NAMES.join(", ")
));
}
Ok(raw.to_string())
}
pub fn validate_view(raw: &str) -> Result<String, String> {
ViewMode::from_name(raw)
.map(|_| raw.to_string())
.ok_or_else(|| {
format!(
"unknown view {raw:?} (available: {})",
VIEW_MODE_NAMES.join(", ")
)
})
}
pub fn validate_tab(raw: &str) -> Result<String, String> {
TabId::from_str(raw)
.map(|_| raw.to_string())
.ok_or_else(|| format!("unknown tab {raw:?} (available: {})", TAB_NAMES.join(", ")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_full_file_parses_into_every_setting() {
let cfg = Config::parse(
r#"
# a comment
theme = "nord"
graph = "dots"
graph_fade = true
view = "dense"
tab = "hot"
smart_interval_secs = 30
refresh_interval_ms = 2000
temp_unit = "fahrenheit"
columns = ["size", "temp"]
watch_paths = ["/var/log", "/srv"]
extra_watch_paths = ["/opt"]
"#,
);
assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
assert_eq!(cfg.theme.as_deref(), Some("nord"));
assert_eq!(cfg.graph.as_deref(), Some("dots"));
assert_eq!(cfg.graph_fade, Some(true));
assert_eq!(cfg.view, Some(ViewMode::Dense));
assert_eq!(cfg.tab, Some(TabId::Hot));
assert_eq!(cfg.smart_interval_secs, Some(30));
assert_eq!(cfg.refresh_interval_ms, Some(2000));
assert_eq!(cfg.temp_unit, Some(TempUnit::Fahrenheit));
assert_eq!(
cfg.columns,
Some(VisibleColumns(VisibleColumns::SIZE | VisibleColumns::TEMP))
);
assert_eq!(
cfg.watch_paths,
Some(vec![PathBuf::from("/var/log"), PathBuf::from("/srv")])
);
assert_eq!(cfg.extra_watch_paths, vec![PathBuf::from("/opt")]);
}
#[test]
fn a_bad_line_is_reported_and_the_rest_of_the_file_still_applies() {
let cfg = Config::parse(
r#"
theme = "not-a-theme"
graph = "dots"
nonsense = 1
smart_interval_secs = 1
"#,
);
assert_eq!(cfg.graph.as_deref(), Some("dots"), "good lines still apply");
assert_eq!(cfg.theme, None, "the bad value is not smuggled through");
assert_eq!(cfg.warnings.len(), 3, "{:?}", cfg.warnings);
for w in &cfg.warnings {
assert!(w.starts_with("line "), "unlocated warning: {w}");
}
assert!(
cfg.warnings.iter().any(|w| w.contains("floor")),
"{:?}",
cfg.warnings
);
assert_eq!(cfg.smart_interval_secs, None);
}
#[test]
fn an_unknown_key_lists_the_known_ones() {
let cfg = Config::parse("watchpaths = [\"/a\"]\n");
assert_eq!(cfg.warnings.len(), 1);
assert!(
cfg.warnings[0].contains("watch_paths"),
"the near-miss should be in the list: {}",
cfg.warnings[0]
);
}
#[test]
fn comments_and_strings_do_not_eat_each_other() {
assert_eq!(strip_comment("a = 1 # trailing"), "a = 1 ");
assert_eq!(
strip_comment(r#"watch_paths = ["/tmp/#scratch"] # real"#),
r#"watch_paths = ["/tmp/#scratch"] "#
);
let cfg = Config::parse(r#"watch_paths = ["/tmp/#scratch"] # real"#);
assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
assert_eq!(cfg.watch_paths, Some(vec![PathBuf::from("/tmp/#scratch")]));
}
#[test]
fn arrays_split_on_commas_outside_strings_only() {
assert_eq!(
parse_array(r#"["a,b", "c"]"#),
Some(vec!["a,b".to_string(), "c".to_string()])
);
assert_eq!(parse_array("[]"), Some(Vec::new()));
assert_eq!(parse_array("[a]"), None, "unquoted elements are rejected");
assert_eq!(parse_array(r#"["a",]"#), None, "trailing comma is rejected");
}
#[test]
fn an_empty_column_list_hides_every_column() {
let cfg = Config::parse("columns = []\n");
assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
assert_eq!(cfg.columns, Some(VisibleColumns(0)));
assert_eq!(Config::parse("").columns, None);
}
#[test]
fn tables_are_rejected_with_an_explanation_rather_than_ignored() {
let cfg = Config::parse("[general]\ntheme = \"nord\"\n");
assert_eq!(cfg.warnings.len(), 1);
assert!(cfg.warnings[0].contains("top level"), "{:?}", cfg.warnings);
assert_eq!(cfg.theme.as_deref(), Some("nord"));
}
#[test]
fn a_leading_tilde_expands_but_nothing_else_does() {
let home = std::env::var_os("HOME").map(PathBuf::from);
if let Some(home) = home {
assert_eq!(expand_path("~/src"), home.join("src"));
assert_eq!(expand_path("$HOME/src"), home.join("src"));
assert_eq!(expand_path("~"), home);
}
assert_eq!(expand_path("/a/~/b"), PathBuf::from("/a/~/b"));
assert_eq!(expand_path("$(whoami)"), PathBuf::from("$(whoami)"));
}
#[test]
fn unquoted_strings_are_rejected_so_the_file_stays_valid_toml() {
let cfg = Config::parse("theme = dark\n");
assert_eq!(cfg.theme, None);
assert_eq!(cfg.warnings.len(), 1);
assert!(cfg.warnings[0].contains("quoted"), "{:?}", cfg.warnings);
}
}