use std::path::Path;
use serde::{Deserialize, Serialize};
use ccalc_engine::eval::Base;
const DEFAULT_CONFIG: &str = r#"# ccalc configuration
# Edit this file and run 'config reload' in the REPL to apply changes.
# Search path for run() / source() — directories checked after the current working directory.
# Tilde (~) is expanded to the home directory.
# Trailing slash means the directory AND all its subdirectories are added (genpath semantics).
# On Windows use forward slashes or escaped backslashes:
# path = ["C:/Users/me/scripts", "D:/work/calc/"]
# path = ["~/.config/ccalc/lib/"]
[display]
# Default decimal precision (number of digits after the decimal point, 0–15).
precision = 10
# Default number base for output: "dec", "hex", "bin", "oct"
base = "dec"
"#;
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub display: DisplayConfig,
#[serde(default)]
pub path: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DisplayConfig {
pub precision: usize,
pub base: String,
}
impl Default for Config {
fn default() -> Self {
toml::from_str(DEFAULT_CONFIG).expect("DEFAULT_CONFIG is valid TOML")
}
}
impl Config {
pub fn precision(&self) -> usize {
self.display.precision.min(15)
}
pub fn base(&self) -> Base {
match self.display.base.as_str() {
"hex" => Base::Hex,
"bin" => Base::Bin,
"oct" => Base::Oct,
_ => Base::Dec,
}
}
pub fn search_path(&self) -> Vec<std::path::PathBuf> {
let mut result = Vec::new();
for s in &self.path {
let recursive = s.ends_with('/') || s.ends_with('\\');
let trimmed = if recursive {
s.trim_end_matches(['/', '\\'])
} else {
s.as_str()
};
let expanded = expand_tilde(trimmed);
let root = std::path::PathBuf::from(&expanded);
if recursive {
collect_dirs_recursive(&root, &mut result);
} else {
result.push(root);
}
}
result
}
}
fn collect_dirs_recursive(root: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
if !root.is_dir() {
return;
}
out.push(root.to_path_buf());
if let Ok(entries) = std::fs::read_dir(root) {
let mut children: Vec<std::path::PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.is_dir()
&& p.file_name().is_none_or(|n| n != "private")
})
.collect();
children.sort();
for child in children {
collect_dirs_recursive(&child, out);
}
}
}
fn expand_tilde(path: &str) -> String {
if path == "~" || path.starts_with("~/") || path.starts_with("~\\") {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
if home.is_empty() {
return path.to_string();
}
if path == "~" {
home
} else {
format!("{}{}", home, &path[1..])
}
} else {
path.to_string()
}
}
pub fn load_or_create(path: &Path) -> Config {
if !path.exists() {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Err(e) = std::fs::write(path, DEFAULT_CONFIG) {
eprintln!(
"Warning: could not create config file '{}': {e}",
path.display()
);
}
return Config::default();
}
load(path).unwrap_or_else(|e| {
eprintln!("Warning: could not read config '{}': {e}", path.display());
Config::default()
})
}
pub fn load(path: &Path) -> Result<Config, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read '{}': {e}", path.display()))?;
toml::from_str(&text).map_err(|e| format!("parse error in '{}': {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_parses() {
let cfg = Config::default();
assert_eq!(cfg.precision(), 10);
assert!(matches!(cfg.base(), Base::Dec));
}
#[test]
fn load_or_create_makes_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
assert!(!path.exists());
let cfg = load_or_create(&path);
assert!(path.exists(), "config.toml should have been created");
assert_eq!(cfg.precision(), 10);
assert!(matches!(cfg.base(), Base::Dec));
}
#[test]
fn load_custom_values() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[display]\nprecision = 4\nbase = \"hex\"\n").unwrap();
let cfg = load(&path).unwrap();
assert_eq!(cfg.precision(), 4);
assert!(matches!(cfg.base(), Base::Hex));
}
#[test]
fn precision_clamped_to_15() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[display]\nprecision = 99\nbase = \"dec\"\n").unwrap();
let cfg = load(&path).unwrap();
assert_eq!(cfg.precision(), 15);
}
#[test]
fn unknown_base_falls_back_to_dec() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[display]\nprecision = 10\nbase = \"invalid\"\n").unwrap();
let cfg = load(&path).unwrap();
assert!(matches!(cfg.base(), Base::Dec));
}
#[test]
fn search_path_loaded_from_top_level() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"path = [\"/my/scripts\", \"/home/user/calc\"]\n\n[display]\nprecision = 10\nbase = \"dec\"\n",
)
.unwrap();
let cfg = load(&path).unwrap();
let sp = cfg.search_path();
assert_eq!(sp.len(), 2);
assert_eq!(sp[0], std::path::PathBuf::from("/my/scripts"));
assert_eq!(sp[1], std::path::PathBuf::from("/home/user/calc"));
}
#[test]
fn search_path_under_display_is_ignored() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[display]\nprecision = 10\nbase = \"dec\"\npath = [\"/wrong\"]\n",
)
.unwrap();
let cfg = load(&path).unwrap();
assert!(cfg.search_path().is_empty());
}
#[test]
fn search_path_trailing_slash_includes_subdirs() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir_all(&sub).unwrap();
let path = dir.path().join("config.toml");
let root_with_slash = format!("{}/", dir.path().to_string_lossy().replace('\\', "/"));
std::fs::write(
&path,
format!(
"path = [\"{root_with_slash}\"]\n\n[display]\nprecision = 10\nbase = \"dec\"\n"
),
)
.unwrap();
let cfg = load(&path).unwrap();
let sp = cfg.search_path();
assert!(sp.len() >= 2, "root + at least one subdir expected");
assert_eq!(sp[0], dir.path());
assert!(sp.iter().any(|p| p == &sub));
}
#[test]
fn search_path_no_trailing_slash_exact_only() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir_all(&sub).unwrap();
let path = dir.path().join("config.toml");
let root = dir.path().to_string_lossy().replace('\\', "/");
std::fs::write(
&path,
format!("path = [\"{root}\"]\n\n[display]\nprecision = 10\nbase = \"dec\"\n"),
)
.unwrap();
let cfg = load(&path).unwrap();
let sp = cfg.search_path();
assert_eq!(sp.len(), 1);
assert_eq!(sp[0], dir.path());
}
#[test]
fn search_path_windows_forward_slashes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"path = [\"e:/github.com/holgertkey/ccalc/examples\"]\n\n[display]\nprecision = 10\nbase = \"dec\"\n",
)
.unwrap();
let cfg = load(&path).unwrap();
let sp = cfg.search_path();
assert_eq!(sp.len(), 1);
assert_eq!(
sp[0],
std::path::PathBuf::from("e:/github.com/holgertkey/ccalc/examples")
);
}
}