changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

@@ -6,3 +6,8 @@ version.workspace = true
[dependencies]
thiserror.workspace = true
serde.workspace = true
figment.workspace = true
tracing.workspace = true
[dev-dependencies]
figment = { workspace = true, features = ["test"] }

View File

@@ -0,0 +1,18 @@
const DEFAULT_MINIMUM_SAMPLE_SIZE: usize = 30;
const DEFAULT_FALSE_DISCOVERY_RATE: f64 = 0.10;
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AnalysisConfig {
pub minimum_sample_size: usize,
pub false_discovery_rate: f64,
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
minimum_sample_size: DEFAULT_MINIMUM_SAMPLE_SIZE,
false_discovery_rate: DEFAULT_FALSE_DISCOVERY_RATE,
}
}
}

View File

@@ -1,4 +1,7 @@
use super::{AuthConfig, EntryConfig, PresetConfig, PushConfig, ServerConfig, StorageConfig};
use super::{
AnalysisConfig, AuthConfig, EntryConfig, ImportConfig, PresetConfig, ProviderConfig,
PushConfig, ServerConfig, StorageConfig, WorkerConfig,
};
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
@@ -9,4 +12,8 @@ pub struct AppConfig {
pub auth: AuthConfig,
pub push: PushConfig,
pub preset: PresetConfig,
pub provider: ProviderConfig,
pub analysis: AnalysisConfig,
pub import: ImportConfig,
pub worker: WorkerConfig,
}

View File

@@ -0,0 +1,7 @@
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("configuration file {0} was named by the environment but does not exist")]
NamedFileMissing(String),
#[error("could not read configuration: {0}")]
Unreadable(#[from] Box<figment::Error>),
}

View File

@@ -0,0 +1,18 @@
const DEFAULT_MAXIMUM_DAYS_PER_IMPORT: usize = 90;
const DEFAULT_REJECTIONS_KEPT: usize = 200;
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(default)]
pub struct ImportConfig {
pub maximum_days_per_import: usize,
pub rejections_kept: usize,
}
impl Default for ImportConfig {
fn default() -> Self {
Self {
maximum_days_per_import: DEFAULT_MAXIMUM_DAYS_PER_IMPORT,
rejections_kept: DEFAULT_REJECTIONS_KEPT,
}
}
}

View File

@@ -1,15 +1,27 @@
mod analysis_config;
mod app_config;
mod auth_config;
mod entry_config;
mod errors;
mod import_config;
mod loader;
mod preset;
mod provider_config;
mod push_config;
mod server_config;
mod storage_config;
mod worker_config;
pub use analysis_config::AnalysisConfig;
pub use app_config::AppConfig;
pub use auth_config::AuthConfig;
pub use entry_config::EntryConfig;
pub use errors::ConfigError;
pub use import_config::ImportConfig;
pub use loader::load;
pub use preset::{PresetActivity, PresetConfig};
pub use provider_config::ProviderConfig;
pub use push_config::PushConfig;
pub use server_config::{CorsConfig, ServerConfig};
pub use storage_config::{MediaBackend, StorageConfig};
pub use worker_config::WorkerConfig;

View File

@@ -0,0 +1,56 @@
use std::path::Path;
use figment::Figment;
use figment::providers::{Env, Format, Toml};
use crate::AppConfig;
use crate::errors::ConfigError;
const DEFAULT_CONFIG_FILE: &str = "config.toml";
const CONFIG_FILE_VARIABLE: &str = "KMOOD_CONFIG_FILE";
const VARIABLE_PREFIX: &str = "KMOOD_";
const SECTION_SEPARATOR: &str = "__";
pub fn load() -> Result<AppConfig, ConfigError> {
let named_file = std::env::var(CONFIG_FILE_VARIABLE).ok();
let path = named_file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE);
if named_file.is_some() && !Path::new(path).exists() {
return Err(ConfigError::NamedFileMissing(path.to_owned()));
}
let config = Figment::new()
.merge(Toml::file(path))
.merge(Env::prefixed(VARIABLE_PREFIX).split(SECTION_SEPARATOR))
.extract()
.map_err(Box::new)?;
announce(path);
Ok(config)
}
fn announce(path: &str) {
if Path::new(path).exists() {
tracing::info!(path, "loaded configuration from file");
} else {
tracing::info!("no configuration file found, using defaults");
}
let overridden = overriding_variables();
if !overridden.is_empty() {
tracing::info!(
variables = ?overridden,
"configuration overridden from the environment"
);
}
}
fn overriding_variables() -> Vec<String> {
let mut names: Vec<String> = std::env::vars()
.map(|(name, _)| name)
.filter(|name| name.starts_with(VARIABLE_PREFIX) && name != CONFIG_FILE_VARIABLE)
.collect();
names.sort();
names
}

