use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Once};
use std::time::SystemTime;
use figment::Figment;
use figment::providers::{Env, Format, Serialized, Toml};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
#[error("config error: {0}")]
Figment(#[from] Box<figment::Error>),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub library: LibraryConfig,
pub playback: PlaybackConfig,
pub remote: RemoteConfig,
pub organize: OrganizeConfig,
#[serde(alias = "visualiser")]
pub visualizer: VisualizerConfig,
pub graphql: GraphqlConfig,
pub subsonic: SubsonicConfig,
pub auth: AuthConfig,
pub sharing: SharingConfig,
pub mcp: McpConfig,
pub push: PushConfig,
pub devices: DevicesConfig,
pub dsp: DspConfig,
pub appearance: AppearanceConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AppearanceConfig {
pub theme: String,
pub theme_icons: bool,
pub record_colours: bool,
pub wash_window: bool,
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
theme: "koan".into(),
theme_icons: true,
record_colours: true,
wash_window: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SharingConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_url: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct McpConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redirect_hosts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PushConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub key_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub team_id: String,
pub topic: String,
}
impl Default for PushConfig {
fn default() -> Self {
Self {
key_path: None,
key: None,
key_id: String::new(),
team_id: String::new(),
topic: "cc.blit.koan".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LibraryConfig {
pub folders: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaybackConfig {
pub replaygain: ReplayGainMode,
pub target_fps: u8,
pub show_fps: bool,
pub pre_amp_db: f64,
pub fade_on_pause: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_device: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub renderer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub renderer_name: Option<String>,
pub renderers: bool,
pub muted: bool,
pub art_size: u16,
pub rate_switch_lead_in_ms: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReplayGainMode {
Off,
Track,
Album,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RemoteConfig {
pub enabled: bool,
pub url: String,
pub username: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub password: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub api_key: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub device_key: String,
pub cache_dir: Option<PathBuf>,
pub download_workers: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_limit: Option<String>,
pub auto_sync: bool,
pub auto_sync_interval_mins: u64,
pub play_queue: bool,
}
impl Default for LibraryConfig {
fn default() -> Self {
let music_dir = dirs::audio_dir().unwrap_or_else(|| {
dirs::home_dir()
.map(|h| h.join("Music"))
.unwrap_or_else(|| PathBuf::from("/Music"))
});
Self {
folders: vec![music_dir],
}
}
}
impl Default for PlaybackConfig {
fn default() -> Self {
Self {
replaygain: ReplayGainMode::Off,
target_fps: 60,
show_fps: false,
pre_amp_db: 0.0,
fade_on_pause: true,
output_device: None,
renderer: None,
renderer_name: None,
renderers: true,
muted: false,
art_size: 24,
rate_switch_lead_in_ms: 1000,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VisualizerConfig {
pub enabled: bool,
pub fps: u8,
pub mode: String,
pub scale: String,
pub amplitude_scale: String,
pub bar_decay_ms: u32,
pub peak_decay_ms: u32,
pub palette: String,
pub reactivity: f32,
pub bass_shake: bool,
pub matrix_overlay: bool,
pub reactive_bg: bool,
}
impl Default for VisualizerConfig {
fn default() -> Self {
Self {
enabled: true,
fps: 60,
mode: "bars".into(),
scale: "bark".into(),
amplitude_scale: "aweight".into(),
bar_decay_ms: 50,
peak_decay_ms: 180,
palette: "spectrum".into(),
reactivity: 1.0,
bass_shake: true,
matrix_overlay: false,
reactive_bg: false,
}
}
}
impl Default for RemoteConfig {
fn default() -> Self {
Self {
enabled: false,
url: String::new(),
username: String::new(),
password: String::new(),
api_key: String::new(),
device_key: String::new(),
cache_dir: None,
download_workers: 5,
cache_limit: None,
auto_sync: true,
auto_sync_interval_mins: 60,
play_queue: false,
}
}
}
pub fn parse_size_bytes(s: &str) -> Option<u64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let mut num_end = 0;
for (i, c) in s.char_indices() {
if c.is_ascii_digit() || c == '.' {
num_end = i + c.len_utf8();
} else if !c.is_whitespace() {
break;
}
}
let num_str = s[..num_end].trim();
let suffix = s[num_end..].trim().to_ascii_uppercase();
let value: f64 = num_str.parse().ok()?;
let multiplier: u64 = match suffix.as_str() {
"" | "B" => 1,
"KB" | "K" => 1024,
"MB" | "M" => 1024 * 1024,
"GB" | "G" => 1024 * 1024 * 1024,
"TB" | "T" => 1024 * 1024 * 1024 * 1024,
_ => return None,
};
Some((value * multiplier as f64) as u64)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OrganizeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub patterns: HashMap<String, String>,
#[serde(default = "default_true")]
pub move_ancillary: bool,
}
impl Default for OrganizeConfig {
fn default() -> Self {
Self {
default: None,
patterns: HashMap::new(),
move_ancillary: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GraphqlConfig {
pub enabled: bool,
pub port: u16,
#[serde(default = "default_bind")]
pub bind: std::net::IpAddr,
pub playground: bool,
pub auth_enabled: bool,
pub access_token_ttl: String,
pub refresh_token_ttl: String,
pub cors_origins: Vec<String>,
pub allowed_hosts: Vec<String>,
pub cookie_secure: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub proxy_auth_header: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub proxy_auth_from: Vec<String>,
pub allow_organize: bool,
}
fn default_true() -> bool {
true
}
fn default_bind() -> std::net::IpAddr {
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
}
impl Default for GraphqlConfig {
fn default() -> Self {
Self {
enabled: true,
port: 4000,
bind: default_bind(),
playground: false,
auth_enabled: true,
access_token_ttl: "15m".into(),
refresh_token_ttl: "30d".into(),
cors_origins: Vec::new(),
allowed_hosts: Vec::new(),
cookie_secure: false,
proxy_auth_header: String::new(),
proxy_auth_from: Vec::new(),
allow_organize: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SubsonicConfig {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
pub username: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub password: String,
pub transcode: bool,
pub ffmpeg: String,
}
impl Default for SubsonicConfig {
fn default() -> Self {
Self {
enabled: false,
port: None,
username: "koan".into(),
password: String::new(),
transcode: true,
ffmpeg: "ffmpeg".into(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AuthConfig {
#[serde(skip_serializing_if = "String::is_empty")]
pub server: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub refresh_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DevicesConfig {
pub nearby: bool,
pub discoverable: bool,
pub port: u16,
pub addresses: Vec<String>,
pub nearby_control: NearbyControl,
pub keep_running: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NearbyControl {
#[default]
Full,
Playback,
}
impl Default for DevicesConfig {
fn default() -> Self {
Self {
nearby: true,
discoverable: true,
port: DEVICES_PORT,
addresses: Vec::new(),
nearby_control: NearbyControl::Full,
keep_running: false,
}
}
}
pub const DEVICES_PORT: u16 = 5626;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DspConfig {
pub enabled: bool,
pub profiles: Vec<DspProfile>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub autoeq_dismissed: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tunings: Vec<DspOutputTuning>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub presets: Vec<DspOutputPreset>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DspOutputTuning {
pub device: String,
pub tuning: String,
#[serde(default = "on", skip_serializing_if = "is_on")]
pub on: bool,
}
fn on() -> bool {
true
}
fn is_on(on: &bool) -> bool {
*on
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DspOutputPreset {
pub device: String,
pub preset: String,
}
impl Default for DspConfig {
fn default() -> Self {
Self {
enabled: true,
profiles: Vec::new(),
autoeq_dismissed: Vec::new(),
tunings: Vec::new(),
presets: Vec::new(),
}
}
}
impl DspProfile {
pub fn measured(&self) -> Option<&DspMeasurement> {
self.fitted.as_ref().or(self.measurement.as_ref())
}
}
impl DspConfig {
pub fn profile_for(&self, device: &str) -> Option<&DspProfile> {
if !self.enabled {
return None;
}
self.profiles
.iter()
.find(|p| p.devices.iter().any(|d| d == device))
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DspProfile {
pub name: String,
pub devices: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preamp_db: Option<f64>,
pub filters: Vec<DspFilter>,
pub impulses: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<DspTarget>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub layers: Vec<DspLayer>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub group: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub preset: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<DspScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<DspRole>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub measurement: Option<DspMeasurement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fitted: Option<DspMeasurement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tuned_for: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original: Option<DspOriginal>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DspOriginal {
pub filters: Vec<DspFilter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preamp_db: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DspRole {
Correction,
Tuning,
Baked,
}
impl DspRole {
pub fn corrects(self) -> bool {
matches!(self, Self::Correction | Self::Baked)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DspMeasurement {
pub ear: DspEar,
pub target: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DspEar {
In,
Over,
Speaker,
}
pub mod dsp_bounds {
use std::ops::RangeInclusive;
pub const FREQ_HZ: RangeInclusive<f64> = 1.0..=48_000.0;
pub const GAIN_DB: RangeInclusive<f64> = -30.0..=30.0;
pub const Q: RangeInclusive<f64> = 0.01..=100.0;
pub const DELAY_MS: RangeInclusive<f64> = 0.0..=2_000.0;
pub const DELAY_SAMPLES: RangeInclusive<f64> = 0.0..=768_000.0;
pub const MIX_GAIN: f64 = 31.63;
pub const CHANNELS: u16 = 64;
pub const BANDS_PER_CHANNEL: usize = 64;
pub const FILTERS: usize = 256;
pub const GRAPHIC_POINTS: usize = 2_048;
pub const LAYERS: usize = 32;
pub const NAME: usize = 128;
pub const CHAIN_DELAY_MS: f64 = 2_000.0;
pub const CHAIN_GRAPHICS: usize = 2;
pub const CHAIN_MIXES: usize = 8;
pub const IMPULSE_TAPS: usize = 262_145;
}
pub fn budget(filters: &mut Vec<DspFilter>) -> Vec<String> {
use dsp_bounds as b;
let mut notes = Vec::new();
let mut note = |n: String| {
if !notes.contains(&n) {
notes.push(n);
}
};
let channels = b::CHANNELS as usize;
let mut delay = vec![0.0f64; channels];
let mut graphics = vec![0usize; channels];
let mut mixes = 0;
let on = |cs: &[u16], c: usize| cs.is_empty() || cs.contains(&(c as u16));
filters.retain_mut(|f| match f {
DspFilter::Delay(d) => {
let ms = d.ms + d.samples / 44.1;
let room = (0..channels)
.filter(|c| on(&d.channels, *c))
.map(|c| b::CHAIN_DELAY_MS - delay[c])
.fold(b::CHAIN_DELAY_MS, f64::min)
.max(0.0);
if ms > room {
note(format!(
"delays shortened to {} ms in all",
b::CHAIN_DELAY_MS
));
if room <= 0.0 {
return false;
}
let scale = room / ms;
d.ms *= scale;
d.samples = (d.samples * scale).floor();
}
let ms = d.ms + d.samples / 44.1;
for (c, total) in delay.iter_mut().enumerate() {
if on(&d.channels, c) {
*total += ms;
}
}
true
}
DspFilter::Graphic(g) => {
let full =
(0..channels).any(|c| on(&g.channels, c) && graphics[c] == b::CHAIN_GRAPHICS);
if full {
note(format!(
"graphic EQs past {} a channel dropped",
b::CHAIN_GRAPHICS
));
return false;
}
for (c, n) in graphics.iter_mut().enumerate() {
if on(&g.channels, c) {
*n += 1;
}
}
true
}
DspFilter::Mix(_) => {
mixes += 1;
if mixes > b::CHAIN_MIXES {
note(format!("mixes past {} dropped", b::CHAIN_MIXES));
return false;
}
true
}
DspFilter::Band(_) => true,
});
notes
}
impl DspProfile {
pub fn sanitize(&mut self) -> Vec<String> {
use dsp_bounds as b;
let mut notes: Vec<String> = Vec::new();
let mut note = |n: String| {
if !notes.contains(&n) {
notes.push(n);
}
};
let mut clamp = |what: &str, v: &mut f64, r: &std::ops::RangeInclusive<f64>, unit: &str| {
let c = v.clamp(*r.start(), *r.end());
if c != *v {
note(format!(
"{what} clamped to {}{unit}",
if *v > *r.end() { r.end() } else { r.start() }
));
*v = c;
}
};
let finite = |v: f64| v.is_finite();
let mut dropped = Vec::new();
let cleaned: String = self
.name
.chars()
.filter(|c| !c.is_control())
.take(b::NAME)
.collect();
let cleaned = if cleaned.trim().is_empty() {
"Profile".to_owned()
} else {
cleaned
};
if cleaned != self.name {
dropped.push("name shortened".to_owned());
self.name = cleaned;
}
if let Some(db) = self.preamp_db.as_mut() {
if finite(*db) {
clamp("preamp", db, &b::GAIN_DB, " dB");
} else {
self.preamp_db = None;
dropped.push("preamp dropped".to_owned());
}
}
let bad_channel = |cs: &[u16]| cs.iter().any(|c| *c >= b::CHANNELS);
let mut per_channel = [0usize; b::CHANNELS as usize];
let before = self.filters.len();
let mut kept = Vec::with_capacity(before.min(b::FILTERS));
for mut filter in std::mem::take(&mut self.filters) {
if kept.len() == b::FILTERS {
dropped.push(format!("filters past {} dropped", b::FILTERS));
break;
}
let keep = match &mut filter {
DspFilter::Band(f) => {
if ![f.freq, f.gain_db, f.q].into_iter().all(finite) || bad_channel(&f.channels)
{
false
} else {
let on =
|c: usize| f.channels.is_empty() || f.channels.contains(&(c as u16));
if (0..b::CHANNELS as usize)
.any(|c| on(c) && per_channel[c] == b::BANDS_PER_CHANNEL)
{
dropped.push(format!(
"bands past {} a channel dropped",
b::BANDS_PER_CHANNEL
));
continue;
} else {
for (c, n) in per_channel.iter_mut().enumerate() {
if on(c) {
*n += 1;
}
}
clamp("a band's frequency", &mut f.freq, &b::FREQ_HZ, " Hz");
clamp("a band's gain", &mut f.gain_db, &b::GAIN_DB, " dB");
clamp("a band's Q", &mut f.q, &b::Q, "");
true
}
}
}
DspFilter::Delay(d) => {
if !finite(d.ms) || !finite(d.samples) || bad_channel(&d.channels) {
false
} else {
clamp("delay", &mut d.ms, &b::DELAY_MS, " ms");
clamp("delay", &mut d.samples, &b::DELAY_SAMPLES, " samples");
true
}
}
DspFilter::Mix(m) => {
m.outputs.truncate(b::CHANNELS as usize);
for o in &mut m.outputs {
o.retain(|(c, g)| *c < b::CHANNELS && finite(*g));
o.truncate(b::CHANNELS as usize);
for (_, g) in o.iter_mut() {
clamp("a mix's gain", g, &(-b::MIX_GAIN..=b::MIX_GAIN), "");
}
}
true
}
DspFilter::Graphic(g) => {
if bad_channel(&g.channels) {
false
} else {
g.points.retain(|(hz, db)| finite(*hz) && finite(*db));
if g.points.len() > b::GRAPHIC_POINTS {
g.points.truncate(b::GRAPHIC_POINTS);
dropped.push(format!(
"graphic EQ points past {} dropped",
b::GRAPHIC_POINTS
));
}
for (hz, db) in &mut g.points {
clamp(
"a graphic EQ's frequency",
hz,
&(0.0..=*b::FREQ_HZ.end()),
" Hz",
);
clamp("a graphic EQ's gain", db, &b::GAIN_DB, " dB");
}
true
}
}
};
if keep {
kept.push(filter);
} else {
dropped.push("a filter that could not play dropped".to_owned());
}
}
self.filters = kept;
for n in budget(&mut self.filters) {
dropped.push(n);
}
if self.layers.len() > b::LAYERS {
self.layers.truncate(b::LAYERS);
dropped.push(format!("layers past {} dropped", b::LAYERS));
}
self.layers.retain(|l| l.profile.chars().count() <= b::NAME);
if self
.origin
.as_ref()
.is_some_and(|o| o.chars().count() > b::NAME)
{
self.origin = None;
}
if self
.tuned_for
.as_ref()
.is_some_and(|t| t.chars().count() > b::NAME)
{
self.tuned_for = None;
dropped.push("tuned-for target dropped".to_owned());
}
if self
.measurement
.as_ref()
.is_some_and(|m| m.target.chars().count() > b::NAME)
{
self.measurement = None;
dropped.push("measurement's target dropped".to_owned());
}
if self
.fitted
.as_ref()
.is_some_and(|m| m.target.chars().count() > b::NAME)
{
self.fitted = None;
dropped.push("measurement's target dropped".to_owned());
}
if let Some(t) = &mut self.target {
let long = |s: &str| s.chars().count() > b::NAME;
if long(&t.made_for) {
self.target = None;
dropped.push("target dropped".to_owned());
} else if t.chosen.as_deref().is_some_and(long) {
t.chosen = None;
dropped.push("chosen target dropped".to_owned());
}
}
if let Some(o) = self.original.take() {
let mut kept = DspProfile {
filters: o.filters,
preamp_db: o.preamp_db,
..Default::default()
};
kept.sanitize();
self.original = Some(DspOriginal {
filters: kept.filters,
preamp_db: kept.preamp_db,
});
}
for d in dropped {
note(d);
}
notes
}
pub fn sanitized(&self) -> Self {
let mut p = self.clone();
p.sanitize();
p
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DspScope {
Everywhere,
Device,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DspLayer {
pub profile: String,
#[serde(default = "layer_on")]
pub on: bool,
}
fn layer_on() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DspTarget {
pub made_for: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chosen: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DspFilter {
Delay(Delay),
Mix(Mix),
Graphic(GraphicEq),
#[serde(untagged)]
Band(EqFilter),
}
impl DspFilter {
pub fn channels(&self) -> &[u16] {
match self {
Self::Band(f) => &f.channels,
Self::Delay(d) => &d.channels,
Self::Graphic(g) => &g.channels,
Self::Mix(_) => &[],
}
}
pub fn channels_mut(&mut self) -> Option<&mut Vec<u16>> {
match self {
Self::Band(f) => Some(&mut f.channels),
Self::Delay(d) => Some(&mut d.channels),
Self::Graphic(g) => Some(&mut g.channels),
Self::Mix(_) => None,
}
}
}
impl From<EqFilter> for DspFilter {
fn from(f: EqFilter) -> Self {
Self::Band(f)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Delay {
#[serde(skip_serializing_if = "is_zero")]
pub ms: f64,
#[serde(skip_serializing_if = "is_zero")]
pub samples: f64,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub subsample: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<u16>,
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Mix {
pub outputs: Vec<Vec<(u16, f64)>>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GraphicEq {
pub points: Vec<(f64, f64)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<u16>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EqFilter {
#[serde(rename = "type")]
pub kind: EqFilterKind,
pub freq: f64,
#[serde(default)]
pub gain_db: f64,
#[serde(default = "default_q")]
pub q: f64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<u16>,
}
fn default_q() -> f64 {
std::f64::consts::FRAC_1_SQRT_2
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EqFilterKind {
Peaking,
LowShelf,
HighShelf,
LowPass,
HighPass,
Notch,
BandPass,
AllPass,
LowShelfFirstOrder,
HighShelfFirstOrder,
LowPassFirstOrder,
HighPassFirstOrder,
AllPassFirstOrder,
Gain,
}
impl EqFilterKind {
pub fn name(self) -> &'static str {
match self {
Self::Peaking => "peaking",
Self::LowShelf => "low_shelf",
Self::HighShelf => "high_shelf",
Self::LowPass => "low_pass",
Self::HighPass => "high_pass",
Self::Notch => "notch",
Self::BandPass => "band_pass",
Self::AllPass => "all_pass",
Self::LowShelfFirstOrder => "low_shelf_first_order",
Self::HighShelfFirstOrder => "high_shelf_first_order",
Self::LowPassFirstOrder => "low_pass_first_order",
Self::HighPassFirstOrder => "high_pass_first_order",
Self::AllPassFirstOrder => "all_pass_first_order",
Self::Gain => "gain",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layer {
Shared,
Machine,
}
pub fn layer_of(path: &str) -> Layer {
match path {
"remote.password"
| "remote.api_key"
| "remote.device_key"
| "subsonic.password"
| "auth.refresh_token"
| "push.key"
| "push.key_path"
| "push.key_id"
| "push.team_id"
| "library.folders"
| "remote.enabled"
| "remote.url"
| "remote.username"
| "remote.cache_dir"
| "remote.cache_limit"
| "remote.play_queue"
| "playback.output_device"
| "playback.renderer"
| "playback.renderer_name"
| "playback.renderers"
| "playback.muted"
| "playback.rate_switch_lead_in_ms"
| "subsonic.enabled"
| "subsonic.port"
| "subsonic.username"
| "subsonic.transcode"
| "subsonic.ffmpeg"
| "devices.nearby"
| "devices.discoverable"
| "devices.port"
| "devices.addresses"
| "devices.nearby_control"
| "devices.keep_running"
| "auth.server"
| "playback.art_size"
| "visualizer.enabled"
| "visualizer.mode"
| "visualizer.matrix_overlay"
| "visualizer.bass_shake" => Layer::Machine,
p if p == "dsp" || p.starts_with("dsp.") => Layer::Machine,
_ => Layer::Shared,
}
}
type ConfigStamp = (PathBuf, Option<SystemTime>, Option<SystemTime>);
type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
LazyLock::new(|| parking_lot::RwLock::new(None));
static CONFIG_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn config_stamp() -> ConfigStamp {
let (base, local) = stamp_of(&config_file_path(), &config_local_file_path());
(config_dir(), base, local)
}
fn stamp_of(base: &Path, local: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
(mtime(base), mtime(local))
}
impl Config {
fn figment() -> Figment {
let base_path = config_file_path();
let local_path = config_local_file_path();
Figment::from(Serialized::defaults(Config::default()))
.merge(Toml::file(&base_path))
.merge(Toml::file(&local_path))
.merge(Env::prefixed("KOAN_").split("__"))
}
pub fn load() -> Result<Self, ConfigError> {
let cfg: Self = Self::figment()
.extract()
.map_err(|e| ConfigError::Figment(Box::new(e)))?;
check_secrets_in_git();
Ok(cfg)
}
pub fn load_or_default() -> Self {
(*Self::cached()).clone()
}
pub fn cached() -> Arc<Config> {
let generation = CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst);
let stamp = config_stamp();
if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
&& *seen == stamp
{
return cfg.clone();
}
let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
log::warn!("failed to load config, using defaults: {}", e);
Self::default()
}));
let mut cache = CONFIG_CACHE.write();
if CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst) == generation
&& config_stamp() == stamp
{
*cache = Some((stamp, cfg.clone()));
}
cfg
}
pub fn invalidate_cache() {
let mut cache = CONFIG_CACHE.write();
CONFIG_GENERATION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
*cache = None;
}
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
let contents = fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}
fn from_files() -> Result<Self, ConfigError> {
Figment::from(Serialized::defaults(Config::default()))
.merge(Toml::file(config_file_path()))
.merge(Toml::file(config_local_file_path()))
.extract()
.map_err(|e| ConfigError::Figment(Box::new(e)))
}
pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
where
F: FnOnce(&mut Config),
{
static ONE_AT_A_TIME: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
let _one = ONE_AT_A_TIME.lock();
let before = Self::from_files()?;
let mut after = before.clone();
mutate(&mut after);
let mut changes = Vec::new();
diff_into(
"",
&toml::Value::try_from(&before)?,
&toml::Value::try_from(&after)?,
&mut changes,
);
if changes.is_empty() {
return Ok(());
}
let base_path = config_file_path();
let local_path = config_local_file_path();
let mut base = read_document(&base_path)?;
let mut local = read_document(&local_path)?;
for (path, value) in &changes {
let (target, other) = match layer_of(path) {
Layer::Shared => (&mut base, &mut local),
Layer::Machine => (&mut local, &mut base),
};
match value {
Some(v) => doc_set(target, path, v),
None => doc_remove(target, path),
}
doc_remove(other, path);
}
write_document(&base_path, &base, false)?;
write_document(&local_path, &local, true)?;
Self::invalidate_cache();
Ok(())
}
pub fn cache_dir(&self) -> PathBuf {
self.remote
.cache_dir
.clone()
.unwrap_or_else(default_cache_dir)
}
pub fn cache_limit_bytes(&self) -> Option<u64> {
self.remote
.cache_limit
.as_deref()
.and_then(parse_size_bytes)
}
}
fn diff_into(
prefix: &str,
before: &toml::Value,
after: &toml::Value,
out: &mut Vec<(String, Option<toml::Value>)>,
) {
let (b, a) = match (before.as_table(), after.as_table()) {
(Some(b), Some(a)) => (b, a),
_ => {
if before != after {
out.push((prefix.to_string(), Some(after.clone())));
}
return;
}
};
let empty = toml::Value::Table(toml::map::Map::new());
for key in b
.keys()
.chain(a.keys())
.collect::<std::collections::BTreeSet<_>>()
{
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match (b.get(key), a.get(key)) {
(Some(bv), Some(av)) => diff_into(&path, bv, av, out),
(None, Some(av)) => diff_into(&path, &empty, av, out),
(Some(_), None) => out.push((path, None)),
(None, None) => unreachable!("key came from one of the two tables"),
}
}
}
fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
let Ok(contents) = fs::read_to_string(path) else {
return Ok(toml_edit::DocumentMut::new());
};
contents
.parse::<toml_edit::DocumentMut>()
.map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
}
fn write_document(
path: &Path,
doc: &toml_edit::DocumentMut,
secret: bool,
) -> Result<(), ConfigError> {
let contents = doc.to_string();
if contents.trim().is_empty() && !path.exists() {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let target = link_target(path);
let temp = unique_beside(&target);
let written = write_new(&temp, &target, contents.as_bytes(), secret)
.and_then(|()| fs::rename(&temp, &target));
if let Err(e) = written {
let _ = fs::remove_file(&temp);
return Err(e.into());
}
#[cfg(target_os = "tvos")]
kept::written(path, contents.as_bytes());
Ok(())
}
fn link_target(path: &Path) -> PathBuf {
let mut target = path.to_path_buf();
for _ in 0..40 {
let Ok(next) = fs::read_link(&target) else {
break;
};
target = match target.parent() {
Some(parent) if next.is_relative() => parent.join(next),
_ => next,
};
}
target
}
fn unique_beside(target: &Path) -> PathBuf {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
target.with_file_name(format!(".{name}.{}.{n}.tmp", std::process::id()))
}
fn write_new(temp: &Path, target: &Path, contents: &[u8], secret: bool) -> std::io::Result<()> {
use std::io::Write as _;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
let kept_mode = {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let kept_mode = (!secret)
.then(|| fs::metadata(target).ok())
.flatten()
.map(|m| m.permissions().mode() & 0o7777);
options.mode(if secret {
0o600
} else {
kept_mode.unwrap_or(0o666)
});
kept_mode
};
#[cfg(not(unix))]
let _ = (target, secret);
let mut file = options.open(temp)?;
file.write_all(contents)?;
#[cfg(unix)]
if let Some(mode) = kept_mode {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(mode))?;
}
Ok(())
}
fn implicit_table() -> toml_edit::Item {
let mut table = toml_edit::Table::new();
table.set_implicit(true);
toml_edit::Item::Table(table)
}
fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
let segments: Vec<&str> = path.split('.').collect();
let (last, parents) = segments.split_last().expect("a diffed path is never empty");
let mut table = doc.as_table_mut();
for segment in parents {
let item = table.entry(segment).or_insert_with(implicit_table);
if !item.is_table() {
*item = implicit_table();
}
table = item.as_table_mut().expect("just ensured it is a table");
}
match table.get_mut(last) {
Some(existing) => *existing = to_edit_item(value),
None => {
table.insert(last, to_edit_item(value));
}
}
}
fn to_edit_item(value: &toml::Value) -> toml_edit::Item {
let toml::Value::Array(items) = value else {
return toml_edit::value(to_edit_value(value));
};
if items.is_empty() || !items.iter().all(toml::Value::is_table) {
return toml_edit::value(to_edit_value(value));
}
let mut sections = toml_edit::ArrayOfTables::new();
for item in items {
let mut section = toml_edit::Table::new();
for (k, v) in item.as_table().expect("checked above") {
let mut v = to_edit_value(v);
if let toml_edit::Value::Array(list) = &mut v
&& list.iter().any(toml_edit::Value::is_inline_table)
{
for entry in list.iter_mut() {
entry.decor_mut().set_prefix("\n ");
if let Some(t) = entry.as_inline_table_mut() {
t.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
}
}
list.set_trailing("\n");
list.set_trailing_comma(true);
}
section.insert(k, toml_edit::value(v));
}
section.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
sections.push(section);
}
toml_edit::Item::ArrayOfTables(sections)
}
fn key_rank(key: &toml_edit::Key) -> (u8, &str) {
let k = key.get();
(if k == "name" || k == "type" { 0 } else { 1 }, k)
}
fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
let segments: Vec<&str> = path.split('.').collect();
let (last, parents) = segments.split_last().expect("a diffed path is never empty");
let mut table = doc.as_table_mut();
for segment in parents {
match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
Some(child) => table = child,
None => return,
}
}
table.remove(last);
}
fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
match value {
toml::Value::String(s) => s.as_str().into(),
toml::Value::Integer(i) => (*i).into(),
toml::Value::Float(f) => (*f).into(),
toml::Value::Boolean(b) => (*b).into(),
toml::Value::Datetime(d) => d.to_string().into(),
toml::Value::Array(items) => items
.iter()
.map(to_edit_value)
.collect::<toml_edit::Array>()
.into(),
toml::Value::Table(t) => {
let mut inline = toml_edit::InlineTable::new();
for (k, v) in t {
inline.insert(k, to_edit_value(v));
}
inline.into()
}
}
}
pub fn config_dir() -> PathBuf {
if let Some(dir) = CONFIG_DIR.read().clone() {
return dir;
}
if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
return PathBuf::from(dir);
}
platform_config_dir()
}
#[cfg(not(any(target_os = "ios", target_os = "tvos")))]
fn platform_config_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("koan")
}
#[cfg(target_os = "ios")]
fn platform_config_dir() -> PathBuf {
ios_library().join("Application Support").join("koan")
}
#[cfg(target_os = "tvos")]
fn platform_config_dir() -> PathBuf {
let dir = ios_library().join("Caches").join("koan-config");
kept::restore(&dir);
dir
}
#[cfg(target_os = "tvos")]
mod kept {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::Once;
use core_foundation::base::TCFType;
use core_foundation::data::{CFData, CFDataRef};
use core_foundation::string::CFString;
use core_foundation_sys::base::{CFGetTypeID, CFRelease};
use core_foundation_sys::data::CFDataGetTypeID;
use core_foundation_sys::preferences::{
CFPreferencesAppSynchronize, CFPreferencesCopyAppValue, CFPreferencesSetAppValue,
kCFPreferencesCurrentApplication,
};
const FILES: [&str; 2] = ["config.toml", "config.local.toml"];
pub(super) fn restore(dir: &Path) {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
for name in FILES {
let path = dir.join(name);
match fs::read(&path) {
Ok(contents) => save(name, &contents),
Err(_) => {
let Some(contents) = load(name) else { continue };
let _ = fs::create_dir_all(dir);
if fs::write(&path, &contents).is_ok() {
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
log::info!("config: restored {name} after the system cleared it");
}
}
}
}
});
}
pub(super) fn written(path: &Path, contents: &[u8]) {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return;
};
if FILES.contains(&name) && path.parent() == Some(super::platform_config_dir().as_path()) {
save(name, contents);
}
}
fn save(name: &str, contents: &[u8]) {
let key = CFString::new(name);
let data = CFData::from_buffer(contents);
unsafe {
CFPreferencesSetAppValue(
key.as_concrete_TypeRef(),
data.as_CFTypeRef(),
kCFPreferencesCurrentApplication,
);
CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication);
}
}
fn load(name: &str) -> Option<Vec<u8>> {
let key = CFString::new(name);
unsafe {
let value = CFPreferencesCopyAppValue(
key.as_concrete_TypeRef(),
kCFPreferencesCurrentApplication,
);
if value.is_null() {
return None;
}
if CFGetTypeID(value) != CFDataGetTypeID() {
CFRelease(value);
return None;
}
Some(
CFData::wrap_under_create_rule(value as CFDataRef)
.bytes()
.to_vec(),
)
}
}
}
#[cfg(any(target_os = "ios", target_os = "tvos"))]
fn ios_library() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("Library")
}
fn default_cache_dir() -> PathBuf {
#[cfg(any(target_os = "ios", target_os = "tvos"))]
if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
return ios_library().join("Caches").join("koan");
}
config_dir().join("cache")
}
pub fn set_config_dir(dir: impl Into<PathBuf>) {
*CONFIG_DIR.write() = Some(dir.into());
Config::invalidate_cache();
}
pub fn isolate_config_for_tests() {
let _one = SWITCHING.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
set_config_dir(dir);
}
#[doc(hidden)]
pub static SWITCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
LazyLock::new(|| parking_lot::RwLock::new(None));
pub fn config_file_path() -> PathBuf {
config_dir().join("config.toml")
}
pub fn config_local_file_path() -> PathBuf {
config_dir().join("config.local.toml")
}
pub fn db_path() -> PathBuf {
config_dir().join("koan.db")
}
const LOG_LIMIT: u64 = 16 * 1024 * 1024;
const LOG_CHECK_EVERY: u32 = 4096;
#[derive(Default)]
pub struct LogFile {
file: Option<fs::File>,
lines: u32,
}
impl LogFile {
pub fn write(&mut self, line: std::fmt::Arguments) {
use std::io::Write as _;
if self.file.is_none() {
self.file = open_log();
}
let Some(file) = self.file.as_mut() else {
return;
};
let _ = writeln!(file, "{line}");
self.lines = self.lines.wrapping_add(1);
if self.lines.is_multiple_of(LOG_CHECK_EVERY)
&& file.metadata().is_ok_and(|m| m.len() > LOG_LIMIT)
{
self.file = open_log();
}
}
pub fn flush(&mut self) {
if let Some(file) = self.file.as_mut() {
let _ = std::io::Write::flush(file);
}
}
}
fn open_log() -> Option<fs::File> {
let dir = config_dir();
fs::create_dir_all(&dir).ok()?;
let path = dir.join("koan.log");
if fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
let _ = fs::rename(&path, dir.join("koan.log.1"));
}
fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.ok()
}
fn check_secrets_in_git() {
static ONCE: Once = Once::new();
ONCE.call_once(scan_for_tracked_secrets);
}
fn scan_for_tracked_secrets() {
let sensitive_fields = ["password", "api_key", "refresh_token"];
for (label, path) in [
("config.toml", config_file_path()),
("config.local.toml", config_local_file_path()),
] {
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let has_secrets = sensitive_fields.iter().any(|field| {
contents.lines().any(|line| {
let line = line.trim();
if let Some(rest) = line.strip_prefix(field) {
let rest = rest.trim_start();
if let Some(value) = rest.strip_prefix('=') {
let value = value.trim().trim_matches('"').trim_matches('\'');
return !value.is_empty();
}
}
false
})
});
if !has_secrets {
continue;
}
if is_tracked_by_git(&path) {
eprintln!();
eprintln!("╔══════════════════════════════════════════════════════════════╗");
eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
eprintln!("╠══════════════════════════════════════════════════════════════╣");
eprintln!("║ ║");
eprintln!("║ File: {:<52} ║", path.display());
eprintln!("║ ║");
eprintln!("║ Your password is in version control. You should: ║");
eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
eprintln!("║ 2. Add it to .gitignore ║");
eprintln!("║ 3. Rotate your credentials immediately ║");
eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
eprintln!("║ `koan remote login` writes there for you ║");
eprintln!("║ ║");
eprintln!("╚══════════════════════════════════════════════════════════════╝");
eprintln!();
panic!("Refusing to start: credentials tracked by git in {label}. See above.");
}
}
}
fn is_tracked_by_git(path: &Path) -> bool {
let Some(parent) = path.parent() else {
return false;
};
std::process::Command::new("git")
.args(["ls-files", "--error-unmatch"])
.arg(path)
.current_dir(parent)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::fs;
fn tmp_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn test_defaults() {
let cfg = Config::default();
assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
assert!(!cfg.remote.enabled);
}
#[test]
fn test_roundtrip_toml() {
let cfg = Config::default();
let serialized = toml::to_string_pretty(&cfg).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
assert_eq!(
deserialized.remote.download_workers,
cfg.remote.download_workers
);
}
#[test]
fn test_load_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"
[library]
folders = ["/tmp/music"]
[playback]
replaygain = "track"
"#,
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
assert!(!cfg.remote.enabled);
}
#[test]
fn test_partial_toml_uses_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("partial.toml");
fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.playback.target_fps, 30);
assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
}
#[test]
fn test_figment_layered_loading() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("config.toml");
let local_path = dir.path().join("config.local.toml");
fs::write(
&base_path,
r#"
[remote]
url = "https://base.example.com"
"#,
)
.unwrap();
fs::write(
&local_path,
r#"
[remote]
enabled = true
url = "https://local.example.com"
username = "admin"
password = "secret"
"#,
)
.unwrap();
let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
.merge(Toml::file(&base_path))
.merge(Toml::file(&local_path))
.extract()
.unwrap();
assert!(cfg.remote.enabled);
assert_eq!(cfg.remote.url, "https://local.example.com");
assert_eq!(cfg.remote.username, "admin");
assert_eq!(cfg.remote.password, "secret");
}
#[test]
fn test_figment_missing_keys_preserved() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("config.toml");
let local_path = dir.path().join("config.local.toml");
fs::write(
&base_path,
r#"
[remote]
url = "https://keep.me"
username = "keepuser"
"#,
)
.unwrap();
fs::write(
&local_path,
r#"
[remote]
password = "secret"
"#,
)
.unwrap();
let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
.merge(Toml::file(&base_path))
.merge(Toml::file(&local_path))
.extract()
.unwrap();
assert_eq!(cfg.remote.url, "https://keep.me");
assert_eq!(cfg.remote.username, "keepuser");
assert_eq!(cfg.remote.password, "secret");
}
#[test]
fn test_env_var_override() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("config.toml");
fs::write(
&base_path,
r#"
[remote]
url = "https://file.example.com"
"#,
)
.unwrap();
unsafe {
std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
}
let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
.merge(Toml::file(&base_path))
.merge(Env::prefixed("KOAN_").split("__"))
.extract()
.unwrap();
assert_eq!(cfg.remote.url, "https://env.example.com");
assert_eq!(cfg.remote.password, "env-secret");
assert_eq!(cfg.graphql.port, 9999);
unsafe {
std::env::remove_var("KOAN_REMOTE__URL");
std::env::remove_var("KOAN_REMOTE__PASSWORD");
std::env::remove_var("KOAN_GRAPHQL__PORT");
}
}
#[test]
fn test_cache_dir_default() {
let cfg = Config::default();
assert!(cfg.cache_dir().ends_with("cache"));
}
#[test]
fn test_cache_dir_explicit() {
let mut cfg = Config::default();
cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
}
#[test]
fn test_organize_config_defaults() {
let cfg = Config::default();
assert!(cfg.organize.default.is_none());
assert!(cfg.organize.patterns.is_empty());
}
#[test]
fn test_organize_config_from_toml() {
let dir = tmp_dir();
let path = dir.join("organize.toml");
fs::write(
&path,
r#"
[organize]
default = "standard"
[organize.patterns]
standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
"#,
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
assert_eq!(cfg.organize.patterns.len(), 2);
assert!(cfg.organize.patterns.contains_key("standard"));
assert!(cfg.organize.patterns.contains_key("va-aware"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_figment_organize_patterns_merge() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("config.toml");
let local_path = dir.path().join("config.local.toml");
fs::write(
&base_path,
r#"
[organize]
default = "standard"
[organize.patterns]
standard = "base-pattern"
"#,
)
.unwrap();
fs::write(
&local_path,
r#"
[organize]
default = "custom"
[organize.patterns]
custom = "local-pattern"
"#,
)
.unwrap();
let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
.merge(Toml::file(&base_path))
.merge(Toml::file(&local_path))
.extract()
.unwrap();
assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
assert_eq!(cfg.organize.patterns.len(), 2);
assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
}
#[test]
fn test_output_device_config_roundtrip() {
let mut cfg = Config::default();
cfg.playback.output_device = Some("My DAC".into());
let serialized = toml::to_string_pretty(&cfg).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(
deserialized.playback.output_device.as_deref(),
Some("My DAC")
);
}
#[test]
fn test_output_device_config_default_is_none() {
let cfg = Config::default();
assert!(cfg.playback.output_device.is_none());
let serialized = toml::to_string_pretty(&cfg).unwrap();
assert!(!serialized.contains("output_device"));
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert!(deserialized.playback.output_device.is_none());
}
#[test]
fn test_output_device_config_from_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"
[playback]
output_device = "External Speakers"
"#,
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(
cfg.playback.output_device.as_deref(),
Some("External Speakers")
);
}
#[test]
fn test_graphql_bind_defaults_to_localhost() {
let cfg = GraphqlConfig::default();
assert_eq!(
cfg.bind,
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
);
}
#[test]
fn test_graphql_bind_from_toml() {
let toml_str = r#"
[graphql]
bind = "0.0.0.0"
port = 5000
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(
cfg.graphql.bind,
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
);
assert_eq!(cfg.graphql.port, 5000);
}
#[test]
fn test_graphql_bind_omitted_defaults_to_localhost() {
let toml_str = r#"
[graphql]
port = 4000
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(
cfg.graphql.bind,
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
);
}
#[test]
fn test_organize_config_roundtrip() {
let mut cfg = Config::default();
cfg.organize.default = Some("standard".into());
cfg.organize
.patterns
.insert("standard".into(), "%artist%/%title%".into());
let serialized = toml::to_string_pretty(&cfg).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
assert_eq!(
deserialized.organize.patterns["standard"],
"%artist%/%title%"
);
}
#[test]
fn test_parse_size_bytes() {
assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
assert_eq!(parse_size_bytes("1024B"), Some(1024));
assert_eq!(parse_size_bytes("1024"), Some(1024));
assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
assert_eq!(
parse_size_bytes("1.5GB"),
Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
);
assert_eq!(parse_size_bytes(""), None);
assert_eq!(parse_size_bytes("abc"), None);
assert_eq!(parse_size_bytes("50XB"), None);
}
#[test]
fn test_cache_limit_config_from_toml() {
let toml_str = r#"
[remote]
cache_limit = "50GB"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
}
#[test]
fn test_cache_limit_none_by_default() {
let cfg = Config::default();
assert!(cfg.remote.cache_limit.is_none());
assert!(cfg.cache_limit_bytes().is_none());
}
#[test]
fn test_cache_limit_not_serialized_when_none() {
let cfg = Config::default();
let serialized = toml::to_string_pretty(&cfg).unwrap();
assert!(!serialized.contains("cache_limit"));
}
#[test]
fn player_uses_config_on_init() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"
[playback]
replaygain = "track"
output_device = "My Fancy DAC"
pre_amp_db = -3.5
target_fps = 30
art_size = 32
[visualizer]
enabled = false
mode = "oscilloscope"
fps = 30
"#,
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(
cfg.playback.replaygain,
ReplayGainMode::Track,
"replaygain should be 'track'"
);
assert_eq!(
cfg.playback.output_device.as_deref(),
Some("My Fancy DAC"),
"output_device should match config"
);
assert!(
(cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
"pre_amp_db should be -3.5"
);
assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
assert_eq!(cfg.visualizer.mode, "oscilloscope");
assert_eq!(cfg.visualizer.fps, 30);
}
#[test]
fn a_missing_config_file_stamps_as_absent() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("config.toml");
let local = dir.path().join("config.local.toml");
assert_eq!(stamp_of(&base, &local), (None, None));
fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
let (base_stamp, local_stamp) = stamp_of(&base, &local);
assert!(base_stamp.is_some(), "creating the file must be a change");
assert!(local_stamp.is_none());
}
#[test]
fn editing_a_config_file_changes_its_stamp() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("config.toml");
let local = dir.path().join("config.local.toml");
fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
let before = stamp_of(&base, &local);
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
assert_ne!(
before,
stamp_of(&base, &local),
"a config edited by hand has to be picked up"
);
}
#[test]
fn invalidating_forces_a_reload() {
let first = Config::cached();
Config::invalidate_cache();
assert!(
!Arc::ptr_eq(&first, &Config::cached()),
"koan's own writes invalidate explicitly; the next read must re-parse"
);
}
pub(crate) static PERSIST_LOCK: &std::sync::Mutex<()> = &super::SWITCHING;
fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
let dir =
std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
set_config_dir(&dir);
(config_file_path(), config_local_file_path())
}
#[cfg(unix)]
#[test]
fn a_write_keeps_links_modes_and_no_leftovers() {
use std::os::unix::fs::PermissionsExt;
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let dotfiles = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(
dotfiles.path().join("config.toml"),
dir.path().join("config.toml"),
)
.unwrap();
set_config_dir(dir.path());
Config::persist(|c| c.graphql.port = 4100).unwrap();
assert!(
fs::symlink_metadata(config_file_path())
.unwrap()
.is_symlink()
);
assert!(
fs::read_to_string(dotfiles.path().join("config.toml"))
.unwrap()
.contains("4100")
);
fs::set_permissions(
dotfiles.path().join("config.toml"),
fs::Permissions::from_mode(0o640),
)
.unwrap();
Config::persist(|c| {
c.graphql.port = 4101;
c.remote.password = "hunter2".into();
})
.unwrap();
let mode = |p: PathBuf| fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(dotfiles.path().join("config.toml")), 0o640);
assert_eq!(mode(config_local_file_path()), 0o600);
assert!(
fs::symlink_metadata(config_file_path())
.unwrap()
.is_symlink()
);
let names = |d: &Path| -> Vec<String> {
fs::read_dir(d)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect()
};
assert!(!names(dir.path()).iter().any(|n| n.ends_with(".tmp")));
assert_eq!(names(dotfiles.path()), vec!["config.toml".to_string()]);
}
#[test]
fn a_read_during_switches_is_never_kept_for_the_wrong_directory() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dirs: Vec<tempfile::TempDir> = (0..2).map(|_| tempfile::tempdir().unwrap()).collect();
for (dir, port) in dirs.iter().zip([4001, 4002]) {
fs::write(
dir.path().join("config.toml"),
format!("[graphql]\nport = {port}\n"),
)
.unwrap();
}
let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
let reader = std::thread::spawn({
let done = done.clone();
move || {
while !done.load(std::sync::atomic::Ordering::Relaxed) {
Config::cached();
}
}
});
for i in 0..2000 {
let (dir, port) = if i % 2 == 0 {
(&dirs[0], 4001)
} else {
(&dirs[1], 4002)
};
set_config_dir(dir.path());
for _ in 0..3 {
assert_eq!(Config::cached().graphql.port, port, "switch {i}");
}
}
done.store(true, std::sync::atomic::Ordering::Relaxed);
reader.join().unwrap();
}
#[test]
fn persist_keeps_comments_and_untouched_keys() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (base, _local) = persist_sandbox("comments");
fs::write(
&base,
"# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
)
.unwrap();
Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
let written = fs::read_to_string(&base).unwrap();
assert!(
written.contains("# koan — shareable defaults"),
"the header comment must survive a write: {written}"
);
assert!(
written.contains("# fps = 60"),
"commented-out defaults are the template's whole point: {written}"
);
assert!(written.contains("palette = \"neon\""));
assert!(
!written.contains("[graphql]"),
"an untouched section must not be invented: {written}"
);
}
#[test]
fn persist_routes_machine_settings_to_the_local_file() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (base, local) = persist_sandbox("routing");
Config::persist(|cfg| {
cfg.playback.replaygain = ReplayGainMode::Album;
cfg.playback.output_device = Some("My DAC".into());
cfg.playback.art_size = 40;
cfg.visualizer.mode = "starfield".into();
})
.unwrap();
let shared = fs::read_to_string(&base).unwrap();
let machine = fs::read_to_string(&local).unwrap();
assert!(shared.contains("replaygain = \"album\""), "{shared}");
for machine_only in ["output_device", "art_size", "starfield"] {
assert!(
!shared.contains(machine_only),
"{machine_only} is this machine's, not the dotfiles repo's: {shared}"
);
assert!(machine.contains(machine_only), "{machine}");
}
}
#[test]
fn persist_writes_dsp_profiles_to_the_local_file_as_sections() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (base, local) = persist_sandbox("dsp");
let profile = DspProfile {
name: "HD 600".into(),
devices: vec!["Topping E30".into()],
preamp_db: None,
filters: vec![
DspFilter::Band(EqFilter {
kind: EqFilterKind::Peaking,
freq: 20.0,
gain_db: -1.3,
q: 2.0,
channels: vec![],
}),
DspFilter::Band(EqFilter {
kind: EqFilterKind::HighShelfFirstOrder,
freq: 10000.0,
gain_db: 2.5,
q: 0.7,
channels: vec![],
}),
DspFilter::Delay(Delay {
ms: 1.5,
channels: vec![1],
..Default::default()
}),
DspFilter::Mix(Mix {
outputs: vec![vec![(0, 0.5), (1, 0.5)], vec![]],
}),
DspFilter::Graphic(GraphicEq {
points: vec![(20.0, -1.0), (1000.0, 0.0)],
channels: vec![],
}),
],
impulses: vec![],
source: vec![],
target: None,
layers: vec![],
group: false,
preset: false,
scope: None,
uid: None,
origin: None,
role: None,
measurement: None,
fitted: None,
tuned_for: None,
original: None,
};
Config::persist(|cfg| cfg.dsp.profiles.push(profile.clone())).unwrap();
assert!(!base.exists() || !fs::read_to_string(&base).unwrap().contains("dsp"));
let machine = fs::read_to_string(&local).unwrap();
assert!(machine.contains("[[dsp.profiles]]"), "{machine}");
assert!(
machine.contains("\n { type = \"peaking\""),
"one band per line: {machine}"
);
assert_eq!(Config::from_files().unwrap().dsp.profiles, vec![profile]);
assert_eq!(
Config::from_files()
.unwrap()
.dsp
.profile_for("Topping E30")
.map(|p| p.name.as_str()),
Some("HD 600")
);
}
#[test]
fn a_profile_is_played_within_bounds() {
let band = |freq: f64, gain_db: f64, q: f64| {
DspFilter::Band(EqFilter {
kind: EqFilterKind::Peaking,
freq,
gain_db,
q,
channels: vec![],
})
};
let mut p = DspProfile {
name: "Loud\u{7}".into(),
preamp_db: Some(60.0),
filters: vec![
DspFilter::Delay(Delay {
ms: 1e12,
..Default::default()
}),
band(0.5, 200.0, 0.0),
band(f64::NAN, 0.0, 1.0),
DspFilter::Band(EqFilter {
kind: EqFilterKind::Peaking,
freq: 1000.0,
gain_db: 0.0,
q: 1.0,
channels: vec![99],
}),
DspFilter::Mix(Mix {
outputs: vec![vec![(0, 1e9), (1, f64::INFINITY)]],
}),
DspFilter::Graphic(GraphicEq {
points: (0..3000).map(|i| (i as f64, 1.0)).collect(),
channels: vec![],
}),
],
..Default::default()
};
p.filters.extend((0..300).map(|_| band(1000.0, 1.0, 1.0)));
let notes = p.sanitize();
let has = |n: &str| notes.iter().any(|x| x == n);
assert!(has("delay clamped to 2000 ms"), "{notes:?}");
assert!(has("preamp clamped to 30 dB"), "{notes:?}");
assert!(has("a band's gain clamped to 30 dB"), "{notes:?}");
assert!(has("a band's frequency clamped to 1 Hz"), "{notes:?}");
assert!(has("a filter that could not play dropped"), "{notes:?}");
assert!(has("bands past 64 a channel dropped"), "{notes:?}");
assert!(has("graphic EQ points past 2048 dropped"), "{notes:?}");
assert_eq!(p.name, "Loud");
assert_eq!(p.preamp_db, Some(30.0));
assert_eq!(
p.filters[0],
DspFilter::Delay(Delay {
ms: 2000.0,
..Default::default()
})
);
assert_eq!(p.filters[1], band(1.0, 30.0, 0.01));
let DspFilter::Mix(m) = &p.filters[2] else {
panic!("{:?}", p.filters[2])
};
assert_eq!(m.outputs, vec![vec![(0, dsp_bounds::MIX_GAIN)]]);
let bands = p
.filters
.iter()
.filter(|f| matches!(f, DspFilter::Band(_)))
.count();
assert_eq!(bands, dsp_bounds::BANDS_PER_CHANNEL);
assert!(p.clone().sanitize().is_empty(), "within bounds now");
}
#[test]
fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (base, _local) = persist_sandbox("folders");
Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
let shared = fs::read_to_string(&base).unwrap_or_default();
assert!(
!shared.contains("folders"),
"a visualiser toggle must not invent library folders: {shared}"
);
}
#[test]
fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (base, local) = persist_sandbox("drain");
fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
let shared = fs::read_to_string(&base).unwrap();
assert!(
!shared.contains("art_size"),
"the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
);
assert!(shared.contains("target_fps"), "{shared}");
assert!(
fs::read_to_string(&local)
.unwrap()
.contains("art_size = 48")
);
}
#[test]
fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (_base, local) = persist_sandbox("shadow");
fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
assert_eq!(
Config::from_files().unwrap().playback.target_fps,
120,
"local wins the merge, so a shared write over a local copy would \
otherwise be silently ignored: {}",
fs::read_to_string(&local).unwrap()
);
}
#[test]
fn persist_writes_nothing_when_the_mutation_changes_nothing() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (base, _local) = persist_sandbox("noop");
fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
assert_eq!(
fs::read_to_string(&base).unwrap(),
"# untouched\n[playback]\ntarget_fps = 60\n"
);
}
#[test]
fn persist_keeps_passwords_out_of_the_shared_file() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (base, local) = persist_sandbox("secrets");
Config::persist(|cfg| {
cfg.remote.password = "hunter2".into();
cfg.subsonic.password = "s3cret".into();
cfg.visualizer.palette = "mono".into();
})
.unwrap();
let shared = fs::read_to_string(&base).unwrap();
assert!(!shared.contains("hunter2"), "{shared}");
assert!(!shared.contains("s3cret"), "{shared}");
assert!(shared.contains("mono"));
let machine = fs::read_to_string(&local).unwrap();
assert!(machine.contains("hunter2") && machine.contains("s3cret"));
}
#[test]
fn persist_removes_a_cleared_password_rather_than_blanking_it() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (_base, local) = persist_sandbox("clear-secret");
fs::write(
&local,
"[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
)
.unwrap();
Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
let machine = fs::read_to_string(&local).unwrap();
assert!(
!machine.contains("password"),
"an emptied secret should leave no key behind: {machine}"
);
assert!(machine.contains("url"), "{machine}");
}
#[test]
fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (base, _local) = persist_sandbox("patterns");
fs::write(
&base,
"[organize.patterns]\nflat = \"%artist% - %title%\"\n",
)
.unwrap();
Config::persist(|cfg| {
cfg.organize
.patterns
.insert("standard".into(), "%album artist%/%album%".into());
})
.unwrap();
let cfg = Config::from_files().unwrap();
assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
}
}