use std::{
collections::BTreeMap,
io::{self, Write as _},
path::{Path, PathBuf},
};
use anyhow::Context;
use serde::{Deserialize, Deserializer};
use crate::theme::{resolve_theme_selection, CustomThemeConfig};
const DEFAULT_CONFIG: &str = include_str!("../config.toml");
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub(crate) struct LeafConfig {
pub(crate) theme: Option<String>,
pub(crate) editor: Option<String>,
pub(crate) watch: Option<bool>,
pub(crate) width: Option<usize>,
pub(crate) extras: Vec<String>,
#[serde(rename = "code-line-numbers")]
pub(crate) code_line_numbers: Option<bool>,
#[serde(
rename = "tab-title-length",
deserialize_with = "deserialize_lenient_i32"
)]
pub(crate) tab_title_length: Option<i32>,
pub(crate) themes: BTreeMap<String, CustomThemeConfig>,
#[serde(skip)]
pub(crate) config_dir: Option<PathBuf>,
}
fn deserialize_lenient_i32<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
where
D: Deserializer<'de>,
{
let value = toml::Value::deserialize(deserializer)?;
Ok(value.as_integer().and_then(|n| i32::try_from(n).ok()))
}
#[derive(Default)]
pub(crate) struct CliOverrides {
pub(crate) width: Option<usize>,
pub(crate) theme: Option<String>,
}
pub(crate) fn load_config(overrides: &CliOverrides) -> (LeafConfig, Option<String>) {
let Some(path) = config_path() else {
return (LeafConfig::default(), None);
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return (LeafConfig::default(), None),
};
let mut config = match toml::from_str::<LeafConfig>(&content) {
Ok(c) => c,
Err(_) => {
return (
LeafConfig::default(),
Some("Could not parse config.toml, using defaults".to_string()),
);
}
};
config.config_dir = path.parent().map(Path::to_path_buf);
let mut warnings: Vec<String> = Vec::new();
let leaf_theme_overrides = std::env::var("LEAF_THEME").is_ok_and(|s| !s.is_empty());
if overrides.theme.is_none() && !leaf_theme_overrides {
if let Some(ref name) = config.theme {
if let Err(message) =
resolve_theme_selection(name, &config.themes, config.config_dir.as_deref())
{
warnings.push(format!("{message} in config, using default"));
}
}
}
let leaf_width_overrides =
std::env::var("LEAF_WIDTH").is_ok_and(|v| v.parse::<usize>().is_ok_and(|w| w >= 20));
if overrides.width.is_none() && !leaf_width_overrides {
if let Some(w) = config.width.filter(|&w| w < 20) {
warnings.push(format!(
"width={w} in config is below minimum (20), will use 20"
));
}
}
let leaf_tab_title_env_overrides = std::env::var("LEAF_TAB_TITLE_LENGTH")
.is_ok_and(|v| v.parse::<i32>().is_ok_and(crate::is_valid_tab_title_length));
if !leaf_tab_title_env_overrides {
if let Some(n) = config.tab_title_length {
if (0..20).contains(&n) {
warnings.push("tab-title-length is invalid, no truncation applied".to_string());
}
}
}
let warning = if warnings.is_empty() {
None
} else {
Some(warnings.join("; "))
};
(config, warning)
}
pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.ok()
.map(|dir| PathBuf::from(dir).join("leaf").join("config.toml"))
}
#[cfg(not(target_os = "windows"))]
{
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.config")))?;
Some(PathBuf::from(base).join("leaf").join("config.toml"))
}
}
pub(crate) fn run_config() -> anyhow::Result<()> {
let path = config_path().context("Cannot determine config directory")?;
if !path.exists() {
println!("Creating default config.toml...");
write_default_config(&path)?;
}
println!("Configuration file: {}", path.display());
open_config_in_editor(&path)
}
pub(crate) fn reset_config() -> anyhow::Result<()> {
let path = config_path().context("Cannot determine config directory")?;
let (old_config, _) = load_config(&CliOverrides::default());
let editor = crate::editor::resolve_editor(None, old_config.editor.as_deref());
print!("Reset configuration to defaults? (y/N): ");
io::stdout().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
if !answer.trim().eq_ignore_ascii_case("y") {
println!("Reset cancelled.");
return Ok(());
}
write_default_config(&path)?;
println!("Configuration reset: {}", path.display());
launch_editor(&editor, &path);
Ok(())
}
fn write_default_config(dest: &Path) -> anyhow::Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Cannot create config directory: {}", parent.display()))?;
}
std::fs::write(dest, DEFAULT_CONFIG)
.with_context(|| format!("Cannot write config file: {}", dest.display()))
}
fn open_config_in_editor(path: &Path) -> anyhow::Result<()> {
let (config, _) = load_config(&CliOverrides::default());
let editor = crate::editor::resolve_editor(None, config.editor.as_deref());
launch_editor(&editor, path);
Ok(())
}
fn launch_editor(editor: &str, path: &Path) {
let expanded = crate::editor::expand_editor_placeholders(editor, 1, path);
if try_launch_editor(&expanded, path) {
return;
}
if let Some(fallback) = crate::editor::resolve_fallback_editor(&expanded) {
try_launch_editor(fallback, path);
}
}
fn try_launch_editor(editor: &str, path: &Path) -> bool {
let (bin, args) = crate::editor::split_editor_cmd(editor);
std::process::Command::new(bin)
.args(args)
.arg(path)
.status()
.map(|s| s.success())
.unwrap_or(false)
}