pub mod keys;
pub mod theme;
use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use etcetera::{BaseStrategy, choose_base_strategy};
use serde::{Deserialize, Serialize};
use crate::tree::scan::SortOrder;
use keys::Keymap;
use theme::Theme;
const DEFAULT_CONFIG: &str = include_str!("default_config.toml");
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub editor: String,
pub jump_lines: usize,
pub scrolloff: usize,
pub icons: Icons,
pub sort_order: SortOrder,
pub show_diffstat: bool,
pub indent: usize,
pub watch: bool,
pub toggles: Toggles,
pub colors: Theme,
pub keys: Keymap,
}
impl Default for Config {
fn default() -> Self {
Self {
editor: String::new(),
jump_lines: 10,
scrolloff: 10,
icons: Icons::default(),
sort_order: SortOrder::default(),
show_diffstat: true,
indent: 2,
watch: true,
toggles: Toggles::default(),
colors: Theme::default(),
keys: Keymap::default(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Icons {
#[default]
Nerdfont,
Unicode,
None,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Toggles {
pub show_hidden: bool,
pub show_gitignored: bool,
pub changed_only: bool,
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path()?;
if !path.exists() {
write_default(&path).with_context(|| format!("failed to create {}", path.display()))?;
}
let text = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
}
}
fn write_default(path: &PathBuf) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, DEFAULT_CONFIG)?;
Ok(())
}
pub fn state_dir() -> Result<PathBuf> {
let strategy = choose_base_strategy().context("cannot locate the cache directory")?;
Ok(strategy.cache_dir().join("bettertree/state"))
}
fn config_path() -> Result<PathBuf> {
let strategy = choose_base_strategy().context("cannot locate the config directory")?;
Ok(strategy.config_dir().join("bettertree/config.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::Command;
use crossterm::event::KeyEvent;
use keys::KeyBinding;
#[test]
fn the_shipped_default_config_parses() {
let config: Config = toml::from_str(DEFAULT_CONFIG).expect("default config is valid");
assert_eq!(config.jump_lines, 10);
assert_eq!(config.icons, Icons::Nerdfont);
}
#[test]
fn the_shipped_default_config_matches_the_hardcoded_defaults() {
let shipped: Config = toml::from_str(DEFAULT_CONFIG).expect("default config is valid");
let fallback = Config::default();
assert_eq!(shipped.editor, fallback.editor);
assert_eq!(shipped.scrolloff, fallback.scrolloff);
assert_eq!(shipped.sort_order, fallback.sort_order);
assert_eq!(shipped.show_diffstat, fallback.show_diffstat);
assert_eq!(shipped.indent, fallback.indent);
assert_eq!(shipped.watch, fallback.watch);
let roles: [(&str, theme::Paint, theme::Paint); 5] = [
("file", shipped.colors.file, fallback.colors.file),
(
"file_ignored",
shipped.colors.file_ignored,
fallback.colors.file_ignored,
),
("dir", shipped.colors.dir, fallback.colors.dir),
(
"ui_selection_bg",
shipped.colors.ui_selection_bg,
fallback.colors.ui_selection_bg,
),
(
"ui_status_bar_bg",
shipped.colors.ui_status_bar_bg,
fallback.colors.ui_status_bar_bg,
),
];
for (role, shipped, fallback) in roles {
assert_eq!(shipped.0, fallback.0, "{role} drifted from the default");
}
}
#[test]
fn the_default_theme_uses_ansi_names_so_it_follows_the_terminal() {
let config: Config = toml::from_str(DEFAULT_CONFIG).expect("default config is valid");
assert_eq!(config.colors.dir.0, ratatui::style::Color::Blue);
assert_eq!(config.colors.file_modified.0, ratatui::style::Color::Yellow);
assert_eq!(config.colors.file.0, ratatui::style::Color::Reset);
for line in DEFAULT_CONFIG.lines() {
assert!(
!line.contains(" = \"#"),
"the shipped theme should not pin hex colours: {line}"
);
}
}
#[test]
fn every_default_binding_resolves_to_a_command() {
let config: Config = toml::from_str(DEFAULT_CONFIG).expect("default config is valid");
let event = KeyEvent::from(crossterm::event::KeyCode::Char('q'));
assert_eq!(config.keys.get(event), Some(Command::Quit));
}
#[test]
fn commented_out_bindings_are_valid_too() {
for line in DEFAULT_CONFIG.lines() {
let Some(binding) = line.strip_prefix("# \"") else {
continue;
};
let Some((spec, rest)) = binding.split_once("\" = \"") else {
continue;
};
let name = rest.trim_end_matches('"');
assert!(KeyBinding::parse(spec).is_ok(), "bad key: {spec}");
assert!(Command::from_name(name).is_some(), "bad command: {name}");
}
}
#[test]
fn unknown_settings_are_rejected() {
let error = toml::from_str::<Config>("nonsense = true").expect_err("should fail");
assert!(error.to_string().contains("nonsense"), "{error}");
}
}