use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const DEFAULT_FOLD_THRESHOLD: usize = 50;
pub const DEFAULT_IGNORE_GLOBS: &[&str] = &["node_modules", "target", "dist", ".git"];
#[derive(Debug, Clone)]
pub struct Config {
pub exec: ExecConfig,
pub outline: OutlineConfig,
pub paths: PathsConfig,
pub general: GeneralConfig,
}
#[derive(Debug, Clone)]
pub struct ExecConfig {
pub keep: Vec<String>,
pub head_lines: usize,
pub tail_lines: usize,
pub collapse_threshold: usize,
}
#[derive(Debug, Clone)]
pub struct OutlineConfig {
pub fold_threshold: usize,
pub show_doc: bool,
}
#[derive(Debug, Clone)]
pub struct PathsConfig {
pub ignore: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct GeneralConfig {
pub show_saved: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
exec: ExecConfig {
keep: ctx_exec::DEFAULT_KEEP_PATTERNS
.iter()
.map(|s| s.to_string())
.collect(),
head_lines: ctx_exec::DEFAULT_HEAD_LINES,
tail_lines: ctx_exec::DEFAULT_TAIL_LINES,
collapse_threshold: ctx_exec::DEFAULT_COLLAPSE_THRESHOLD,
},
outline: OutlineConfig {
fold_threshold: DEFAULT_FOLD_THRESHOLD,
show_doc: true,
},
paths: PathsConfig {
ignore: DEFAULT_IGNORE_GLOBS.iter().map(|s| s.to_string()).collect(),
},
general: GeneralConfig { show_saved: true },
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Partial {
exec: PartialExec,
outline: PartialOutline,
paths: PartialPaths,
general: PartialGeneral,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct PartialExec {
keep: Option<Vec<String>>,
head_lines: Option<usize>,
tail_lines: Option<usize>,
collapse_threshold: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct PartialOutline {
fold_threshold: Option<usize>,
show_doc: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct PartialPaths {
ignore: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct PartialGeneral {
show_saved: Option<bool>,
}
impl Partial {
fn merge_into(self, config: &mut Config) {
if let Some(keep) = self.exec.keep {
config.exec.keep = keep;
}
if let Some(v) = self.exec.head_lines {
config.exec.head_lines = v;
}
if let Some(v) = self.exec.tail_lines {
config.exec.tail_lines = v;
}
if let Some(v) = self.exec.collapse_threshold {
config.exec.collapse_threshold = v;
}
if let Some(v) = self.outline.fold_threshold {
config.outline.fold_threshold = v;
}
if let Some(v) = self.outline.show_doc {
config.outline.show_doc = v;
}
if let Some(ignore) = self.paths.ignore {
config.paths.ignore = ignore;
}
if let Some(v) = self.general.show_saved {
config.general.show_saved = v;
}
}
}
pub fn load(explicit: Option<&Path>) -> Result<Config, String> {
let mut config = Config::default();
if let Some(global) = xdg_global_path()
&& global.is_file()
{
merge_file(&mut config, &global)?;
}
if let Some(project) =
discover_project_config(&std::env::current_dir().map_err(|e| e.to_string())?)
{
merge_file(&mut config, &project)?;
}
if let Some(explicit) = explicit {
merge_file(&mut config, explicit)?;
}
Ok(config)
}
fn merge_file(config: &mut Config, path: &Path) -> Result<(), String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read config {}: {e}", path.display()))?;
let partial: Partial =
toml::from_str(&text).map_err(|e| format!("invalid config {}: {e}", path.display()))?;
partial.merge_into(config);
Ok(())
}
fn xdg_global_path() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg).join("ctxctl/config.toml"));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/ctxctl/config.toml"))
}
fn discover_project_config(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(current) = dir {
let candidate = current.join(".ctxctl/config.toml");
if candidate.is_file() {
return Some(candidate);
}
dir = current.parent();
}
None
}