View File

@@ -0,0 +1,5 @@
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct ProviderConfig {
pub encryption_key: Option<String>,
}

View File

@@ -0,0 +1,39 @@
const DEFAULT_POLL_SECONDS: u64 = 15;
const DEFAULT_SWEEP_SECONDS: u64 = 900;
const DEFAULT_SESSION_CLEANUP_SECONDS: u64 = 3600;
const DEFAULT_REMINDER_SECONDS: u64 = 60;
const DEFAULT_JOBS_PER_POLL: usize = 20;
const DEFAULT_ENQUEUED_PER_SWEEP: usize = 200;
const DEFAULT_MOST_ATTEMPTS: u32 = 5;
const DEFAULT_STALLED_AFTER_SECONDS: i64 = 300;
const DEFAULT_LOOK_UP_WEATHER: bool = true;
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(default)]
pub struct WorkerConfig {
pub poll_seconds: u64,
pub sweep_seconds: u64,
pub session_cleanup_seconds: u64,
pub reminder_seconds: u64,
pub jobs_per_poll: usize,
pub enqueued_per_sweep: usize,
pub most_attempts: u32,
pub stalled_after_seconds: i64,
pub look_up_weather: bool,
}
impl Default for WorkerConfig {
fn default() -> Self {
Self {
poll_seconds: DEFAULT_POLL_SECONDS,
sweep_seconds: DEFAULT_SWEEP_SECONDS,
session_cleanup_seconds: DEFAULT_SESSION_CLEANUP_SECONDS,
reminder_seconds: DEFAULT_REMINDER_SECONDS,
jobs_per_poll: DEFAULT_JOBS_PER_POLL,
enqueued_per_sweep: DEFAULT_ENQUEUED_PER_SWEEP,
most_attempts: DEFAULT_MOST_ATTEMPTS,
stalled_after_seconds: DEFAULT_STALLED_AFTER_SECONDS,
look_up_weather: DEFAULT_LOOK_UP_WEATHER,
}
}
}

View File

@@ -0,0 +1,7 @@
#![allow(clippy::result_large_err)]
#[path = "loader/layering_test.rs"]
mod layering_test;
#[path = "loader/rejection_test.rs"]
mod rejection_test;

View File

