use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
use ratatui::style::Color;
use serde::Deserialize;
use crate::preview::PreviewKind;
pub const DEFAULT_CODE_BG: Color = Color::Rgb(43, 48, 59);
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub ui: UiConfig,
pub preview: PreviewConfig,
pub keys: KeysConfig,
pub editor: EditorConfig,
pub git: GitConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct GitConfig {
pub tool: String,
pub diff: String,
pub main_branch: String,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
tool: "lazygit".into(),
diff: "unified".into(), main_branch: "".into(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct EditorConfig {
pub command: String,
pub ext: HashMap<String, String>,
}
impl EditorConfig {
fn configured(&self, ext: &str) -> Option<String> {
let by_ext = self
.ext
.get(ext)
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
by_ext.or_else(|| {
let d = self.command.trim();
(!d.is_empty()).then(|| d.to_string())
})
}
pub fn resolve(&self, path: &Path) -> Vec<String> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let tmpl = self
.configured(&ext)
.or_else(|| env_nonempty("VISUAL"))
.or_else(|| env_nonempty("EDITOR"))
.unwrap_or_else(|| "vim".to_string());
build_argv(&tmpl, path)
}
}
fn env_nonempty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn build_argv(tmpl: &str, path: &Path) -> Vec<String> {
let p = path.to_string_lossy().to_string();
let mut argv: Vec<String> = tmpl.split_whitespace().map(|s| s.to_string()).collect();
if argv.is_empty() {
return vec!["vim".to_string(), p];
}
let mut substituted = false;
for a in argv.iter_mut() {
if a.contains("{path}") {
*a = a.replace("{path}", &p);
substituted = true;
}
}
if !substituted {
argv.push(p);
}
argv
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct KeysConfig {
pub copy_prefix: String,
pub copy_name: String, pub copy_full: String, pub copy_relative: String, pub copy_parent: String, #[serde(flatten)]
pub surfaces: HashMap<String, HashMap<String, String>>,
}
impl Default for KeysConfig {
fn default() -> Self {
Self {
copy_prefix: "c".into(),
copy_name: "n".into(),
copy_full: "p".into(),
copy_relative: "r".into(),
copy_parent: "d".into(),
surfaces: HashMap::new(),
}
}
}
impl KeysConfig {
pub fn to_keymap_config(&self) -> crate::keymap::KeysFileConfig {
let mut surfaces = self.surfaces.clone();
let defaults = KeysConfig::default();
let aliases: [(&str, &str, &str); 4] = [
(&self.copy_name, &defaults.copy_name, "copy_name"),
(
&self.copy_relative,
&defaults.copy_relative,
"copy_relative",
),
(&self.copy_full, &defaults.copy_full, "copy_full"),
(&self.copy_parent, &defaults.copy_parent, "copy_parent"),
];
for (cur, def, action) in aliases {
let cur = cur.trim();
if cur.is_empty() || cur == def {
continue;
}
if let Some(c) = cur.chars().next() {
surfaces
.entry("global".to_string())
.or_default()
.entry(format!("y {c}"))
.or_insert_with(|| action.to_string());
}
}
crate::keymap::KeysFileConfig { surfaces }
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct UiConfig {
pub show_hidden: bool,
pub tabbar: String, pub icons: bool, pub wrap: bool, pub line_numbers: bool, pub tab_width: usize,
pub syntax_highlight: bool,
pub preview_loading: String,
pub path_style: String, pub keys: String, pub lang: String, pub statusbar: String,
pub theme: ThemeConfig, pub image_render_scale: f64,
pub svg_max_px: u32,
pub sort: SortConfig,
pub details: Vec<String>,
pub graph_max_branches: usize,
pub graph_base_branches: Vec<String>,
pub commit_meta_align: String,
pub confirm_quit: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct SortConfig {
pub key: String,
pub reverse: bool,
pub dirs_first: bool,
}
impl Default for SortConfig {
fn default() -> Self {
Self {
key: "name".into(),
reverse: false,
dirs_first: true,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
pub bg: String,
pub code_bg: String,
pub code_label_align: String,
pub code_label_bg: String,
pub code_theme: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
bg: "none".into(), code_bg: "#2b303b".into(), code_label_align: "right".into(),
code_label_bg: "auto".into(),
code_theme: "TwoDark".into(), }
}
}
impl ThemeConfig {
pub fn bg(&self) -> Option<Color> {
parse_color_opt(&self.bg, None)
}
pub fn code_bg(&self) -> Option<Color> {
parse_color_opt(&self.code_bg, Some(DEFAULT_CODE_BG))
}
pub fn code_label_right(&self) -> bool {
!self.code_label_align.trim().eq_ignore_ascii_case("left")
}
pub fn code_label_bg(&self) -> Option<Color> {
if self.code_label_bg.trim().eq_ignore_ascii_case("auto") {
return self.code_bg().map(lighten);
}
parse_color_opt(&self.code_label_bg, None)
}
}
fn parse_color_opt(s: &str, fallback: Option<Color>) -> Option<Color> {
let s = s.trim();
if s.is_empty() || s.eq_ignore_ascii_case("none") {
return None;
}
match Color::from_str(s) {
Ok(c) => Some(c),
Err(_) => fallback,
}
}
pub fn lighten(c: Color) -> Color {
match c {
Color::Rgb(r, g, b) => Color::Rgb(
r.saturating_add(27),
g.saturating_add(30),
b.saturating_add(40),
),
other => other,
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PreviewConfig {
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Rule {
pub glob: Option<String>,
pub mime: Option<String>,
pub builtin: Option<String>, pub command: Option<String>, pub render_as: Option<String>, pub detached: bool, }
impl Default for UiConfig {
fn default() -> Self {
Self {
show_hidden: false,
tabbar: "auto".into(),
icons: true,
wrap: true,
line_numbers: false,
tab_width: 4,
syntax_highlight: true,
preview_loading: "indicator".into(),
path_style: "relative".into(),
keys: "vim".into(),
lang: "auto".into(),
statusbar: "split".into(),
theme: ThemeConfig::default(),
image_render_scale: 1.0,
svg_max_px: 800,
sort: SortConfig::default(),
details: Vec::new(),
graph_max_branches: 12,
graph_base_branches: Vec::new(),
commit_meta_align: "right".into(),
confirm_quit: true,
}
}
}
impl Default for PreviewConfig {
fn default() -> Self {
Self {
rules: vec![
Rule {
glob: Some("*.md".into()),
builtin: Some("markdown".into()),
..Rule::empty()
},
Rule {
glob: Some("*.{mmd,mermaid}".into()),
builtin: Some("mermaid".into()), ..Rule::empty()
},
Rule {
glob: Some("*.svg".into()),
builtin: Some("svg".into()),
..Rule::empty()
},
Rule {
mime: Some("image/*".into()),
builtin: Some("image".into()),
..Rule::empty()
},
Rule {
glob: Some("*.{rs,ts,tsx,js,py,go,toml,json,sh,yaml,yml,c,cpp,h}".into()),
builtin: Some("code".into()),
..Rule::empty()
},
Rule {
mime: Some("video/*".into()),
builtin: Some("video".into()),
..Rule::empty()
},
Rule {
glob: Some("*.pdf".into()),
builtin: Some("pdf".into()),
..Rule::empty()
},
],
}
}
}
impl Rule {
fn empty() -> Self {
Self {
glob: None,
mime: None,
builtin: None,
command: None,
render_as: None,
detached: false,
}
}
}
impl Config {
pub fn load_reporting() -> (Self, Option<String>) {
let Some(path) = dirs_config_path() else {
return (Config::default(), None);
};
let Ok(text) = std::fs::read_to_string(&path) else {
return (Config::default(), None);
};
match toml::from_str::<Config>(&text) {
Ok(cfg) => (cfg, None),
Err(e) => {
let msg = e
.to_string()
.lines()
.next()
.unwrap_or("parse error")
.to_string();
(
Config::default(),
Some(format!("config error (using defaults): {msg}")),
)
}
}
}
pub fn resolve_preview(&self, path: &Path) -> PreviewKind {
for rule in &self.preview.rules {
if rule_matches(rule, path) {
return PreviewKind::from_rule(rule, path);
}
}
if crate::preview::text::is_probably_text(path) {
PreviewKind::Text(path.to_path_buf())
} else {
PreviewKind::can_not_preview(path)
}
}
}
fn rule_matches(rule: &Rule, path: &Path) -> bool {
if let Some(glob) = &rule.glob {
if let Ok(set) = globset::GlobBuilder::new(glob)
.case_insensitive(true)
.build()
.map(|g| g.compile_matcher())
{
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if set.is_match(name) {
return true;
}
}
}
if let Some(mime_pat) = &rule.mime {
if let Some(kind) = infer::get_from_path(path).ok().flatten() {
let mime = kind.mime_type();
if mime_glob_match(mime_pat, mime) {
return true;
}
}
}
false
}
fn mime_glob_match(pattern: &str, mime: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix("/*") {
mime.starts_with(prefix)
} else {
pattern == mime
}
}
fn dirs_config_path() -> Option<std::path::PathBuf> {
let home = std::env::var_os("HOME")?;
let mut p = std::path::PathBuf::from(home);
p.push(".config/konoma/config.toml");
Some(p)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn tmp(name: &str, bytes: &[u8]) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("konoma_cfg_test_{name}"));
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(bytes).unwrap();
p
}
#[test]
fn markdown_rule_still_wins() {
let p = tmp("README.md", b"# title\n");
let kind = Config::default().resolve_preview(&p);
assert!(matches!(kind, PreviewKind::Markdown(_)), "got {kind:?}");
std::fs::remove_file(&p).ok();
}
#[test]
fn glob_matches_uppercase_extension() {
let p = tmp("README.MD", b"# title\n");
let kind = Config::default().resolve_preview(&p);
assert!(matches!(kind, PreviewKind::Markdown(_)), "got {kind:?}");
std::fs::remove_file(&p).ok();
let p = tmp("MAIN.RS", b"fn main() {}\n");
let kind = Config::default().resolve_preview(&p);
assert!(matches!(kind, PreviewKind::Code(_)), "got {kind:?}");
std::fs::remove_file(&p).ok();
}
#[test]
fn editor_build_argv_substitutes_or_appends_path() {
let path = Path::new("/tmp/a.rs");
assert_eq!(
build_argv("code -g {path}:1", path),
vec!["code", "-g", "/tmp/a.rs:1"]
);
assert_eq!(build_argv("nvim", path), vec!["nvim", "/tmp/a.rs"]);
assert_eq!(build_argv("code -w", path), vec!["code", "-w", "/tmp/a.rs"]);
assert_eq!(build_argv(" ", path), vec!["vim", "/tmp/a.rs"]);
}
#[test]
fn editor_configured_prefers_ext_then_command() {
let mut e = EditorConfig {
command: "nvim".into(),
ext: HashMap::new(),
};
e.ext.insert("md".into(), "code -w".into());
e.ext.insert("blank".into(), " ".into()); assert_eq!(e.configured("md").as_deref(), Some("code -w"));
assert_eq!(e.configured("rs").as_deref(), Some("nvim"));
assert_eq!(e.configured("blank").as_deref(), Some("nvim"));
let empty = EditorConfig::default();
assert_eq!(empty.configured("rs"), None);
}
#[test]
fn editor_resolve_uses_ext_mapping() {
let mut e = EditorConfig {
command: "nvim".into(),
ext: HashMap::new(),
};
e.ext.insert("md".into(), "code -w".into());
assert_eq!(
e.resolve(Path::new("/x/NOTE.MD")),
vec!["code", "-w", "/x/NOTE.MD"]
);
assert_eq!(e.resolve(Path::new("/x/a.rs")), vec!["nvim", "/x/a.rs"]);
}
#[test]
fn unknown_text_falls_back_to_text() {
let p = tmp("LICENSE", b"MIT License\n\nPermission...\n");
let kind = Config::default().resolve_preview(&p);
assert!(matches!(kind, PreviewKind::Text(_)), "got {kind:?}");
std::fs::remove_file(&p).ok();
}
#[test]
fn theme_code_bg_parses_hex_none_and_invalid() {
assert_eq!(ThemeConfig::default().code_bg(), Some(DEFAULT_CODE_BG));
let t = ThemeConfig {
code_bg: "#101820".into(),
..Default::default()
};
assert_eq!(t.code_bg(), Some(Color::Rgb(16, 24, 32)));
for v in ["none", " NONE ", ""] {
let t = ThemeConfig {
code_bg: v.into(),
..Default::default()
};
assert_eq!(t.code_bg(), None, "{v:?} は None になるべき");
}
let t = ThemeConfig {
code_bg: "definitely-not-a-color".into(),
..Default::default()
};
assert_eq!(t.code_bg(), Some(DEFAULT_CODE_BG));
}
#[test]
fn theme_bg_parses_and_defaults_to_none() {
assert_eq!(ThemeConfig::default().bg(), None);
let t = ThemeConfig {
bg: "#102030".into(),
..Default::default()
};
assert_eq!(t.bg(), Some(Color::Rgb(16, 32, 48)));
let t = ThemeConfig {
bg: "nope-xyz".into(),
..Default::default()
};
assert_eq!(t.bg(), None);
}
#[test]
fn image_render_scale_defaults_and_parses() {
assert_eq!(Config::default().ui.image_render_scale, 1.0);
let p = tmp("imgscale.toml", b"[ui]\nimage_render_scale = 0.3\n");
let text = std::fs::read_to_string(&p).unwrap();
let cfg: Config = toml::from_str(&text).unwrap();
assert_eq!(cfg.ui.image_render_scale, 0.3);
std::fs::remove_file(&p).ok();
}
#[test]
fn code_label_align_and_bg_resolve() {
let d = ThemeConfig::default();
assert!(d.code_label_right());
assert_eq!(d.code_label_bg(), Some(lighten(DEFAULT_CODE_BG)));
let t = ThemeConfig {
code_label_align: "left".into(),
..Default::default()
};
assert!(!t.code_label_right());
let t = ThemeConfig {
code_label_align: "LEFT".into(),
..Default::default()
};
assert!(!t.code_label_right());
let t = ThemeConfig {
code_label_bg: "#ff0000".into(),
..Default::default()
};
assert_eq!(t.code_label_bg(), Some(Color::Rgb(255, 0, 0)));
let t = ThemeConfig {
code_label_bg: "none".into(),
..Default::default()
};
assert_eq!(t.code_label_bg(), None);
let t = ThemeConfig {
code_bg: "none".into(),
code_label_bg: "auto".into(),
..Default::default()
};
assert_eq!(t.code_label_bg(), None);
}
#[test]
fn broken_config_reports_error_and_falls_back() {
let broken = "[keys]\ncopy_prefix = \n"; let parsed = toml::from_str::<Config>(broken);
assert!(parsed.is_err(), "壊れた TOML は Err になる");
let msg = parsed.err().map(|e| e.to_string());
assert!(msg.is_some());
}
#[test]
fn copy_keys_default_and_custom() {
let d = KeysConfig::default();
assert_eq!(d.copy_prefix, "c");
assert_eq!(d.copy_name, "n");
assert_eq!(d.copy_full, "p");
let p = tmp(
"keys.toml",
b"[keys]\ncopy_prefix = \"y\"\ncopy_full = \"P\"\n",
);
let text = std::fs::read_to_string(&p).unwrap();
let cfg: Config = toml::from_str(&text).unwrap();
assert_eq!(cfg.keys.copy_prefix, "y");
assert_eq!(cfg.keys.copy_full, "P");
assert_eq!(cfg.keys.copy_name, "n", "未指定は既定のまま");
std::fs::remove_file(&p).ok();
}
#[test]
fn keys_surfaces_parse_from_toml() {
let p = tmp(
"keys_surfaces.toml",
br#"
[keys]
copy_prefix = "y"
[keys.tree]
d = "refresh"
"space d" = "file_delete"
[keys.preview_text]
"ctrl-f" = "navigate:page_down"
"#,
);
let text = std::fs::read_to_string(&p).unwrap();
let cfg: Config = toml::from_str(&text).unwrap();
assert_eq!(cfg.keys.copy_prefix, "y");
assert_eq!(
cfg.keys.surfaces.get("tree").unwrap().get("d").unwrap(),
"refresh"
);
assert_eq!(
cfg.keys
.surfaces
.get("tree")
.unwrap()
.get("space d")
.unwrap(),
"file_delete"
);
assert_eq!(
cfg.keys
.surfaces
.get("preview_text")
.unwrap()
.get("ctrl-f")
.unwrap(),
"navigate:page_down"
);
std::fs::remove_file(&p).ok();
}
#[test]
fn to_keymap_config_maps_changed_copy_alias_only() {
use crate::app::CopyKind;
use crate::keymap::{Action, KeyMap, KeyPress, KeyScheme, LeaderId, Resolution, Surface};
let def = KeysConfig::default();
let km = KeyMap::from_config(KeyScheme::Vim, &def.to_keymap_config());
assert_eq!(
km.resolve(Surface::Tree, Some(LeaderId::Copy), KeyPress::ch('f')),
Resolution::Action(Action::CopyPath(CopyKind::Full))
);
assert_eq!(
km.resolve(Surface::Tree, Some(LeaderId::Copy), KeyPress::ch('p')),
Resolution::Action(Action::CopyPath(CopyKind::Parent))
);
let custom = KeysConfig {
copy_full: "P".into(),
..KeysConfig::default()
};
let km2 = KeyMap::from_config(KeyScheme::Vim, &custom.to_keymap_config());
assert_eq!(
km2.resolve(Surface::Tree, Some(LeaderId::Copy), KeyPress::ch('P')),
Resolution::Action(Action::CopyPath(CopyKind::Full))
);
assert_eq!(
km2.resolve(Surface::Tree, Some(LeaderId::Copy), KeyPress::ch('f')),
Resolution::Action(Action::CopyPath(CopyKind::Full))
);
}
#[test]
fn theme_parses_from_toml() {
let p = tmp(
"theme.toml",
b"[ui.theme]\nbg = \"#000010\"\ncode_bg = \"none\"\n",
);
let text = std::fs::read_to_string(&p).unwrap();
let cfg: Config = toml::from_str(&text).unwrap();
assert_eq!(cfg.ui.theme.code_bg(), None);
assert_eq!(cfg.ui.theme.bg(), Some(Color::Rgb(0, 0, 16)));
std::fs::remove_file(&p).ok();
}
#[test]
fn unknown_binary_falls_back_to_can_not_preview() {
let p = tmp("mystery", &[0x00, 0x01, 0x02, 0x03]);
let kind = Config::default().resolve_preview(&p);
assert!(
matches!(kind, PreviewKind::CanNotPreview { .. }),
"got {kind:?}"
);
std::fs::remove_file(&p).ok();
}
#[test]
fn env_nonempty_trims_and_filters_blank() {
let key = "KONOMA_TEST_ENV_NONEMPTY_PROBE";
std::env::remove_var(key);
assert_eq!(env_nonempty(key), None, "未設定は None");
std::env::set_var(key, " spaced ");
assert_eq!(
env_nonempty(key).as_deref(),
Some("spaced"),
"前後空白は trim"
);
std::env::set_var(key, " ");
assert_eq!(env_nonempty(key), None, "空白のみは None");
std::env::remove_var(key);
}
#[test]
fn mime_glob_match_prefix_and_exact() {
assert!(
mime_glob_match("image/*", "image/png"),
"プレフィックス一致"
);
assert!(mime_glob_match("image/*", "image/"), "境界(空サブタイプ)");
assert!(
!mime_glob_match("image/*", "video/mp4"),
"別カテゴリは不一致"
);
assert!(mime_glob_match("text/plain", "text/plain"), "完全一致");
assert!(
!mime_glob_match("text/plain", "text/html"),
"完全指定は厳密"
);
}
#[test]
fn dirs_config_path_is_under_home_config() {
if std::env::var_os("HOME").is_some() {
let p = dirs_config_path().expect("HOME があれば Some");
assert!(
p.ends_with(".config/konoma/config.toml"),
"設定パスの末尾: {}",
p.display()
);
}
}
#[test]
fn editor_config_resolve_priority_ext_command_env_default() {
use std::path::Path;
let mut ec = EditorConfig {
command: "code -w".into(),
ext: HashMap::new(),
};
ec.ext.insert("rs".into(), "nvim {path}".into());
let argv = ec.resolve(Path::new("/x/main.rs"));
assert_eq!(
argv,
vec!["nvim".to_string(), "/x/main.rs".to_string()],
"{{path}} 置換"
);
let argv = ec.resolve(Path::new("/x/readme.md"));
assert_eq!(
argv,
vec![
"code".to_string(),
"-w".to_string(),
"/x/readme.md".to_string()
]
);
let empty = EditorConfig {
command: String::new(),
ext: HashMap::new(),
};
let save_v = std::env::var_os("VISUAL");
let save_e = std::env::var_os("EDITOR");
std::env::set_var("VISUAL", "myvisual");
std::env::set_var("EDITOR", "myeditor");
assert_eq!(
empty.resolve(Path::new("/x/f")),
vec!["myvisual".to_string(), "/x/f".to_string()],
"VISUAL 優先"
);
std::env::remove_var("VISUAL");
assert_eq!(
empty.resolve(Path::new("/x/f")),
vec!["myeditor".to_string(), "/x/f".to_string()],
"次に EDITOR"
);
std::env::remove_var("EDITOR");
assert_eq!(
empty.resolve(Path::new("/x/f")),
vec!["vim".to_string(), "/x/f".to_string()],
"最後は vim"
);
match save_v {
Some(v) => std::env::set_var("VISUAL", v),
None => std::env::remove_var("VISUAL"),
}
match save_e {
Some(v) => std::env::set_var("EDITOR", v),
None => std::env::remove_var("EDITOR"),
}
}
#[test]
fn load_reporting_returns_config_and_well_formed_error() {
let (_cfg, err) = Config::load_reporting();
if let Some(e) = err {
assert!(
e.starts_with("config error (using defaults): "),
"エラー文言の接頭辞が規約どおり: {e}"
);
}
}
}