use koan_core::config;
use koan_core::db::connection::Database;
use owo_colors::OwoColorize;
pub fn cmd_config() {
let base_path = config::config_file_path();
let local_path = config::config_local_file_path();
println!("{}", "sources".bold());
if base_path.exists() {
println!(" {} {}", "config:".cyan(), base_path.display());
} else {
println!(
" {} {} {}",
"config:".cyan(),
base_path.display(),
"(not found)".red().dimmed()
);
}
if local_path.exists() {
println!(" {} {}", "config.local:".cyan(), local_path.display());
} else {
println!(
" {} {} {}",
"config.local:".cyan(),
local_path.display(),
"(not found)".dimmed()
);
}
println!(" {} {}", "db:".cyan(), config::db_path().display());
let env_overrides: Vec<_> = std::env::vars()
.filter(|(k, _)| k.starts_with("KOAN_"))
.collect();
if !env_overrides.is_empty() {
println!(
" {} {} active",
"env:".cyan(),
format!("{} KOAN_* vars", env_overrides.len()).green()
);
for (k, _) in &env_overrides {
println!(" {}", k.dimmed());
}
}
println!();
println!("{}", "resolved".bold());
let cfg = config::Config::load().unwrap_or_default();
match toml::to_string_pretty(&cfg) {
Ok(s) => print!("{}", s),
Err(e) => eprintln!("{} {}", "error:".red().bold(), e),
}
}
pub fn cmd_init() {
let dir = config::config_dir();
let config_path = config::config_file_path();
let local_path = config::config_local_file_path();
let cache_dir = config::Config::default().cache_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("{} {}", "error:".red().bold(), e);
std::process::exit(1);
}
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
eprintln!("{} {}", "error:".red().bold(), e);
std::process::exit(1);
}
println!("{} {}", "dir".cyan(), dir.display());
{
let already_exists = config_path.exists();
let existing_base: toml::map::Map<String, toml::Value> = if already_exists {
let contents = std::fs::read_to_string(&config_path).unwrap_or_default();
toml::from_str::<toml::Value>(&contents)
.ok()
.and_then(|v| v.as_table().cloned())
.unwrap_or_default()
} else {
toml::map::Map::new()
};
let output = generate_config_template(&existing_base);
if let Err(e) = std::fs::write(&config_path, output) {
eprintln!("{} {}", "error:".red().bold(), e);
} else {
let action = if already_exists { "updated" } else { "created" };
println!(
" {} {} {}",
"config:".cyan(),
config_path.display(),
action.green()
);
}
}
if local_path.exists() {
println!(
" {} {} {}",
"config.local:".cyan(),
local_path.display(),
"(exists)".dimmed()
);
} else {
let default_folders = config::Config::default().library.folders;
let folders_str = default_folders
.iter()
.map(|p| format!("\"{}\"", p.display()))
.collect::<Vec<_>>()
.join(", ");
let local_content = format!(
r#"# koan — machine-specific overrides (gitignored)
# Edit the paths below, then run: koan scan
[library]
folders = [{folders_str}]
# Uncomment to connect a Navidrome/Subsonic server:
# (run `koan remote login URL username` instead for interactive setup)
#
# [remote]
# enabled = true
# url = "https://music.example.com"
# username = "admin"
# password = ""
"#
);
if let Err(e) = std::fs::write(&local_path, local_content) {
eprintln!("{} {}", "error:".red().bold(), e);
} else {
println!(
" {} {} {}",
"config.local:".cyan(),
local_path.display(),
"created".green()
);
}
}
let gitignore_path = dir.join(".gitignore");
if !gitignore_path.exists() {
let gitignore_content = "*.log\n*.db\n*.db-wal\n*.db-shm\nconfig.local.toml\ncache/\n";
if let Err(e) = std::fs::write(&gitignore_path, gitignore_content) {
eprintln!("{} {}", "error:".red().bold(), e);
} else {
println!(
" {} {} {}",
".gitignore:".cyan(),
gitignore_path.display(),
"created".green()
);
}
}
let db_path = config::db_path();
if db_path.exists() {
println!(
" {} {} {}",
"db:".cyan(),
db_path.display(),
"(exists)".dimmed()
);
} else {
match Database::open_default() {
Ok(_) => println!(
" {} {} {}",
"db:".cyan(),
db_path.display(),
"created".green()
),
Err(e) => eprintln!("{} {}", "error:".red().bold(), e),
}
}
println!(
" {} {} {}",
"cache:".cyan(),
cache_dir.display(),
"ready".green()
);
println!(" {} {}", "log:".cyan(), dir.join("koan.log").display());
}
const RETIRED_SECTIONS: &[&str] = &["discovery"];
fn generate_config_template(existing_base: &toml::map::Map<String, toml::Value>) -> String {
let defaults = config::Config::default();
let default_toml = toml::to_string_pretty(&defaults).expect("default config serializes");
let mut output = String::from(
"# koan — shareable defaults (safe to commit to dotfiles)\n\
# Uncomment to customise. Run `koan config` to see resolved values.\n\n",
);
let held_back = |section: &str| -> String {
let mut out = String::new();
if section == "organize"
&& let Some(v) = existing_base
.get("organize")
.and_then(|s| s.as_table())
.and_then(|t| t.get("default"))
{
out.push_str(&format!("default = {}\n", format_toml_value(v)));
}
out
};
let mut current_section = String::new();
let mut section_buf = String::new();
let mut section_has_content = false;
for line in default_toml.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
let extra = held_back(¤t_section);
if section_has_content || !extra.is_empty() {
section_buf.push_str(&extra);
output.push_str(§ion_buf);
output.push('\n');
}
section_buf.clear();
section_has_content = false;
current_section = trimmed
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
section_buf.push_str(line);
section_buf.push('\n');
continue;
}
if trimmed.is_empty() {
continue;
}
if let Some((key, default_val_str)) = trimmed.split_once(" = ") {
let key = key.trim();
if config::layer_of(&format!("{}.{}", current_section, key)) == config::Layer::Machine {
continue;
}
let base_section = existing_base
.get(¤t_section)
.and_then(|v| v.as_table());
let user_val = base_section
.and_then(|t| t.get(key))
.filter(|v| format_toml_value(v) != default_val_str);
if let Some(user_val) = user_val {
section_buf.push_str(&format!("{} = {}\n", key, format_toml_value(user_val)));
} else {
section_buf.push_str(&format!("# {} = {}\n", key, default_val_str));
}
section_has_content = true;
}
}
let extra = held_back(¤t_section);
if section_has_content || !extra.is_empty() {
section_buf.push_str(&extra);
output.push_str(§ion_buf);
output.push('\n');
}
if let Some(patterns) = existing_base
.get("organize")
.and_then(|s| s.as_table())
.and_then(|t| t.get("patterns"))
.and_then(|v| v.as_table())
.filter(|t| !t.is_empty())
{
output.push_str("[organize.patterns]\n");
for (name, pattern) in patterns {
output.push_str(&format!("{} = {}\n", name, format_toml_value(pattern)));
}
output.push('\n');
}
let default_val = toml::Value::try_from(&defaults).expect("default config serializes");
let default_table = default_val.as_table().unwrap();
for (section_name, section_val) in existing_base {
if default_table.contains_key(section_name)
|| RETIRED_SECTIONS.contains(§ion_name.as_str())
{
continue;
}
if let Some(table) = section_val.as_table() {
output.push_str(&format!("[{}]\n", section_name));
for (key, value) in table {
output.push_str(&format!("{} = {}\n", key, format_toml_value(value)));
}
output.push('\n');
}
}
output
}
fn format_toml_value(val: &toml::Value) -> String {
match val {
toml::Value::String(s) => format!("\"{}\"", s),
toml::Value::Integer(i) => i.to_string(),
toml::Value::Float(f) => {
if f.fract() == 0.0 {
format!("{:.1}", f)
} else {
f.to_string()
}
}
toml::Value::Boolean(b) => b.to_string(),
toml::Value::Array(a) => {
let items: Vec<String> = a.iter().map(format_toml_value).collect();
format!("[{}]", items.join(", "))
}
toml::Value::Table(_) => {
toml::to_string(val).unwrap_or_else(|_| "{}".into())
}
toml::Value::Datetime(d) => d.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn template_from(existing: &str) -> String {
let table = toml::from_str::<toml::Value>(existing)
.unwrap()
.as_table()
.cloned()
.unwrap();
generate_config_template(&table)
}
#[test]
fn the_generated_template_is_valid_toml() {
let out = template_from(
r#"
[organize]
default = "mine"
[organize.patterns]
mine = "%artist%/%title%"
"#,
);
let parsed: config::Config = toml::from_str(&out).expect("template must parse");
assert_eq!(parsed.organize.default.as_deref(), Some("mine"));
assert_eq!(parsed.organize.patterns["mine"], "%artist%/%title%");
}
#[test]
fn hand_written_patterns_survive_regeneration() {
let out = template_from(
r#"
[organize.patterns]
va = "%album artist%/%album%/%title%"
"#,
);
assert!(out.contains("[organize.patterns]"), "{out}");
assert!(
out.contains(r#"va = "%album artist%/%album%/%title%""#),
"{out}"
);
}
#[test]
fn values_matching_the_default_go_back_to_being_comments() {
let out = template_from("[playback]\ntarget_fps = 60\nshow_fps = true\n");
assert!(out.contains("# target_fps = 60"), "{out}");
assert!(out.contains("\nshow_fps = true"), "{out}");
}
#[test]
fn retired_settings_are_not_resurrected() {
let out = template_from(
"[playback]\nticker_fps = 8\n\n[radio]\nuse_subsonic = true\n\n\
[discovery]\nacoustic_weight = 0.5\n",
);
for gone in [
"ticker_fps",
"use_subsonic",
"[discovery]",
"acoustic_weight",
] {
assert!(!out.contains(gone), "{gone} should be gone:\n{out}");
}
}
#[test]
fn sections_this_build_does_not_know_are_kept() {
let out = template_from("[dsp]\ncrossfeed = true\n");
assert!(out.contains("[dsp]"), "{out}");
assert!(out.contains("crossfeed = true"), "{out}");
}
#[test]
fn machine_settings_never_reach_the_shared_template() {
let out = template_from("[playback]\nart_size = 56\n\n[library]\nfolders = [\"/music\"]\n");
for machine in ["art_size", "folders", "output_device", "[subsonic]"] {
assert!(!out.contains(machine), "{machine} should be gone:\n{out}");
}
}
}