@@ -0,0 +1,138 @@
use figment::Jail;
use config::{MediaBackend, load};
const CONFIG_FILE: &str = "config.toml";
#[test]
fn with_no_file_and_no_environment_every_default_stands() {
Jail::expect_with(|_jail| {
let loaded = load().unwrap();
assert_eq!(loaded.server.port, 3000);
assert_eq!(loaded.server.host, "0.0.0.0");
assert_eq!(loaded.storage.data_dir, "./data");
assert!(loaded.auth.jwt_secret.is_none());
Ok(())
});
}
#[test]
fn a_file_overrides_the_defaults_it_names() {
Jail::expect_with(|jail| {
jail.create_file(CONFIG_FILE, "[server]\nport = 4321\n")?;
let loaded = load().unwrap();
assert_eq!(loaded.server.port, 4321);
assert_eq!(loaded.server.host, "0.0.0.0");
Ok(())
});
}
#[test]
fn the_environment_overrides_the_file() {
Jail::expect_with(|jail| {
jail.create_file(CONFIG_FILE, "[server]\nport = 4321\nhost = \"127.0.0.1\"\n")?;
jail.set_env("KMOOD_SERVER__PORT", "8080");
let loaded = load().unwrap();
assert_eq!(loaded.server.port, 8080);
assert_eq!(loaded.server.host, "127.0.0.1");
Ok(())
});
}
#[test]
fn a_double_underscore_descends_a_section() {
Jail::expect_with(|jail| {
jail.set_env("KMOOD_SERVER__CORS__ALLOW_ANY_ORIGIN", "false");
let loaded = load().unwrap();
assert!(!loaded.server.cors.allow_any_origin);
Ok(())
});
}
#[test]
fn a_single_underscore_stays_inside_a_field_name() {
Jail::expect_with(|jail| {
jail.set_env("KMOOD_STORAGE__DATA_DIR", "/var/lib/kmood");
jail.set_env("KMOOD_AUTH__JWT_SECRET", "from-the-environment");
let loaded = load().unwrap();
assert_eq!(loaded.storage.data_dir, "/var/lib/kmood");
assert_eq!(
loaded.auth.jwt_secret.as_deref(),
Some("from-the-environment")
);
Ok(())
});
}
#[test]
fn the_environment_can_switch_the_media_backend() {
Jail::expect_with(|jail| {
jail.set_env("KMOOD_STORAGE__MEDIA__BACKEND", "s3");
jail.set_env("KMOOD_STORAGE__MEDIA__BUCKET", "moods");
jail.set_env("KMOOD_STORAGE__MEDIA__REGION", "eu-central-1");
let loaded = load().unwrap();
match loaded.storage.media {
MediaBackend::S3 { bucket, region, .. } => {
assert_eq!(bucket, "moods");
assert_eq!(region, "eu-central-1");
}
other => panic!("expected the s3 backend, got {other:?}"),
}
Ok(())
});
}
#[test]
fn an_unrelated_variable_leaves_the_configuration_alone() {
Jail::expect_with(|jail| {
jail.set_env("PORT", "9999");
jail.set_env("SERVER__PORT", "9999");
let loaded = load().unwrap();
assert_eq!(loaded.server.port, 3000);
Ok(())
});
}
#[test]
fn a_list_arrives_as_an_array() {
Jail::expect_with(|jail| {
jail.set_env(
"KMOOD_SERVER__CORS__ALLOWED_ORIGINS",
r#"["https://a.example","https://b.example"]"#,
);
let loaded = load().unwrap();
assert_eq!(
loaded.server.cors.allowed_origins,
vec!["https://a.example", "https://b.example"]
);
Ok(())
});
}
#[test]
fn the_file_to_read_can_be_named_by_the_environment() {
Jail::expect_with(|jail| {
jail.create_file("elsewhere.toml", "[server]\nport = 7777\n")?;
jail.set_env("KMOOD_CONFIG_FILE", "elsewhere.toml");
let loaded = load().unwrap();
assert_eq!(loaded.server.port, 7777);
Ok(())
});
}

View File

@@ -0,0 +1,47 @@
use figment::Jail;
use config::load;
const CONFIG_FILE: &str = "config.toml";
#[test]
fn a_malformed_file_is_refused_rather_than_ignored() {
Jail::expect_with(|jail| {
jail.create_file(CONFIG_FILE, "[server\nport = ")?;
assert!(load().is_err());
Ok(())
});
}
#[test]
fn a_value_of_the_wrong_type_is_refused() {
Jail::expect_with(|jail| {
jail.set_env("KMOOD_SERVER__PORT", "not-a-port");
assert!(load().is_err());
Ok(())
});
}
#[test]
fn a_refusal_names_the_key_at_fault() {
Jail::expect_with(|jail| {
jail.set_env("KMOOD_SERVER__PORT", "not-a-port");
let message = load().unwrap_err().to_string().to_lowercase();
assert!(message.contains("port"), "unhelpful message: {message}");
Ok(())
});
}
#[test]
fn a_named_file_that_is_missing_is_refused_rather_than_ignored() {
Jail::expect_with(|jail| {
jail.set_env("KMOOD_CONFIG_FILE", "nowhere.toml");
assert!(load().is_err());
Ok(())
});
}