2026-05-21 14:22:33 +03:00
|
|
|
|
/// Application-level configuration for furumusic.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Every field is available both as a `FURU_`-prefixed environment variable
|
|
|
|
|
|
/// and through the admin UI. The resolution order is:
|
|
|
|
|
|
///
|
|
|
|
|
|
/// env var > DB override > compiled default
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Adding a new field to [`AppConfig`] automatically makes it settable via
|
|
|
|
|
|
/// the `FURU_<FIELD_NAME>` env var thanks to the [`impl_env_overrides`] macro.
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
|
|
|
|
|
|
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
|
|
|
|
|
use cot::db::{Database, DatabaseField, Identifier, LimitedString, Model};
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// ConfigSource — tracks where each field's effective value came from
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub enum ConfigSource {
|
|
|
|
|
|
Default,
|
|
|
|
|
|
Database,
|
|
|
|
|
|
Env,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ConfigSource {
|
|
|
|
|
|
pub fn code(self) -> &'static str {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
Self::Default => "default",
|
|
|
|
|
|
Self::Database => "database",
|
|
|
|
|
|
Self::Env => "env",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// ConfigEntry — DB model for the furu__config table
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
#[cot::db::model]
|
|
|
|
|
|
pub struct ConfigEntry {
|
|
|
|
|
|
#[model(primary_key)]
|
|
|
|
|
|
key: String,
|
|
|
|
|
|
value: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ConfigEntry {
|
|
|
|
|
|
pub fn new(key: String, value: String) -> Self {
|
|
|
|
|
|
Self { key, value }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Migration
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
pub mod db_migrations {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Copy, Clone)]
|
|
|
|
|
|
pub struct M0001CreateConfig;
|
|
|
|
|
|
|
|
|
|
|
|
impl migrations::Migration for M0001CreateConfig {
|
|
|
|
|
|
const APP_NAME: &'static str = "furumusic";
|
|
|
|
|
|
const MIGRATION_NAME: &'static str = "m_0001_create_config";
|
|
|
|
|
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[];
|
2026-05-25 13:50:24 +03:00
|
|
|
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
|
|
|
|
|
.table_name(Identifier::new("furu__config"))
|
|
|
|
|
|
.fields(&[
|
|
|
|
|
|
Field::new(
|
|
|
|
|
|
Identifier::new("key"),
|
|
|
|
|
|
<LimitedString<255> as DatabaseField>::TYPE,
|
|
|
|
|
|
)
|
|
|
|
|
|
.primary_key()
|
|
|
|
|
|
.set_null(<LimitedString<255> as DatabaseField>::NULLABLE),
|
|
|
|
|
|
Field::new(Identifier::new("value"), <String as DatabaseField>::TYPE)
|
2026-05-21 14:22:33 +03:00
|
|
|
|
.set_null(<String as DatabaseField>::NULLABLE),
|
2026-05-25 13:50:24 +03:00
|
|
|
|
])
|
|
|
|
|
|
.build()];
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -- M0002: rename furu__config → furumusic__config_entry ---------------
|
|
|
|
|
|
|
|
|
|
|
|
#[cot::db::migrations::migration_op]
|
|
|
|
|
|
async fn rename_config_table(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
|
|
|
|
|
ctx.db
|
|
|
|
|
|
.raw("ALTER TABLE furu__config RENAME TO furumusic__config_entry")
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Copy, Clone)]
|
|
|
|
|
|
pub struct M0002RenameConfigTable;
|
|
|
|
|
|
|
|
|
|
|
|
impl migrations::Migration for M0002RenameConfigTable {
|
|
|
|
|
|
const APP_NAME: &'static str = "furumusic";
|
|
|
|
|
|
const MIGRATION_NAME: &'static str = "m_0002_rename_config_table";
|
2026-05-25 13:50:24 +03:00
|
|
|
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
|
|
|
|
|
&[migrations::MigrationDependency::migration(
|
|
|
|
|
|
"furumusic",
|
|
|
|
|
|
"m_0001_create_config",
|
|
|
|
|
|
)];
|
|
|
|
|
|
const OPERATIONS: &'static [Operation] = &[Operation::custom(rename_config_table).build()];
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfig, &M0002RenameConfigTable];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// ConfigSources — parallel struct tracking the source of each field
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
pub struct ConfigSources {
|
|
|
|
|
|
pub database_url: ConfigSource,
|
|
|
|
|
|
pub oidc_issuer: ConfigSource,
|
|
|
|
|
|
pub oidc_client_id: ConfigSource,
|
|
|
|
|
|
pub oidc_client_secret: ConfigSource,
|
|
|
|
|
|
pub log_level: ConfigSource,
|
|
|
|
|
|
pub auth_password_enabled: ConfigSource,
|
|
|
|
|
|
pub auth_sso_enabled: ConfigSource,
|
|
|
|
|
|
pub oidc_button_text: ConfigSource,
|
|
|
|
|
|
pub oidc_admin_groups: ConfigSource,
|
2026-05-25 16:26:45 +03:00
|
|
|
|
pub oidc_user_groups: ConfigSource,
|
2026-05-21 16:21:42 +03:00
|
|
|
|
pub swagger_enabled: ConfigSource,
|
2026-05-23 13:08:09 +03:00
|
|
|
|
pub agent_enabled: ConfigSource,
|
|
|
|
|
|
pub agent_inbox_dir: ConfigSource,
|
|
|
|
|
|
pub agent_storage_dir: ConfigSource,
|
|
|
|
|
|
pub agent_llm_url: ConfigSource,
|
|
|
|
|
|
pub agent_llm_model: ConfigSource,
|
|
|
|
|
|
pub agent_llm_auth: ConfigSource,
|
|
|
|
|
|
pub agent_confidence_threshold: ConfigSource,
|
|
|
|
|
|
pub agent_context_limit: ConfigSource,
|
|
|
|
|
|
pub agent_concurrency: ConfigSource,
|
2026-05-26 18:16:34 +03:00
|
|
|
|
pub lastfm_api_key: ConfigSource,
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for ConfigSources {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
database_url: ConfigSource::Default,
|
|
|
|
|
|
oidc_issuer: ConfigSource::Default,
|
|
|
|
|
|
oidc_client_id: ConfigSource::Default,
|
|
|
|
|
|
oidc_client_secret: ConfigSource::Default,
|
|
|
|
|
|
log_level: ConfigSource::Default,
|
|
|
|
|
|
auth_password_enabled: ConfigSource::Default,
|
|
|
|
|
|
auth_sso_enabled: ConfigSource::Default,
|
|
|
|
|
|
oidc_button_text: ConfigSource::Default,
|
|
|
|
|
|
oidc_admin_groups: ConfigSource::Default,
|
2026-05-25 16:26:45 +03:00
|
|
|
|
oidc_user_groups: ConfigSource::Default,
|
2026-05-21 16:21:42 +03:00
|
|
|
|
swagger_enabled: ConfigSource::Default,
|
2026-05-23 13:08:09 +03:00
|
|
|
|
agent_enabled: ConfigSource::Default,
|
|
|
|
|
|
agent_inbox_dir: ConfigSource::Default,
|
|
|
|
|
|
agent_storage_dir: ConfigSource::Default,
|
|
|
|
|
|
agent_llm_url: ConfigSource::Default,
|
|
|
|
|
|
agent_llm_model: ConfigSource::Default,
|
|
|
|
|
|
agent_llm_auth: ConfigSource::Default,
|
|
|
|
|
|
agent_confidence_threshold: ConfigSource::Default,
|
|
|
|
|
|
agent_context_limit: ConfigSource::Default,
|
|
|
|
|
|
agent_concurrency: ConfigSource::Default,
|
2026-05-26 18:16:34 +03:00
|
|
|
|
lastfm_api_key: ConfigSource::Default,
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Env-var helper
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Read a single env var with the `FURU_` prefix, returning `None` when the
|
|
|
|
|
|
/// variable is absent and logging a warning when it is present but cannot be
|
|
|
|
|
|
/// parsed.
|
|
|
|
|
|
fn env_override<T: std::str::FromStr>(field: &str) -> Option<T> {
|
|
|
|
|
|
let key = format!("FURU_{}", field.to_ascii_uppercase());
|
|
|
|
|
|
match std::env::var(&key) {
|
|
|
|
|
|
Ok(val) => match val.parse::<T>() {
|
|
|
|
|
|
Ok(v) => Some(v),
|
|
|
|
|
|
Err(_) => {
|
|
|
|
|
|
tracing::warn!("ignoring invalid value for {key}: {val:?}");
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
Err(_) => None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Macro: generates apply_env_overrides + apply_env_overrides_tracked
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Generates two methods on [`AppConfig`]:
|
|
|
|
|
|
///
|
|
|
|
|
|
/// - `apply_env_overrides`: overwrites fields from `FURU_*` env vars (no source tracking).
|
|
|
|
|
|
/// - `apply_env_overrides_tracked`: same but also marks sources as [`ConfigSource::Env`].
|
|
|
|
|
|
macro_rules! impl_env_overrides {
|
|
|
|
|
|
($($field:ident),* $(,)?) => {
|
|
|
|
|
|
impl AppConfig {
|
|
|
|
|
|
/// Apply `FURU_*` environment variable overrides to self.
|
|
|
|
|
|
pub fn apply_env_overrides(&mut self) {
|
|
|
|
|
|
$(
|
|
|
|
|
|
if let Some(v) = env_override(stringify!($field)) {
|
|
|
|
|
|
self.$field = v;
|
|
|
|
|
|
}
|
|
|
|
|
|
)*
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Apply `FURU_*` environment variable overrides and record sources.
|
|
|
|
|
|
pub fn apply_env_overrides_tracked(&mut self, sources: &mut ConfigSources) {
|
|
|
|
|
|
$(
|
|
|
|
|
|
if let Some(v) = env_override(stringify!($field)) {
|
|
|
|
|
|
self.$field = v;
|
|
|
|
|
|
sources.$field = ConfigSource::Env;
|
|
|
|
|
|
}
|
|
|
|
|
|
)*
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// AppConfig
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct AppConfig {
|
|
|
|
|
|
/// PostgreSQL connection URL.
|
|
|
|
|
|
pub database_url: String,
|
|
|
|
|
|
/// OIDC issuer URL.
|
|
|
|
|
|
pub oidc_issuer: String,
|
|
|
|
|
|
/// OIDC client ID.
|
|
|
|
|
|
pub oidc_client_id: String,
|
|
|
|
|
|
/// OIDC client secret.
|
|
|
|
|
|
pub oidc_client_secret: String,
|
|
|
|
|
|
/// Tracing log level filter (e.g. "info", "debug", "warn,furumusic=debug").
|
|
|
|
|
|
pub log_level: String,
|
|
|
|
|
|
/// Whether password-based login is enabled.
|
|
|
|
|
|
pub auth_password_enabled: bool,
|
|
|
|
|
|
/// Whether SSO (OIDC) login is enabled.
|
|
|
|
|
|
pub auth_sso_enabled: bool,
|
|
|
|
|
|
/// Label shown on the SSO login button.
|
|
|
|
|
|
pub oidc_button_text: String,
|
|
|
|
|
|
/// Comma-separated list of OIDC group names that grant admin role.
|
|
|
|
|
|
pub oidc_admin_groups: String,
|
2026-05-25 16:26:45 +03:00
|
|
|
|
/// Comma-separated list of OIDC group names that are allowed to use the service.
|
|
|
|
|
|
pub oidc_user_groups: String,
|
2026-05-21 16:21:42 +03:00
|
|
|
|
/// Whether the Swagger UI is served at /swagger/.
|
|
|
|
|
|
pub swagger_enabled: bool,
|
2026-05-23 13:08:09 +03:00
|
|
|
|
/// Whether the AI agent background loop is enabled.
|
|
|
|
|
|
pub agent_enabled: bool,
|
|
|
|
|
|
/// Directory to scan for incoming audio files.
|
|
|
|
|
|
pub agent_inbox_dir: String,
|
|
|
|
|
|
/// Directory for organized permanent storage.
|
|
|
|
|
|
pub agent_storage_dir: String,
|
|
|
|
|
|
/// LLM API URL (OpenAI-compatible).
|
|
|
|
|
|
pub agent_llm_url: String,
|
|
|
|
|
|
/// LLM model name.
|
|
|
|
|
|
pub agent_llm_model: String,
|
|
|
|
|
|
/// LLM Authorization header value (e.g. "Bearer sk-...").
|
|
|
|
|
|
pub agent_llm_auth: String,
|
|
|
|
|
|
/// Confidence threshold for auto-approval (0.0–1.0).
|
|
|
|
|
|
pub agent_confidence_threshold: f64,
|
|
|
|
|
|
/// LLM context window size in tokens. Chat history resets when approaching this limit.
|
|
|
|
|
|
pub agent_context_limit: u64,
|
|
|
|
|
|
/// Number of files to process in parallel via the LLM.
|
|
|
|
|
|
pub agent_concurrency: u64,
|
2026-05-26 18:16:34 +03:00
|
|
|
|
/// Last.fm API key for weekly popularity enrichment.
|
|
|
|
|
|
pub lastfm_api_key: String,
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for AppConfig {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
database_url: String::new(),
|
|
|
|
|
|
oidc_issuer: String::new(),
|
|
|
|
|
|
oidc_client_id: String::new(),
|
|
|
|
|
|
oidc_client_secret: String::new(),
|
|
|
|
|
|
log_level: "info".into(),
|
|
|
|
|
|
auth_password_enabled: true,
|
|
|
|
|
|
auth_sso_enabled: false,
|
|
|
|
|
|
oidc_button_text: "Sign in with SSO".into(),
|
|
|
|
|
|
oidc_admin_groups: String::new(),
|
2026-05-25 16:26:45 +03:00
|
|
|
|
oidc_user_groups: String::new(),
|
2026-05-21 16:21:42 +03:00
|
|
|
|
swagger_enabled: false,
|
2026-05-23 13:08:09 +03:00
|
|
|
|
agent_enabled: false,
|
|
|
|
|
|
agent_inbox_dir: String::new(),
|
|
|
|
|
|
agent_storage_dir: String::new(),
|
|
|
|
|
|
agent_llm_url: "http://localhost:8080".into(),
|
|
|
|
|
|
agent_llm_model: "default".into(),
|
|
|
|
|
|
agent_llm_auth: String::new(),
|
|
|
|
|
|
agent_confidence_threshold: 0.85,
|
|
|
|
|
|
agent_context_limit: 8192,
|
|
|
|
|
|
agent_concurrency: 2,
|
2026-05-26 18:16:34 +03:00
|
|
|
|
lastfm_api_key: String::new(),
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Register every field that should be overridable via FURU_* env vars.
|
|
|
|
|
|
impl_env_overrides!(
|
|
|
|
|
|
database_url,
|
|
|
|
|
|
oidc_issuer,
|
|
|
|
|
|
oidc_client_id,
|
|
|
|
|
|
oidc_client_secret,
|
|
|
|
|
|
log_level,
|
|
|
|
|
|
auth_password_enabled,
|
|
|
|
|
|
auth_sso_enabled,
|
|
|
|
|
|
oidc_button_text,
|
|
|
|
|
|
oidc_admin_groups,
|
2026-05-25 16:26:45 +03:00
|
|
|
|
oidc_user_groups,
|
2026-05-21 16:21:42 +03:00
|
|
|
|
swagger_enabled,
|
2026-05-23 13:08:09 +03:00
|
|
|
|
agent_enabled,
|
|
|
|
|
|
agent_inbox_dir,
|
|
|
|
|
|
agent_storage_dir,
|
|
|
|
|
|
agent_llm_url,
|
|
|
|
|
|
agent_llm_model,
|
|
|
|
|
|
agent_llm_auth,
|
|
|
|
|
|
agent_confidence_threshold,
|
|
|
|
|
|
agent_context_limit,
|
|
|
|
|
|
agent_concurrency,
|
2026-05-26 18:16:34 +03:00
|
|
|
|
lastfm_api_key,
|
2026-05-21 14:22:33 +03:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
impl AppConfig {
|
2026-05-26 14:47:10 +03:00
|
|
|
|
fn normalize_host_paths(&mut self) {
|
|
|
|
|
|
self.agent_inbox_dir = normalize_host_path(&self.agent_inbox_dir);
|
|
|
|
|
|
self.agent_storage_dir = normalize_host_path(&self.agent_storage_dir);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 14:22:33 +03:00
|
|
|
|
/// Build config: start from defaults, then overlay env vars.
|
|
|
|
|
|
/// Used at startup before the DB is available (to get `database_url`).
|
|
|
|
|
|
pub fn load() -> Self {
|
|
|
|
|
|
let mut cfg = Self::default();
|
|
|
|
|
|
cfg.apply_env_overrides();
|
2026-05-26 14:47:10 +03:00
|
|
|
|
cfg.normalize_host_paths();
|
2026-05-21 14:22:33 +03:00
|
|
|
|
cfg
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Build config with full 3-layer resolution (default → DB → env) and
|
|
|
|
|
|
/// track the source of each field.
|
|
|
|
|
|
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
|
|
|
|
|
let mut cfg = Self::default();
|
|
|
|
|
|
let mut sources = ConfigSources::default();
|
|
|
|
|
|
cfg.apply_db_overrides(db, &mut sources).await;
|
|
|
|
|
|
cfg.apply_env_overrides_tracked(&mut sources);
|
2026-05-26 14:47:10 +03:00
|
|
|
|
cfg.normalize_host_paths();
|
2026-05-21 14:22:33 +03:00
|
|
|
|
(cfg, sources)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Query all rows from `furu__config` and overlay matching fields.
|
|
|
|
|
|
async fn apply_db_overrides(&mut self, db: &Database, sources: &mut ConfigSources) {
|
|
|
|
|
|
let rows = match ConfigEntry::objects().all(db).await {
|
|
|
|
|
|
Ok(rows) => rows,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::warn!("failed to read furu__config: {e}");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let map: HashMap<String, String> = rows
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|entry| (entry.key.to_string(), entry.value))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
macro_rules! apply_db_field {
|
|
|
|
|
|
($field:ident) => {
|
|
|
|
|
|
if let Some(val) = map.get(stringify!($field)) {
|
|
|
|
|
|
match val.parse() {
|
|
|
|
|
|
Ok(v) => {
|
|
|
|
|
|
self.$field = v;
|
|
|
|
|
|
sources.$field = ConfigSource::Database;
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(_) => {
|
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
|
"ignoring invalid DB config value for {}: {:?}",
|
|
|
|
|
|
stringify!($field),
|
|
|
|
|
|
val,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
apply_db_field!(database_url);
|
|
|
|
|
|
apply_db_field!(oidc_issuer);
|
|
|
|
|
|
apply_db_field!(oidc_client_id);
|
|
|
|
|
|
apply_db_field!(oidc_client_secret);
|
|
|
|
|
|
apply_db_field!(log_level);
|
|
|
|
|
|
apply_db_field!(auth_password_enabled);
|
|
|
|
|
|
apply_db_field!(auth_sso_enabled);
|
|
|
|
|
|
apply_db_field!(oidc_button_text);
|
|
|
|
|
|
apply_db_field!(oidc_admin_groups);
|
2026-05-25 16:26:45 +03:00
|
|
|
|
apply_db_field!(oidc_user_groups);
|
2026-05-21 16:21:42 +03:00
|
|
|
|
apply_db_field!(swagger_enabled);
|
2026-05-23 13:08:09 +03:00
|
|
|
|
apply_db_field!(agent_enabled);
|
|
|
|
|
|
apply_db_field!(agent_inbox_dir);
|
|
|
|
|
|
apply_db_field!(agent_storage_dir);
|
|
|
|
|
|
apply_db_field!(agent_llm_url);
|
|
|
|
|
|
apply_db_field!(agent_llm_model);
|
|
|
|
|
|
apply_db_field!(agent_llm_auth);
|
|
|
|
|
|
apply_db_field!(agent_confidence_threshold);
|
|
|
|
|
|
apply_db_field!(agent_context_limit);
|
|
|
|
|
|
apply_db_field!(agent_concurrency);
|
2026-05-26 18:16:34 +03:00
|
|
|
|
apply_db_field!(lastfm_api_key);
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 14:47:10 +03:00
|
|
|
|
fn normalize_host_path(value: &str) -> String {
|
|
|
|
|
|
let trimmed = value.trim();
|
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
|
return String::new();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalize_windows_user_path(trimmed).unwrap_or_else(|| trimmed.to_owned())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
|
fn normalize_windows_user_path(value: &str) -> Option<String> {
|
|
|
|
|
|
let normalized = value.replace('\\', "/");
|
|
|
|
|
|
let mut parts = normalized.split('/').filter(|part| !part.is_empty());
|
|
|
|
|
|
let drive = parts.next()?;
|
|
|
|
|
|
if drive.len() != 2 || !drive.ends_with(':') {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
if !parts.next()?.eq_ignore_ascii_case("Users") {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let user = parts.next()?;
|
|
|
|
|
|
if user.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut out = format!("/Users/{user}");
|
|
|
|
|
|
for part in parts {
|
|
|
|
|
|
out.push('/');
|
|
|
|
|
|
out.push_str(part);
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
|
fn normalize_windows_user_path(_value: &str) -> Option<String> {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 14:22:33 +03:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn defaults_are_sane() {
|
|
|
|
|
|
let cfg = AppConfig::default();
|
|
|
|
|
|
assert!(cfg.database_url.is_empty());
|
|
|
|
|
|
assert_eq!(cfg.log_level, "info");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 14:47:10 +03:00
|
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn normalizes_windows_user_path_on_unix() {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
normalize_host_path(r"C:\Users\ab\repos\furumusic\media\uploads"),
|
|
|
|
|
|
"/Users/ab/repos/furumusic/media/uploads"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(not(windows))]
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn leaves_unix_path_unchanged() {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
normalize_host_path("/Users/ab/repos/furumusic/media/uploads"),
|
|
|
|
|
|
"/Users/ab/repos/furumusic/media/uploads"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 14:22:33 +03:00
|
|
|
|
// SAFETY: tests run with --test-threads=1 so no concurrent env access.
|
2026-05-25 13:50:24 +03:00
|
|
|
|
unsafe fn set(k: &str, v: &str) {
|
|
|
|
|
|
unsafe { std::env::set_var(k, v) };
|
|
|
|
|
|
}
|
|
|
|
|
|
unsafe fn unset(k: &str) {
|
|
|
|
|
|
unsafe { std::env::remove_var(k) };
|
|
|
|
|
|
}
|
2026-05-21 14:22:33 +03:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn env_override_string_field() {
|
2026-05-25 13:50:24 +03:00
|
|
|
|
unsafe {
|
|
|
|
|
|
set("FURU_OIDC_ISSUER", "https://example.com");
|
|
|
|
|
|
}
|
2026-05-21 14:22:33 +03:00
|
|
|
|
let cfg = AppConfig::load();
|
|
|
|
|
|
assert_eq!(cfg.oidc_issuer, "https://example.com");
|
2026-05-25 13:50:24 +03:00
|
|
|
|
unsafe {
|
|
|
|
|
|
unset("FURU_OIDC_ISSUER");
|
|
|
|
|
|
}
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn env_override_bool_field() {
|
2026-05-25 13:50:24 +03:00
|
|
|
|
unsafe {
|
|
|
|
|
|
set("FURU_AUTH_SSO_ENABLED", "true");
|
|
|
|
|
|
}
|
2026-05-21 14:22:33 +03:00
|
|
|
|
let cfg = AppConfig::load();
|
|
|
|
|
|
assert!(cfg.auth_sso_enabled);
|
2026-05-25 13:50:24 +03:00
|
|
|
|
unsafe {
|
|
|
|
|
|
unset("FURU_AUTH_SSO_ENABLED");
|
|
|
|
|
|
}
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn source_tracking_env() {
|
2026-05-25 13:50:24 +03:00
|
|
|
|
unsafe {
|
|
|
|
|
|
set("FURU_OIDC_ISSUER", "https://tracked.example.com");
|
|
|
|
|
|
}
|
2026-05-21 14:22:33 +03:00
|
|
|
|
let mut cfg = AppConfig::default();
|
|
|
|
|
|
let mut sources = ConfigSources::default();
|
|
|
|
|
|
cfg.apply_env_overrides_tracked(&mut sources);
|
|
|
|
|
|
assert_eq!(cfg.oidc_issuer, "https://tracked.example.com");
|
|
|
|
|
|
assert_eq!(sources.oidc_issuer, ConfigSource::Env);
|
|
|
|
|
|
assert_eq!(sources.database_url, ConfigSource::Default);
|
2026-05-25 13:50:24 +03:00
|
|
|
|
unsafe {
|
|
|
|
|
|
unset("FURU_OIDC_ISSUER");
|
|
|
|
|
|
}
|
2026-05-21 14:22:33 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn config_source_codes() {
|
|
|
|
|
|
assert_eq!(ConfigSource::Default.code(), "default");
|
|
|
|
|
|
assert_eq!(ConfigSource::Database.code(), "database");
|
|
|
|
|
|
assert_eq!(ConfigSource::Env.code(), "env");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|