use crate::keys::Keymap;
use crate::theme::{self, Mode, Palette};
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
pub trait Words: Copy + PartialEq + 'static {
const WORDS: &'static [(Self, &'static str)];
fn name(self) -> &'static str {
Self::WORDS
.iter()
.find(|(v, _)| *v == self)
.map(|(_, w)| *w)
.expect("every variant is listed in WORDS")
}
fn parse(s: &str) -> Option<Self> {
Self::WORDS
.iter()
.find(|(_, w)| w.eq_ignore_ascii_case(s))
.map(|(v, _)| *v)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TableStyle {
#[default]
Auto,
Scroll,
Fit,
Wrap,
Cards,
}
impl Words for TableStyle {
const WORDS: &'static [(Self, &'static str)] = &[
(TableStyle::Auto, "auto"),
(TableStyle::Scroll, "scroll"),
(TableStyle::Fit, "fit"),
(TableStyle::Wrap, "wrap"),
(TableStyle::Cards, "cards"),
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PreviewClick {
#[default]
Select,
Edit,
}
impl Words for PreviewClick {
const WORDS: &'static [(Self, &'static str)] = &[
(PreviewClick::Select, "select"),
(PreviewClick::Edit, "edit"),
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FrontMatter {
#[default]
Dim,
Show,
Hide,
}
impl Words for FrontMatter {
const WORDS: &'static [(Self, &'static str)] = &[
(FrontMatter::Dim, "dim"),
(FrontMatter::Show, "show"),
(FrontMatter::Hide, "hide"),
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Properties {
#[default]
Box,
Line,
Hide,
}
impl Words for Properties {
const WORDS: &'static [(Self, &'static str)] = &[
(Properties::Box, "box"),
(Properties::Line, "line"),
(Properties::Hide, "hide"),
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BorderStyle {
#[default]
Rounded,
Square,
None,
}
impl Words for BorderStyle {
const WORDS: &'static [(Self, &'static str)] = &[
(BorderStyle::Rounded, "rounded"),
(BorderStyle::Square, "square"),
(BorderStyle::None, "none"),
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusItem {
Path,
Name,
Mode,
Keys,
Message,
Properties,
}
impl StatusItem {
fn parse(word: &str) -> Option<StatusItem> {
Some(match word {
"path" => StatusItem::Path,
"name" | "file" | "filename" => StatusItem::Name,
"mode" => StatusItem::Mode,
"keys" | "hints" => StatusItem::Keys,
"message" | "status" => StatusItem::Message,
"properties" | "props" => StatusItem::Properties,
_ => return None,
})
}
fn word(self) -> &'static str {
match self {
StatusItem::Path => "path",
StatusItem::Name => "name",
StatusItem::Mode => "mode",
StatusItem::Keys => "keys",
StatusItem::Message => "message",
StatusItem::Properties => "properties",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Theme {
#[default]
Auto,
Dark,
Light,
}
impl Words for Theme {
const WORDS: &'static [(Self, &'static str)] = &[
(Theme::Auto, "auto"),
(Theme::Dark, "dark"),
(Theme::Light, "light"),
];
}
impl Theme {
pub fn mode(self) -> Mode {
match self {
Theme::Auto => theme::detected(),
Theme::Dark => Mode::Dark,
Theme::Light => Mode::Light,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub notes_dir: PathBuf,
pub attachments_dir: PathBuf,
pub attachment_subfolder: String,
pub theme: Theme,
pub palette: Palette,
pub page_width: u16,
pub borders: BorderStyle,
pub bold_headings: bool,
pub status_bar: bool,
pub key_hints: bool,
pub opener: bool,
pub window_title: bool,
pub status_bar_items: Vec<StatusItem>,
pub autosave_ms: u64,
pub tab_width: usize,
pub rename_files: bool,
pub update_links: bool,
pub status_words: bool,
pub table_style: TableStyle,
pub preview_click: PreviewClick,
pub front_matter: FrontMatter,
pub properties: Properties,
pub wikilinks: bool,
pub tags: bool,
pub linked_mentions: bool,
pub autocomplete: bool,
pub quick_open_recursive: bool,
pub quick_open_browse: bool,
pub quick_open_dirs: Vec<PathBuf>,
pub daily_dir: PathBuf,
pub daily_format: String,
pub daily_template: PathBuf,
pub keys: Keymap,
}
impl Default for Config {
fn default() -> Self {
let home = std::env::home_dir().unwrap_or_else(|| PathBuf::from("."));
let notes_dir = default_notes_dir(&home);
Config {
attachments_dir: notes_dir.join("attachments"),
attachment_subfolder: "attachments".to_string(),
notes_dir,
theme: Theme::Auto,
palette: theme::base(theme::detected()),
page_width: 100,
borders: BorderStyle::Rounded,
bold_headings: true,
status_bar: true,
key_hints: true,
opener: true,
window_title: true,
status_bar_items: vec![StatusItem::Path, StatusItem::Message, StatusItem::Keys],
autosave_ms: 500,
tab_width: 2,
rename_files: true,
update_links: true,
status_words: false,
table_style: TableStyle::Auto,
preview_click: PreviewClick::Select,
front_matter: FrontMatter::Dim,
properties: Properties::Box,
wikilinks: true,
tags: true,
linked_mentions: true,
autocomplete: true,
quick_open_recursive: true,
quick_open_browse: false,
quick_open_dirs: Vec::new(),
daily_dir: PathBuf::from("journal"),
daily_format: crate::daily::DEFAULT_FORMAT.to_string(),
daily_template: PathBuf::from("journal/template.md"),
keys: Keymap::default(),
}
}
}
pub fn settings_path() -> Result<PathBuf> {
Ok(config_dir()?.join("settings.md"))
}
pub fn config_dir() -> Result<PathBuf> {
let config = std::env::home_dir()
.context("no home directory")?
.join(".config");
let new = config.join("catcher");
let old = config.join("tinynote");
if !new.exists() && old.is_dir() {
return Ok(old);
}
Ok(new)
}
fn default_notes_dir(home: &Path) -> PathBuf {
let new = home.join("catcher");
let old = home.join("tinynote");
if !new.exists() && old.is_dir() {
return old;
}
new
}
impl Config {
pub fn load() -> Result<Config> {
let (config, warning) = Config::load_reporting()?;
if let Some(w) = warning {
if !crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) {
eprintln!("catcher: {w}");
}
}
Ok(config)
}
pub fn load_reporting() -> Result<(Config, Option<String>)> {
let path = settings_path()?;
if !path.exists() {
let fresh = Config::from_str("");
write_settings(&path, &fresh)?;
return Ok((fresh, None));
}
let text =
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let on_disk = Config::from_file_text(&text);
let warning = if covers_every_setting(&text, &on_disk) {
None
} else {
write_settings(&path, &on_disk)
.err()
.map(|e| format!("settings not updated: {e:#}"))
};
Ok((Config::from_str(&text), warning))
}
pub fn apply(&self) {
theme::set_palette(self.palette);
theme::set_bold_headings(self.bold_headings);
crate::md::links::set_enabled(self.wikilinks);
crate::md::tags::set_enabled(self.tags);
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(text: &str) -> Config {
let mut c = Config::from_file_text(text);
if let Some(d) = std::env::var_os("CATCHER_DIR") {
c.root_at(text, PathBuf::from(d));
}
c
}
pub fn load_for(root: &Path) -> Result<Config> {
let path = settings_path()?;
let text = fs::read_to_string(&path).unwrap_or_default();
let mut c = Config::from_file_text(&text);
c.root_at(&text, root.to_path_buf());
Ok(c)
}
fn root_at(&mut self, text: &str, root: PathBuf) {
self.notes_dir = root;
if value(text, "attachments_dir").is_none() {
self.attachments_dir = self.notes_dir.join("attachments");
self.adopt_obsidian_attachments();
}
}
fn adopt_obsidian_attachments(&mut self) {
if self.attachments_dir != self.notes_dir.join("attachments") {
return;
}
let Ok(text) = fs::read_to_string(self.notes_dir.join(".obsidian/app.json")) else {
return;
};
if let Some(setting) = obsidian_attachment_setting(&text) {
self.set_obsidian_attachments(&setting);
}
}
fn set_obsidian_attachments(&mut self, setting: &str) {
let setting = setting.trim().trim_end_matches('/');
match setting
.strip_prefix("./")
.or(if setting == "." { Some("") } else { None })
{
Some("") => {
self.attachments_dir = self.notes_dir.clone();
self.attachment_subfolder = ".".to_string();
}
Some(sub) => {
self.attachments_dir = self.notes_dir.join(sub);
self.attachment_subfolder = sub.to_string();
}
None if setting.is_empty() || setting == "/" => {
self.attachments_dir = self.notes_dir.clone();
}
None => {
self.attachments_dir = self.notes_dir.join(setting.trim_start_matches('/'));
}
}
}
fn from_file_text(text: &str) -> Config {
let home = std::env::home_dir().unwrap_or_else(|| PathBuf::from("."));
let mut c = Config::default();
if let Some(v) = value(text, "notes_dir") {
c.notes_dir = expand(&v, &home);
c.attachments_dir = c.notes_dir.join("attachments");
}
if let Some(v) = value(text, "attachments_dir") {
c.attachments_dir = expand(&v, &home);
}
c.adopt_obsidian_attachments();
c.theme = word(text, "theme").unwrap_or(Theme::Auto);
c.palette = theme::base(c.theme.mode());
for key in theme::COLOR_KEYS {
if let Some(color) = value(text, key).and_then(|v| theme::parse_color(&v)) {
c.palette.set(key, color);
}
}
if let Some(v) = value(text, "page_width") {
c.page_width = if v.eq_ignore_ascii_case("full") {
0
} else {
v.parse().unwrap_or(c.page_width)
};
}
c.borders = word(text, "borders").unwrap_or(c.borders);
c.bold_headings = flag(text, "bold_headings", c.bold_headings);
c.status_bar = flag(text, "status_bar", c.status_bar);
c.key_hints = flag(text, "key_hints", c.key_hints);
c.opener = flag(text, "opener", c.opener);
c.window_title = flag(text, "window_title", c.window_title);
let items: Vec<StatusItem> = values(text, "status_bar_items")
.iter()
.flat_map(|v| v.split(',').map(str::trim).map(str::to_ascii_lowercase))
.filter_map(|w| StatusItem::parse(&w))
.fold(Vec::new(), |mut acc, item| {
if !acc.contains(&item) {
acc.push(item);
}
acc
});
if !items.is_empty() {
c.status_bar_items = items;
}
c.rename_files = flag(text, "rename_files", c.rename_files);
c.update_links = flag(text, "update_links", c.update_links);
c.status_words = flag(text, "status_words", c.status_words);
c.wikilinks = flag(text, "wikilinks", c.wikilinks);
c.tags = flag(text, "tags", c.tags);
c.quick_open_recursive = match value(text, "quick_open").as_deref() {
Some("folder") => false,
Some("recursive") => true,
_ => c.quick_open_recursive,
};
c.quick_open_browse = match value(text, "quick_open_mode").as_deref() {
Some("browse") | Some("tree") => true,
Some("search") | Some("list") => false,
_ => c.quick_open_browse,
};
if let Some(v) = value(text, "autosave_ms").and_then(|v| v.parse::<u64>().ok()) {
c.autosave_ms = v.min(60_000);
}
if let Some(v) = value(text, "tab_width").and_then(|v| v.parse::<usize>().ok()) {
c.tab_width = v.clamp(1, 16);
}
c.table_style = word(text, "table_style").unwrap_or(c.table_style);
c.quick_open_dirs = values(text, "quick_open_dirs")
.iter()
.flat_map(|v| v.split(',').map(str::trim).map(String::from))
.filter(|v| !v.is_empty())
.map(|v| expand(&v, &home))
.collect();
if let Some(v) = value(text, "daily_dir") {
c.daily_dir = expand(&v, &home);
}
if let Some(v) = value(text, "daily_format") {
c.daily_format = v.trim_matches('/').to_string();
}
if let Some(v) = value(text, "daily_template") {
c.daily_template = expand(&v, &home);
}
c.keys = Keymap::from_settings(|key| value(text, key));
c.preview_click = word(text, "preview_click").unwrap_or(c.preview_click);
c.linked_mentions = flag(text, "linked_mentions", c.linked_mentions);
c.autocomplete = flag(text, "autocomplete", c.autocomplete);
c.front_matter = word(text, "front_matter").unwrap_or(c.front_matter);
c.properties = word(text, "properties").unwrap_or(c.properties);
c
}
pub fn ensure_dirs(&self) -> Result<()> {
fs::create_dir_all(&self.notes_dir)
.with_context(|| format!("creating notes_dir {}", self.notes_dir.display()))?;
Ok(())
}
pub fn daily_dir(&self) -> PathBuf {
crate::daily::resolve(&self.notes_dir, &self.daily_dir)
}
pub fn daily_template(&self) -> PathBuf {
crate::daily::resolve(&self.notes_dir, &self.daily_template)
}
pub fn link_for(&self, file: &Path) -> String {
match file.strip_prefix(&self.notes_dir) {
Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
Err(_) => file.to_string_lossy().into_owned(),
}
}
pub fn to_document(&self) -> String {
let short = crate::index::short;
let yn = |b: bool| if b { "yes" } else { "no" };
let mut d = Doc::default();
d.head(
"Settings",
"Change a value and press ^S — it applies at once. New settings \
are added to this file as they arrive.",
);
d.section("Folders");
d.row(
"notes_dir",
short(&self.notes_dir),
"where the .md files live",
);
d.row(
"attachments_dir",
short(&self.attachments_dir),
"where pasted images go",
);
d.section("Daily note");
d.row(
"daily_dir",
short(&self.daily_dir),
"one note a day, under notes_dir unless absolute",
);
d.row(
"daily_format",
&self.daily_format,
"the file name: YYYY MM DD MMMM ddd Do HH mm A, [literal], / for subfolders",
);
d.row(
"daily_template",
short(&self.daily_template),
"{{title}} {{date}} {{date:FMT}} {{time}} {{yesterday}} {{tomorrow}}; a heading if missing",
);
d.section("Appearance");
d.row("theme", self.theme.name(), "auto · dark · light");
d.row(
"page_width",
if self.page_width == 0 {
"full".to_string()
} else {
self.page_width.to_string()
},
"columns of note, or full",
);
d.row("borders", self.borders.name(), "rounded · square · none");
d.row("bold_headings", yn(self.bold_headings), "yes · no");
d.row("status_bar", yn(self.status_bar), "the bottom line at all");
d.row("key_hints", yn(self.key_hints), "the shortcuts in it");
d.row(
"opener",
yn(self.opener),
"the note decodes out of noise on start; Toggle opener flips it",
);
d.row(
"window_title",
yn(self.window_title),
"the terminal title follows the note",
);
d.row(
"status_words",
yn(self.status_words),
"words and characters in the bar",
);
d.row(
"status_bar_items",
self.status_bar_items
.iter()
.map(|i| i.word())
.collect::<Vec<_>>()
.join(", "),
"path · name · mode · properties · keys · message, in order",
);
d.section("Colours");
d.note("#rrggbb · #rgb · red, brightblue · default · theme");
let base = theme::base(self.theme.mode());
for c in &theme::COLORS {
let mine = self.palette.get(c.name);
let value = match (mine, base.get(c.name)) {
(Some(m), Some(b)) if m != b => theme::color_to_string(m),
_ => "theme".to_string(),
};
d.row(c.name, value, c.hint);
}
d.section("Editing");
d.row("autosave_ms", self.autosave_ms, "idle time before a save");
d.row("tab_width", self.tab_width, "spaces one tab inserts");
d.row(
"rename_files",
yn(self.rename_files),
"filename follows title",
);
d.row(
"update_links",
yn(self.update_links),
"a rename fixes [[links]] to the note",
);
d.row(
"front_matter",
self.front_matter.name(),
"dim · show · hide",
);
d.section("Reading");
d.row(
"table_style",
self.table_style.name(),
"auto · scroll · fit · wrap · cards",
);
d.row("preview_click", self.preview_click.name(), "select · edit");
d.row(
"properties",
self.properties.name(),
"box · line · hide — Toggle properties cycles them",
);
d.row("wikilinks", yn(self.wikilinks), "[[links]] open notes");
d.row(
"tags",
yn(self.tags),
"#tags coloured; follow one to list its notes",
);
d.row(
"linked_mentions",
yn(self.linked_mentions),
"notes that link here, at the foot",
);
d.row(
"autocomplete",
yn(self.autocomplete),
"suggest notes after [[ and tags after #",
);
d.row(
"quick_open",
if self.quick_open_recursive {
"recursive"
} else {
"folder"
},
"recursive · folder",
);
d.row(
"quick_open_mode",
if self.quick_open_browse {
"browse"
} else {
"search"
},
"search · browse",
);
if self.quick_open_dirs.is_empty() {
d.row(
"quick_open_dirs",
"",
"extra folders to search, comma separated",
);
} else {
for (i, dir) in self.quick_open_dirs.iter().enumerate() {
let hint = if i == 0 {
"extra folders to search"
} else {
""
};
d.row("quick_open_dirs", short(dir), hint);
}
}
d.section("Keys");
d.note("^K · cmd+k · alt+k · f5 · none — or several, as `^/ f1`");
for (key, spec, what) in self.keys.settings_rows() {
d.row(key, spec, what);
}
d.finish()
}
}
pub fn set_value(key: &str, new: &str) -> Result<()> {
let path = settings_path()?;
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let out = with_value(&text, key, new);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&path, out).with_context(|| format!("writing {}", path.display()))
}
fn with_value(text: &str, key: &str, new: &str) -> String {
let mut out = String::with_capacity(text.len() + 32);
let mut found = false;
for line in text.split_inclusive('\n') {
let body = line.trim_end_matches(['\n', '\r']);
let stripped = strip_comment(body);
let head = stripped.trim().trim_start_matches(['-', '*', '>', ' ']);
let is_key = head
.split_once([':', '='])
.is_some_and(|(k, _)| k.trim() == key);
if !is_key || found {
out.push_str(line);
continue;
}
found = true;
let hint = &body[stripped.len()..];
let (prefix, _) = stripped.split_once([':', '=']).unwrap_or((stripped, ""));
let pad = if hint.is_empty() { "" } else { " " };
out.push_str(&format!("{prefix}: {new}{pad}{}", hint.trim_start()));
out.push_str(&line[body.len()..]);
}
if !found {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&format!("- {key}: {new}\n"));
}
out
}
fn write_settings(path: &Path, config: &Config) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(path, config.to_document()).with_context(|| format!("writing {}", path.display()))
}
fn covers_every_setting(text: &str, config: &Config) -> bool {
let have = setting_keys(text);
setting_keys(&config.to_document())
.into_iter()
.all(|k| have.contains(&k))
}
fn setting_keys(text: &str) -> std::collections::BTreeSet<String> {
text.lines()
.filter_map(|line| {
let line = strip_comment(line);
let line = line.trim();
let line = line
.strip_prefix("- ")
.or_else(|| line.strip_prefix("* "))?;
let (k, _) = line.split_once(':')?;
let k = k.trim();
(!k.is_empty() && k.chars().all(|c| c.is_alphanumeric() || c == '_'))
.then(|| k.to_string())
})
.collect()
}
#[derive(Default)]
struct Doc {
out: String,
pending: Vec<(String, String)>,
}
impl Doc {
fn head(&mut self, title: &str, line: &str) {
self.out.push_str(&format!("# {title}\n\n{line}\n"));
}
fn section(&mut self, name: &str) {
self.flush();
self.out.push_str(&format!("\n## {name}\n\n"));
}
fn note(&mut self, text: &str) {
self.flush();
self.out.push_str(&format!("{text}\n\n"));
}
fn row(&mut self, key: &str, value: impl std::fmt::Display, hint: &str) {
let value = value.to_string();
let line = if value.is_empty() {
format!("- {key}:")
} else {
format!("- {key}: {value}")
};
self.pending.push((line, hint.to_string()));
}
fn flush(&mut self) {
let width = self
.pending
.iter()
.filter(|(_, h)| !h.is_empty())
.map(|(l, _)| l.chars().count())
.max()
.unwrap_or(0);
for (line, hint) in std::mem::take(&mut self.pending) {
if hint.is_empty() {
self.out.push_str(&format!("{line}\n"));
} else {
let pad = " ".repeat(width.saturating_sub(line.chars().count()) + 2);
self.out.push_str(&format!("{line}{pad}# {hint}\n"));
}
}
}
fn finish(mut self) -> String {
self.flush();
self.out
}
}
pub fn obsidian_attachment_setting(json: &str) -> Option<String> {
let key = "\"attachmentFolderPath\"";
let rest = &json[json.find(key)? + key.len()..];
let rest = rest.trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
let rest = rest.strip_prefix('"')?;
let mut out = String::new();
let mut chars = rest.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(out),
'\\' => match chars.next()? {
'n' => out.push('\n'),
't' => out.push('\t'),
'u' => {
let hex: String = chars.by_ref().take(4).collect();
let code = u32::from_str_radix(&hex, 16).ok()?;
out.push(char::from_u32(code)?);
}
other => out.push(other),
},
c => out.push(c),
}
}
None
}
fn value(text: &str, key: &str) -> Option<String> {
for line in text.lines() {
let line = strip_comment(line);
let line = line.trim().trim_start_matches(['-', '*', '>', ' ']);
let Some((k, v)) = line.split_once([':', '=']) else {
continue;
};
if k.trim() != key {
continue;
}
let v = v
.trim()
.trim_matches(|c| c == '"' || c == '\'' || c == '`')
.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
None
}
fn values(text: &str, key: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let line = strip_comment(line);
let line = line.trim().trim_start_matches(['-', '*', '>', ' ']);
let (k, v) = line.split_once([':', '='])?;
(k.trim() == key).then(|| {
v.trim()
.trim_matches(|c| c == '"' || c == '\'' || c == '`')
.trim()
.to_string()
})
})
.filter(|v| !v.is_empty())
.collect()
}
fn flag(text: &str, key: &str, default: bool) -> bool {
match value(text, key).as_deref().map(str::to_ascii_lowercase) {
Some(v) => matches!(v.as_str(), "yes" | "true" | "on" | "1"),
None => default,
}
}
fn word<T: Words>(text: &str, key: &str) -> Option<T> {
value(text, key).and_then(|v| T::parse(&v))
}
fn strip_comment(line: &str) -> &str {
if line.trim_start().starts_with("# ") || line.trim() == "#" {
return "";
}
let mut quote: Option<char> = None;
let bytes = line.as_bytes();
for (i, c) in line.char_indices() {
match (quote, c) {
(None, '"') | (None, '\'') => quote = Some(c),
(Some(q), c) if c == q => quote = None,
(None, '#') if bytes.get(i + 1).is_none_or(|b| b.is_ascii_whitespace()) => {
return &line[..i]
}
_ => {}
}
}
line
}
pub fn expand_home(value: &str) -> PathBuf {
match std::env::home_dir() {
Some(home) => expand(value, &home),
None => PathBuf::from(value),
}
}
fn expand(value: &str, home: &Path) -> PathBuf {
match value.strip_prefix("~/") {
Some(rest) => home.join(rest),
None if value == "~" => home.to_path_buf(),
None => PathBuf::from(value),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn word_settings_parse_in_any_case_and_write_back_their_word() {
let c = Config::from_file_text("- theme: LIGHT\n- borders: Square\n- table_style: cards\n");
assert_eq!(c.theme, Theme::Light);
assert_eq!(c.borders, BorderStyle::Square);
assert_eq!(c.table_style, TableStyle::Cards);
assert_eq!(TableStyle::parse("nope"), None);
assert_eq!(c.table_style.name(), "cards");
for c in theme::COLORS {
assert!(theme::COLOR_KEYS.contains(&c.name));
}
}
#[test]
fn reads_markdown_list_settings() {
let text = "## Folders\n\n- notes_dir: /a/b\n- theme: light\n";
assert_eq!(value(text, "notes_dir").as_deref(), Some("/a/b"));
assert_eq!(value(text, "theme").as_deref(), Some("light"));
assert_eq!(value(text, "attachments_dir"), None);
}
#[test]
fn a_markdown_heading_is_not_a_comment_but_prose_after_a_value_is() {
let text = "# Settings\n\n- notes_dir: /a/b # mine\n";
assert_eq!(value(text, "notes_dir").as_deref(), Some("/a/b"));
}
#[test]
fn a_hash_inside_a_quoted_path_is_not_a_comment() {
let text = "- notes_dir: \"/a/c#1/notes\"\n";
assert_eq!(value(text, "notes_dir").as_deref(), Some("/a/c#1/notes"));
}
#[test]
fn the_generated_document_round_trips_to_the_same_config() {
let mut palette = theme::base(Mode::Light);
palette.accent = theme::parse_color("#00ff88").unwrap();
let c = Config {
theme: Theme::Light,
palette,
page_width: 72,
borders: BorderStyle::None,
bold_headings: false,
key_hints: false,
window_title: false,
autosave_ms: 1500,
tab_width: 4,
rename_files: false,
update_links: false,
status_words: true,
table_style: TableStyle::Cards,
preview_click: PreviewClick::Edit,
front_matter: FrontMatter::Hide,
status_bar_items: vec![
StatusItem::Name,
StatusItem::Properties,
StatusItem::Message,
],
wikilinks: false,
quick_open_recursive: false,
quick_open_browse: true,
quick_open_dirs: vec![PathBuf::from("/vault"), PathBuf::from("/work")],
..Default::default()
};
let back = Config::from_str(&c.to_document());
if std::env::var_os("CATCHER_DIR").is_none() {
assert_eq!(back, c);
}
}
#[test]
fn the_environment_points_the_session_elsewhere_but_is_never_written_back() {
let text = "- notes_dir: /a/b\n";
let on_disk = Config::from_file_text(text);
assert_eq!(on_disk.notes_dir, PathBuf::from("/a/b"));
assert!(on_disk.to_document().contains("notes_dir: /a/b"));
}
#[test]
fn quick_open_can_be_told_to_open_on_the_tree_instead_of_the_list() {
assert!(!Config::default().quick_open_browse);
assert!(Config::from_str("- quick_open_mode: browse\n").quick_open_browse);
assert!(Config::from_str("- quick_open_mode: tree\n").quick_open_browse);
assert!(!Config::from_str("- quick_open_mode: search\n").quick_open_browse);
let c = Config {
quick_open_browse: true,
..Default::default()
};
assert!(Config::from_str(&c.to_document()).quick_open_browse);
}
#[test]
fn linked_mentions_is_on_by_default_and_can_be_turned_off() {
assert!(Config::default().linked_mentions);
assert!(!Config::from_str("- linked_mentions: no\n").linked_mentions);
let c = Config {
linked_mentions: false,
..Default::default()
};
assert!(!Config::from_str(&c.to_document()).linked_mentions);
}
#[test]
fn autocomplete_is_on_by_default_and_can_be_turned_off() {
assert!(Config::default().autocomplete);
assert!(!Config::from_str("- autocomplete: off\n").autocomplete);
let c = Config {
autocomplete: false,
..Default::default()
};
assert!(!Config::from_str(&c.to_document()).autocomplete);
}
#[test]
fn status_words_is_off_by_default_and_can_be_turned_on() {
assert!(!Config::default().status_words);
assert!(Config::from_str("- status_words: on\n").status_words);
assert!(!Config::from_str("- status_words: off\n").status_words);
}
#[test]
fn update_links_is_on_by_default_and_can_be_turned_off() {
assert!(Config::default().update_links);
assert!(!Config::from_str("- update_links: no\n").update_links);
let c = Config {
update_links: false,
..Default::default()
};
assert!(!Config::from_str(&c.to_document()).update_links);
}
#[test]
fn wikilinks_can_be_turned_off() {
assert!(Config::default().wikilinks);
assert!(!Config::from_str("- wikilinks: no\n").wikilinks);
let c = Config {
wikilinks: false,
..Default::default()
};
assert!(!Config::from_str(&c.to_document()).wikilinks);
}
#[test]
fn tags_can_be_turned_off() {
assert!(Config::default().tags);
assert!(!Config::from_str("- tags: no\n").tags);
let c = Config {
tags: false,
..Default::default()
};
assert!(!Config::from_str(&c.to_document()).tags);
}
#[test]
fn front_matter_takes_dim_show_or_hide_and_defaults_to_dim() {
assert_eq!(Config::default().front_matter, FrontMatter::Dim);
let read = |v: &str| Config::from_str(&format!("- front_matter: {v}\n")).front_matter;
assert_eq!(read("show"), FrontMatter::Show);
assert_eq!(read("hide"), FrontMatter::Hide);
assert_eq!(read("dim"), FrontMatter::Dim);
assert_eq!(read("sometimes"), FrontMatter::Dim);
assert_eq!(
Config::from_str("nothing set\n").front_matter,
FrontMatter::Dim
);
}
#[test]
fn properties_is_a_status_item_a_user_has_to_ask_for() {
assert!(!Config::default()
.status_bar_items
.contains(&StatusItem::Properties));
let c = Config::from_str("- status_bar_items: path, properties, keys\n");
assert_eq!(
c.status_bar_items,
vec![StatusItem::Path, StatusItem::Properties, StatusItem::Keys]
);
assert!(c.to_document().contains("properties"));
assert_eq!(
Config::from_str(&c.to_document()).status_bar_items,
c.status_bar_items
);
}
#[test]
fn an_older_settings_file_is_spotted_and_its_values_survive_the_rewrite() {
let old = "# Settings\n\n## Folders\n\n- notes_dir: /vault\n\n## Appearance\n\n - theme: light\n\nA paragraph of prose that used to live here.\n";
let c = Config::from_str(old);
assert!(!covers_every_setting(old, &c));
let fresh = c.to_document();
assert!(covers_every_setting(&fresh, &c));
let back = Config::from_str(&fresh);
assert_eq!(back.theme, Theme::Light);
if std::env::var_os("CATCHER_DIR").is_none() {
assert_eq!(back.notes_dir, PathBuf::from("/vault"));
}
assert!(!fresh.contains("A paragraph of prose"));
}
#[test]
fn a_current_settings_file_is_left_alone() {
let c = Config::default();
assert!(covers_every_setting(&c.to_document(), &c));
let edited = c
.to_document()
.replace("- theme: auto", "- theme: auto # mine");
assert!(covers_every_setting(&edited, &c));
}
#[test]
fn prose_lines_are_not_mistaken_for_settings() {
let keys = setting_keys("#rrggbb · #rgb · red\n- accent: #ff9e64\n- a thing: no\n");
assert!(keys.contains("accent"));
assert_eq!(keys.len(), 1);
}
#[test]
fn every_default_survives_a_round_trip() {
let c = Config::default();
let back = Config::from_str(&c.to_document());
if std::env::var_os("CATCHER_DIR").is_none() {
assert_eq!(back, c);
}
}
#[test]
fn the_defaults_are_catcher_s_own_directory() {
let home = std::env::home_dir().unwrap_or_else(|| PathBuf::from("."));
let c = Config::from_str("nothing set\n");
if std::env::var_os("CATCHER_DIR").is_none() {
assert_eq!(c.notes_dir, default_notes_dir(&home));
}
assert_eq!(c.attachments_dir, c.notes_dir.join("attachments"));
}
#[test]
fn attachments_follow_a_changed_notes_dir_unless_set_themselves() {
let c = Config::from_str("- notes_dir: /vault\n");
if std::env::var_os("CATCHER_DIR").is_none() {
assert_eq!(c.attachments_dir, PathBuf::from("/vault/attachments"));
let c = Config::from_str("- notes_dir: /vault\n- attachments_dir: /pics\n");
assert_eq!(c.attachments_dir, PathBuf::from("/pics"));
}
}
#[test]
fn the_daily_note_settings_default_to_journal_and_round_trip() {
let c = Config::default();
assert_eq!(c.daily_dir, PathBuf::from("journal"));
assert_eq!(c.daily_template, PathBuf::from("journal/template.md"));
assert_eq!(c.daily_format, "YYYY-MM-DD");
assert_eq!(c.daily_dir(), c.notes_dir.join("journal"));
assert_eq!(c.daily_template(), c.notes_dir.join("journal/template.md"));
let c = Config {
daily_dir: PathBuf::from("/vault/daily"),
daily_template: PathBuf::from("templates/day.md"),
daily_format: "YYYY/MM/DD-MM-YYYY".to_string(),
..Default::default()
};
assert_eq!(c.daily_dir(), PathBuf::from("/vault/daily"));
let back = Config::from_str(&c.to_document());
assert_eq!(back.daily_dir, c.daily_dir);
assert_eq!(back.daily_template, c.daily_template);
assert_eq!(back.daily_format, c.daily_format);
assert_eq!(
Config::from_str("- daily_format: /YYYY/").daily_format,
"YYYY"
);
let home = std::env::home_dir().unwrap_or_else(|| PathBuf::from("."));
let c = Config::from_str("- daily_dir: ~/days\n");
assert_eq!(c.daily_dir, home.join("days"));
}
#[test]
fn paths_expand_a_leading_tilde() {
let home = PathBuf::from("/home/x");
assert_eq!(expand("~/notes", &home), PathBuf::from("/home/x/notes"));
assert_eq!(expand("/abs", &home), PathBuf::from("/abs"));
}
#[test]
fn theme_defaults_to_dark_and_only_light_flips_it() {
assert_eq!(Config::from_str("").theme, Theme::Auto);
assert_eq!(Config::from_str("- theme: light").theme, Theme::Light);
assert_eq!(Config::from_str("- theme: dark").theme, Theme::Dark);
assert_eq!(Config::from_str("- theme: lite").theme, Theme::Auto);
assert_eq!(Theme::Dark.mode(), Mode::Dark);
assert_eq!(Theme::Light.mode(), Mode::Light);
}
#[test]
fn colours_override_the_theme_they_sit_on() {
let c = Config::from_str("- theme: dark\n- accent: #00ff88\n");
assert_eq!(c.palette.accent, theme::parse_color("#00ff88").unwrap());
assert_eq!(c.palette.dim, theme::DARK.dim);
let c = Config::from_str("- accent: chartreuse-ish\n");
assert_eq!(c.palette.accent, theme::DARK.accent);
}
#[test]
fn colour_shorthand_and_names_parse() {
assert_eq!(theme::parse_color("#f80"), theme::parse_color("#ff8800"));
assert!(theme::parse_color("brightblue").is_some());
assert!(theme::parse_color("#gg0000").is_none());
assert!(theme::parse_color("#ff00").is_none());
}
#[test]
fn numbers_are_clamped_to_something_usable() {
assert_eq!(Config::from_str("- tab_width: 99").tab_width, 16);
assert_eq!(Config::from_str("- tab_width: 0").tab_width, 1);
assert_eq!(
Config::from_str("- autosave_ms: 999999").autosave_ms,
60_000
);
assert_eq!(Config::from_str("- page_width: full").page_width, 0);
}
#[test]
fn extra_quick_open_folders_can_be_repeated_or_listed() {
let home = std::env::home_dir().unwrap_or_else(|| PathBuf::from("."));
let c = Config::from_str("- quick_open_dirs: /vault\n- quick_open_dirs: ~/work\n");
assert_eq!(
c.quick_open_dirs,
vec![PathBuf::from("/vault"), home.join("work")]
);
let c = Config::from_str("- quick_open_dirs: /a, /b\n");
assert_eq!(
c.quick_open_dirs,
vec![PathBuf::from("/a"), PathBuf::from("/b")]
);
assert!(Config::from_str("- quick_open_dirs:\n")
.quick_open_dirs
.is_empty());
}
#[test]
fn the_status_bar_is_a_list_of_parts_in_the_order_given() {
let c = Config::from_str("- status_bar_items: name, keys\n");
assert_eq!(c.status_bar_items, vec![StatusItem::Name, StatusItem::Keys]);
let c = Config::from_str("- status_bar_items: keys, weather, keys, path\n");
assert_eq!(c.status_bar_items, vec![StatusItem::Keys, StatusItem::Path]);
assert_eq!(
Config::from_str("- status_bar_items: weather\n").status_bar_items,
Config::default().status_bar_items
);
assert_eq!(
Config::from_str("").status_bar_items,
vec![StatusItem::Path, StatusItem::Message, StatusItem::Keys]
);
}
#[test]
fn the_search_key_is_written_and_read_back() {
use crate::keys::Action;
let c = Config::default();
assert!(c.to_document().contains("- key_search: ctrl+⇧F"));
let back = Config::from_str("- key_search: f3\n");
assert_eq!(back.keys.label(Action::SearchAll), "F3");
}
#[test]
fn properties_is_a_reading_setting_that_round_trips() {
let c = Config::from_str("- properties: line\n");
assert_eq!(c.properties, Properties::Line);
assert_eq!(
Config::from_str("- properties: hide\n").properties,
Properties::Hide
);
assert_eq!(Config::from_str("").properties, Properties::Box);
assert!(c.to_document().contains("- properties: line"));
}
#[test]
fn setting_one_value_leaves_the_rest_of_the_document_alone() {
let text = "# Settings\n\n- properties: box # front matter as a box · one line · hide\n- wikilinks: yes\n";
let out = with_value(text, "properties", "line");
assert_eq!(
out,
"# Settings\n\n- properties: line # front matter as a box · one line · hide\n- wikilinks: yes\n"
);
assert_eq!(Config::from_str(&out).properties, Properties::Line);
let out = with_value("- wikilinks: yes\n", "properties", "hide");
assert!(out.ends_with("- wikilinks: yes\n- properties: hide\n"));
assert_eq!(
with_value("- front_matter: dim\n", "front_matter", "hide"),
"- front_matter: hide\n"
);
}
#[test]
fn flags_take_the_words_a_person_would_type() {
assert!(!Config::from_str("- key_hints: no").key_hints);
assert!(!Config::from_str("- key_hints: false").key_hints);
assert!(Config::from_str("- key_hints: yes").key_hints);
assert!(Config::from_str("").key_hints);
}
#[test]
fn reads_the_attachment_folder_from_obsidian_app_json() {
let json = r#"{ "promptDelete": false, "attachmentFolderPath": "Files/img", "x": 1 }"#;
assert_eq!(
obsidian_attachment_setting(json).as_deref(),
Some("Files/img")
);
let json = "{\n \"attachmentFolderPath\" : \"./\"\n}";
assert_eq!(obsidian_attachment_setting(json).as_deref(), Some("./"));
assert_eq!(
obsidian_attachment_setting(r#"{"attachmentFolderPath":"a \"q\" \u0041"}"#).as_deref(),
Some("a \"q\" A")
);
assert_eq!(obsidian_attachment_setting(r#"{"other": "x"}"#), None);
assert_eq!(
obsidian_attachment_setting(r#"{"attachmentFolderPath": 3}"#),
None
);
assert_eq!(
obsidian_attachment_setting(r#"{"attachmentFolderPath": "open"#),
None
);
}
#[test]
fn obsidian_attachment_settings_map_onto_the_lookup() {
let mut c = Config {
notes_dir: PathBuf::from("/v"),
attachments_dir: PathBuf::from("/v/attachments"),
..Default::default()
};
c.set_obsidian_attachments("Files");
assert_eq!(c.attachments_dir, PathBuf::from("/v/Files"));
assert_eq!(c.attachment_subfolder, "attachments");
c.set_obsidian_attachments("./");
assert_eq!(c.attachments_dir, PathBuf::from("/v"));
assert_eq!(c.attachment_subfolder, ".");
c.set_obsidian_attachments("./_media");
assert_eq!(c.attachments_dir, PathBuf::from("/v/_media"));
assert_eq!(c.attachment_subfolder, "_media");
let dir = std::env::temp_dir().join("catcher-obsidian-cfg-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".obsidian")).unwrap();
std::fs::write(
dir.join(".obsidian/app.json"),
r#"{"attachmentFolderPath": "Files"}"#,
)
.unwrap();
let mut c = Config {
notes_dir: dir.clone(),
attachments_dir: dir.join("attachments"),
..Default::default()
};
c.adopt_obsidian_attachments();
assert_eq!(c.attachments_dir, dir.join("Files"));
let mut c = Config {
notes_dir: dir.clone(),
attachments_dir: PathBuf::from("/pics"),
..Default::default()
};
c.adopt_obsidian_attachments();
assert_eq!(c.attachments_dir, PathBuf::from("/pics"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn links_are_relative_inside_the_notes_dir() {
let c = Config {
notes_dir: PathBuf::from("/n"),
attachments_dir: PathBuf::from("/n/attachments"),
..Default::default()
};
assert_eq!(
c.link_for(Path::new("/n/attachments/a.png")),
"attachments/a.png"
);
assert_eq!(c.link_for(Path::new("/other/a.png")), "/other/a.png");
}
#[test]
fn an_untouched_colour_is_written_as_the_word_theme() {
let c = Config::default();
let doc = c.to_document();
assert!(doc.contains("- code_bg: theme"));
assert_eq!(Config::from_str(&doc).palette.code_bg, theme::DARK.code_bg);
}
#[test]
fn a_pinned_colour_is_written_as_its_hex_and_only_that_one_is() {
let mut c = Config::default();
c.palette.accent = theme::parse_color("#00ff88").unwrap();
let doc = c.to_document();
assert!(doc.contains("- accent: #00ff88"));
assert!(doc.contains("- dim: theme"));
}
#[test]
fn the_editing_command_keys_are_written_unbound_and_read_back_bound() {
use crate::keys::Action;
let doc = Config::default().to_document();
for key in [
"key_checkbox",
"key_line_up",
"key_line_down",
"key_heading",
"key_date",
"key_copy_path",
"key_reveal",
"key_split_right",
"key_split_down",
"key_new_tab",
] {
assert!(doc.contains(&format!("- {key}: none")), "{key}");
}
let c = Config::from_str(&doc.replace("- key_date: none", "- key_date: ^D"));
assert_eq!(c.keys.label(Action::InsertDate), "^D");
assert!(c.to_document().contains("- key_date: ^D"));
}
#[test]
fn switching_the_theme_moves_every_colour_the_user_has_not_pinned() {
let doc = Config::default().to_document();
let light = doc.replace("- theme: auto", "- theme: light");
let c = Config::from_str(&light);
assert_eq!(c.theme, Theme::Light);
assert_eq!(c.palette.code_bg, theme::LIGHT.code_bg);
assert_eq!(c.palette.accent, theme::LIGHT.accent);
}
}