pinakes/crates/pinakes-core/src/config.rs
NotAShelf e02c15490e
pinakes-core: improve media management features; various configuration improvements
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2d1f04f13970d21c36067f30bc04a9176a6a6964
2026-02-05 06:34:21 +03:00

1109 lines
32 KiB
Rust

use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Expand environment variables in a string.
/// Supports both ${VAR_NAME} and $VAR_NAME syntax.
/// Returns an error if a referenced variable is not set.
fn expand_env_var_string(input: &str) -> crate::error::Result<String> {
let mut result = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '$' {
// Check if it's ${VAR} or $VAR syntax
let use_braces = chars.peek() == Some(&'{');
if use_braces {
chars.next(); // consume '{'
}
// Collect variable name
let mut var_name = String::new();
while let Some(&next_ch) = chars.peek() {
if use_braces {
if next_ch == '}' {
chars.next(); // consume '}'
break;
}
var_name.push(next_ch);
chars.next();
} else {
// For $VAR syntax, stop at non-alphanumeric/underscore
if next_ch.is_alphanumeric() || next_ch == '_' {
var_name.push(next_ch);
chars.next();
} else {
break;
}
}
}
if var_name.is_empty() {
return Err(crate::error::PinakesError::Config(
"empty environment variable name".to_string(),
));
}
// Look up the environment variable
match std::env::var(&var_name) {
Ok(value) => result.push_str(&value),
Err(_) => {
return Err(crate::error::PinakesError::Config(format!(
"environment variable not set: {}",
var_name
)));
}
}
} else if ch == '\\' {
// Handle escaped characters
if let Some(&next_ch) = chars.peek() {
if next_ch == '$' {
chars.next(); // consume the escaped $
result.push('$');
} else {
result.push(ch);
}
} else {
result.push(ch);
}
} else {
result.push(ch);
}
}
Ok(result)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub storage: StorageConfig,
pub directories: DirectoryConfig,
pub scanning: ScanningConfig,
pub server: ServerConfig,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub accounts: AccountsConfig,
#[serde(default)]
pub jobs: JobsConfig,
#[serde(default)]
pub thumbnails: ThumbnailConfig,
#[serde(default)]
pub webhooks: Vec<WebhookConfig>,
#[serde(default)]
pub scheduled_tasks: Vec<ScheduledTaskConfig>,
#[serde(default)]
pub plugins: PluginsConfig,
#[serde(default)]
pub transcoding: TranscodingConfig,
#[serde(default)]
pub enrichment: EnrichmentConfig,
#[serde(default)]
pub cloud: CloudConfig,
#[serde(default)]
pub analytics: AnalyticsConfig,
#[serde(default)]
pub photos: PhotoConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledTaskConfig {
pub id: String,
pub enabled: bool,
pub schedule: crate::scheduler::Schedule,
pub last_run: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobsConfig {
#[serde(default = "default_worker_count")]
pub worker_count: usize,
#[serde(default = "default_cache_ttl")]
pub cache_ttl_secs: u64,
}
fn default_worker_count() -> usize {
2
}
fn default_cache_ttl() -> u64 {
60
}
impl Default for JobsConfig {
fn default() -> Self {
Self {
worker_count: default_worker_count(),
cache_ttl_secs: default_cache_ttl(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThumbnailConfig {
#[serde(default = "default_thumb_size")]
pub size: u32,
#[serde(default = "default_thumb_quality")]
pub quality: u8,
#[serde(default)]
pub ffmpeg_path: Option<String>,
#[serde(default = "default_video_seek")]
pub video_seek_secs: u32,
}
fn default_thumb_size() -> u32 {
320
}
fn default_thumb_quality() -> u8 {
80
}
fn default_video_seek() -> u32 {
2
}
impl Default for ThumbnailConfig {
fn default() -> Self {
Self {
size: default_thumb_size(),
quality: default_thumb_quality(),
ffmpeg_path: None,
video_seek_secs: default_video_seek(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
pub url: String,
pub events: Vec<String>,
#[serde(default)]
pub secret: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_view")]
pub default_view: String,
#[serde(default = "default_page_size")]
pub default_page_size: usize,
#[serde(default = "default_view_mode")]
pub default_view_mode: String,
#[serde(default)]
pub auto_play_media: bool,
#[serde(default = "default_true")]
pub show_thumbnails: bool,
#[serde(default)]
pub sidebar_collapsed: bool,
}
fn default_theme() -> String {
"dark".to_string()
}
fn default_view() -> String {
"library".to_string()
}
fn default_page_size() -> usize {
48
}
fn default_view_mode() -> String {
"grid".to_string()
}
fn default_true() -> bool {
true
}
impl Default for UiConfig {
fn default() -> Self {
Self {
theme: default_theme(),
default_view: default_view(),
default_page_size: default_page_size(),
default_view_mode: default_view_mode(),
auto_play_media: false,
show_thumbnails: true,
sidebar_collapsed: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AccountsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub users: Vec<UserAccount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserAccount {
pub username: String,
pub password_hash: String,
#[serde(default)]
pub role: UserRole,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UserRole {
Admin,
Editor,
#[default]
Viewer,
}
impl UserRole {
pub fn can_read(self) -> bool {
true
}
pub fn can_write(self) -> bool {
matches!(self, Self::Admin | Self::Editor)
}
pub fn can_admin(self) -> bool {
matches!(self, Self::Admin)
}
}
impl std::fmt::Display for UserRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Admin => write!(f, "admin"),
Self::Editor => write!(f, "editor"),
Self::Viewer => write!(f, "viewer"),
}
}
}
// ===== Plugin Configuration =====
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_plugin_data_dir")]
pub data_dir: PathBuf,
#[serde(default = "default_plugin_cache_dir")]
pub cache_dir: PathBuf,
#[serde(default)]
pub plugin_dirs: Vec<PathBuf>,
#[serde(default)]
pub enable_hot_reload: bool,
#[serde(default)]
pub allow_unsigned: bool,
#[serde(default = "default_max_concurrent_ops")]
pub max_concurrent_ops: usize,
#[serde(default = "default_plugin_timeout")]
pub plugin_timeout_secs: u64,
}
fn default_plugin_data_dir() -> PathBuf {
Config::default_data_dir().join("plugins").join("data")
}
fn default_plugin_cache_dir() -> PathBuf {
Config::default_data_dir().join("plugins").join("cache")
}
fn default_max_concurrent_ops() -> usize {
4
}
fn default_plugin_timeout() -> u64 {
30
}
impl Default for PluginsConfig {
fn default() -> Self {
Self {
enabled: false,
data_dir: default_plugin_data_dir(),
cache_dir: default_plugin_cache_dir(),
plugin_dirs: vec![],
enable_hot_reload: false,
allow_unsigned: false,
max_concurrent_ops: default_max_concurrent_ops(),
plugin_timeout_secs: default_plugin_timeout(),
}
}
}
// ===== Transcoding Configuration =====
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscodingConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub cache_dir: Option<PathBuf>,
#[serde(default = "default_cache_ttl_hours")]
pub cache_ttl_hours: u64,
#[serde(default = "default_max_concurrent_transcodes")]
pub max_concurrent: usize,
#[serde(default)]
pub hardware_acceleration: Option<String>,
#[serde(default)]
pub profiles: Vec<TranscodeProfile>,
}
fn default_cache_ttl_hours() -> u64 {
48
}
fn default_max_concurrent_transcodes() -> usize {
2
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscodeProfile {
pub name: String,
pub video_codec: String,
pub audio_codec: String,
pub max_bitrate_kbps: u32,
pub max_resolution: String,
}
impl Default for TranscodingConfig {
fn default() -> Self {
Self {
enabled: false,
cache_dir: None,
cache_ttl_hours: default_cache_ttl_hours(),
max_concurrent: default_max_concurrent_transcodes(),
hardware_acceleration: None,
profiles: vec![
TranscodeProfile {
name: "high".to_string(),
video_codec: "h264".to_string(),
audio_codec: "aac".to_string(),
max_bitrate_kbps: 8000,
max_resolution: "1080p".to_string(),
},
TranscodeProfile {
name: "medium".to_string(),
video_codec: "h264".to_string(),
audio_codec: "aac".to_string(),
max_bitrate_kbps: 4000,
max_resolution: "720p".to_string(),
},
],
}
}
}
// ===== Enrichment Configuration =====
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnrichmentConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub auto_enrich_on_import: bool,
#[serde(default)]
pub sources: EnrichmentSources,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EnrichmentSources {
#[serde(default)]
pub musicbrainz: EnrichmentSource,
#[serde(default)]
pub tmdb: EnrichmentSource,
#[serde(default)]
pub lastfm: EnrichmentSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnrichmentSource {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub api_endpoint: Option<String>,
}
// ===== Cloud Configuration =====
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_auto_sync_interval")]
pub auto_sync_interval_mins: u64,
#[serde(default)]
pub accounts: Vec<CloudAccount>,
}
fn default_auto_sync_interval() -> u64 {
60
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudAccount {
pub id: String,
pub provider: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub sync_rules: Vec<CloudSyncRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudSyncRule {
pub local_path: PathBuf,
pub remote_path: String,
pub direction: CloudSyncDirection,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CloudSyncDirection {
Upload,
Download,
Bidirectional,
}
impl Default for CloudConfig {
fn default() -> Self {
Self {
enabled: false,
auto_sync_interval_mins: default_auto_sync_interval(),
accounts: vec![],
}
}
}
// ===== Analytics Configuration =====
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyticsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_true")]
pub track_usage: bool,
#[serde(default = "default_retention_days")]
pub retention_days: u64,
}
fn default_retention_days() -> u64 {
90
}
impl Default for AnalyticsConfig {
fn default() -> Self {
Self {
enabled: false,
track_usage: true,
retention_days: default_retention_days(),
}
}
}
// ===== Photo Management Configuration =====
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhotoConfig {
/// Generate perceptual hashes for image duplicate detection (CPU-intensive)
#[serde(default = "default_true")]
pub generate_perceptual_hash: bool,
/// Automatically create tags from EXIF keywords
#[serde(default)]
pub auto_tag_from_exif: bool,
/// Generate multi-resolution thumbnails (tiny, grid, preview)
#[serde(default)]
pub multi_resolution_thumbnails: bool,
/// Auto-detect photo events/albums based on time and location
#[serde(default)]
pub enable_event_detection: bool,
/// Minimum number of photos to form an event
#[serde(default = "default_min_event_photos")]
pub min_event_photos: usize,
/// Maximum time gap between photos in the same event (in seconds)
#[serde(default = "default_event_time_gap")]
pub event_time_gap_secs: i64,
/// Maximum distance between photos in the same event (in kilometers)
#[serde(default = "default_event_distance")]
pub event_max_distance_km: f64,
}
fn default_min_event_photos() -> usize {
5
}
fn default_event_time_gap() -> i64 {
2 * 60 * 60 // 2 hours
}
fn default_event_distance() -> f64 {
1.0 // 1 km
}
impl Default for PhotoConfig {
fn default() -> Self {
Self {
generate_perceptual_hash: true,
auto_tag_from_exif: false,
multi_resolution_thumbnails: false,
enable_event_detection: false,
min_event_photos: default_min_event_photos(),
event_time_gap_secs: default_event_time_gap(),
event_max_distance_km: default_event_distance(),
}
}
}
// ===== Storage Configuration =====
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
pub backend: StorageBackendType,
pub sqlite: Option<SqliteConfig>,
pub postgres: Option<PostgresConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StorageBackendType {
Sqlite,
Postgres,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SqliteConfig {
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostgresConfig {
pub host: String,
pub port: u16,
pub database: String,
pub username: String,
pub password: String,
pub max_connections: usize,
/// Enable TLS for PostgreSQL connections
#[serde(default)]
pub tls_enabled: bool,
/// Verify TLS certificates (default: true)
#[serde(default = "default_true")]
pub tls_verify_ca: bool,
/// Path to custom CA certificate file (PEM format)
#[serde(default)]
pub tls_ca_cert_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryConfig {
pub roots: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanningConfig {
pub watch: bool,
pub poll_interval_secs: u64,
pub ignore_patterns: Vec<String>,
#[serde(default = "default_import_concurrency")]
pub import_concurrency: usize,
}
fn default_import_concurrency() -> usize {
8
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
/// Optional API key for bearer token authentication.
/// If set, all requests (except /health) must include `Authorization: Bearer <key>`.
/// Can also be set via `PINAKES_API_KEY` environment variable.
pub api_key: Option<String>,
/// Explicitly disable authentication (INSECURE - use only for development).
/// When true, all requests are allowed without authentication.
/// This must be explicitly set to true; empty api_key alone is not sufficient.
#[serde(default)]
pub authentication_disabled: bool,
/// TLS/HTTPS configuration
#[serde(default)]
pub tls: TlsConfig,
}
/// TLS/HTTPS configuration for secure connections
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsConfig {
/// Enable TLS (HTTPS)
#[serde(default)]
pub enabled: bool,
/// Path to the TLS certificate file (PEM format)
#[serde(default)]
pub cert_path: Option<PathBuf>,
/// Path to the TLS private key file (PEM format)
#[serde(default)]
pub key_path: Option<PathBuf>,
/// Enable HTTP to HTTPS redirect (starts a second listener on http_port)
#[serde(default)]
pub redirect_http: bool,
/// Port for HTTP redirect listener (default: 80)
#[serde(default = "default_http_port")]
pub http_port: u16,
/// Enable HSTS (HTTP Strict Transport Security) header
#[serde(default = "default_true")]
pub hsts_enabled: bool,
/// HSTS max-age in seconds (default: 1 year)
#[serde(default = "default_hsts_max_age")]
pub hsts_max_age: u64,
}
fn default_http_port() -> u16 {
80
}
fn default_hsts_max_age() -> u64 {
31536000 // 1 year in seconds
}
impl Default for TlsConfig {
fn default() -> Self {
Self {
enabled: false,
cert_path: None,
key_path: None,
redirect_http: false,
http_port: default_http_port(),
hsts_enabled: true,
hsts_max_age: default_hsts_max_age(),
}
}
}
impl TlsConfig {
/// Validate TLS configuration
pub fn validate(&self) -> Result<(), String> {
if self.enabled {
if self.cert_path.is_none() {
return Err("TLS enabled but cert_path not specified".into());
}
if self.key_path.is_none() {
return Err("TLS enabled but key_path not specified".into());
}
if let Some(ref cert_path) = self.cert_path
&& !cert_path.exists()
{
return Err(format!(
"TLS certificate file not found: {}",
cert_path.display()
));
}
if let Some(ref key_path) = self.key_path
&& !key_path.exists()
{
return Err(format!("TLS key file not found: {}", key_path.display()));
}
}
Ok(())
}
}
impl Config {
pub fn from_file(path: &Path) -> crate::error::Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
crate::error::PinakesError::Config(format!("failed to read config file: {e}"))
})?;
let mut config: Self = toml::from_str(&content).map_err(|e| {
crate::error::PinakesError::Config(format!("failed to parse config: {e}"))
})?;
config.expand_env_vars()?;
Ok(config)
}
/// Expand environment variables in secret fields.
/// Supports ${VAR_NAME} and $VAR_NAME syntax.
fn expand_env_vars(&mut self) -> crate::error::Result<()> {
// Postgres password
if let Some(ref mut postgres) = self.storage.postgres {
postgres.password = expand_env_var_string(&postgres.password)?;
}
// Server API key
if let Some(ref api_key) = self.server.api_key {
self.server.api_key = Some(expand_env_var_string(api_key)?);
}
// Webhook secrets
for webhook in &mut self.webhooks {
if let Some(ref secret) = webhook.secret {
webhook.secret = Some(expand_env_var_string(secret)?);
}
}
// Enrichment API keys
if let Some(ref api_key) = self.enrichment.sources.musicbrainz.api_key {
self.enrichment.sources.musicbrainz.api_key = Some(expand_env_var_string(api_key)?);
}
if let Some(ref api_key) = self.enrichment.sources.tmdb.api_key {
self.enrichment.sources.tmdb.api_key = Some(expand_env_var_string(api_key)?);
}
if let Some(ref api_key) = self.enrichment.sources.lastfm.api_key {
self.enrichment.sources.lastfm.api_key = Some(expand_env_var_string(api_key)?);
}
Ok(())
}
/// Try loading from file, falling back to defaults if the file doesn't exist.
pub fn load_or_default(path: &Path) -> crate::error::Result<Self> {
if path.exists() {
Self::from_file(path)
} else {
let config = Self::default();
// Ensure the data directory exists for the default SQLite database
config.ensure_dirs()?;
Ok(config)
}
}
/// Save the current config to a TOML file.
pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self).map_err(|e| {
crate::error::PinakesError::Config(format!("failed to serialize config: {e}"))
})?;
std::fs::write(path, content)?;
Ok(())
}
/// Ensure all directories needed by this config exist and are writable.
pub fn ensure_dirs(&self) -> crate::error::Result<()> {
if let Some(ref sqlite) = self.storage.sqlite
&& let Some(parent) = sqlite.path.parent()
{
std::fs::create_dir_all(parent)?;
let metadata = std::fs::metadata(parent)?;
if metadata.permissions().readonly() {
return Err(crate::error::PinakesError::Config(format!(
"directory is not writable: {}",
parent.display()
)));
}
}
Ok(())
}
/// Returns the default config file path following XDG conventions.
pub fn default_config_path() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from(xdg).join("pinakes").join("pinakes.toml")
} else if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home)
.join(".config")
.join("pinakes")
.join("pinakes.toml")
} else {
PathBuf::from("pinakes.toml")
}
}
/// Validate configuration values for correctness.
pub fn validate(&self) -> Result<(), String> {
if self.server.port == 0 {
return Err("server port cannot be 0".into());
}
if self.server.host.is_empty() {
return Err("server host cannot be empty".into());
}
if self.scanning.poll_interval_secs == 0 {
return Err("poll interval cannot be 0".into());
}
if self.scanning.import_concurrency == 0 || self.scanning.import_concurrency > 256 {
return Err("import_concurrency must be between 1 and 256".into());
}
// Validate authentication configuration
let has_api_key = self.server.api_key.as_ref().is_some_and(|k| !k.is_empty());
let has_accounts = !self.accounts.users.is_empty();
let auth_disabled = self.server.authentication_disabled;
if !auth_disabled && !has_api_key && !has_accounts {
return Err(
"authentication is not configured: set an api_key, configure user accounts, \
or explicitly set authentication_disabled = true"
.into(),
);
}
// Empty API key is not allowed (must use authentication_disabled flag)
if let Some(ref api_key) = self.server.api_key
&& api_key.is_empty()
{
return Err("empty api_key is not allowed. To disable authentication, \
set authentication_disabled = true instead"
.into());
}
// Require TLS when authentication is enabled on non-localhost
let is_localhost = self.server.host == "127.0.0.1"
|| self.server.host == "localhost"
|| self.server.host == "::1";
if (has_api_key || has_accounts)
&& !auth_disabled
&& !is_localhost
&& !self.server.tls.enabled
{
return Err(
"TLS must be enabled when authentication is used on non-localhost hosts. \
Set server.tls.enabled = true or bind to localhost only"
.into(),
);
}
// Validate TLS configuration
self.server.tls.validate()?;
Ok(())
}
/// Returns the default data directory following XDG conventions.
pub fn default_data_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
PathBuf::from(xdg).join("pinakes")
} else if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home)
.join(".local")
.join("share")
.join("pinakes")
} else {
PathBuf::from("pinakes-data")
}
}
}
impl Default for Config {
fn default() -> Self {
let data_dir = Self::default_data_dir();
Self {
storage: StorageConfig {
backend: StorageBackendType::Sqlite,
sqlite: Some(SqliteConfig {
path: data_dir.join("pinakes.db"),
}),
postgres: None,
},
directories: DirectoryConfig { roots: vec![] },
scanning: ScanningConfig {
watch: false,
poll_interval_secs: 300,
ignore_patterns: vec![
".*".to_string(),
"node_modules".to_string(),
"__pycache__".to_string(),
"target".to_string(),
],
import_concurrency: default_import_concurrency(),
},
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 3000,
api_key: None,
authentication_disabled: false,
tls: TlsConfig::default(),
},
ui: UiConfig::default(),
accounts: AccountsConfig::default(),
jobs: JobsConfig::default(),
thumbnails: ThumbnailConfig::default(),
webhooks: vec![],
scheduled_tasks: vec![],
plugins: PluginsConfig::default(),
transcoding: TranscodingConfig::default(),
enrichment: EnrichmentConfig::default(),
cloud: CloudConfig::default(),
analytics: AnalyticsConfig::default(),
photos: PhotoConfig::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config_with_concurrency(concurrency: usize) -> Config {
let mut config = Config::default();
config.scanning.import_concurrency = concurrency;
config.server.authentication_disabled = true; // Disable auth for concurrency tests
config
}
#[test]
fn test_validate_import_concurrency_zero() {
let config = test_config_with_concurrency(0);
assert!(config.validate().is_err());
assert!(
config
.validate()
.unwrap_err()
.contains("import_concurrency")
);
}
#[test]
fn test_validate_import_concurrency_too_high() {
let config = test_config_with_concurrency(257);
assert!(config.validate().is_err());
assert!(
config
.validate()
.unwrap_err()
.contains("import_concurrency")
);
}
#[test]
fn test_validate_import_concurrency_valid() {
let config = test_config_with_concurrency(8);
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_import_concurrency_boundary_low() {
let config = test_config_with_concurrency(1);
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_import_concurrency_boundary_high() {
let config = test_config_with_concurrency(256);
assert!(config.validate().is_ok());
}
// Environment variable expansion tests
#[test]
fn test_expand_env_var_simple() {
unsafe {
std::env::set_var("TEST_VAR_SIMPLE", "test_value");
}
let result = expand_env_var_string("$TEST_VAR_SIMPLE");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test_value");
unsafe {
std::env::remove_var("TEST_VAR_SIMPLE");
}
}
#[test]
fn test_expand_env_var_braces() {
unsafe {
std::env::set_var("TEST_VAR_BRACES", "test_value");
}
let result = expand_env_var_string("${TEST_VAR_BRACES}");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test_value");
unsafe {
std::env::remove_var("TEST_VAR_BRACES");
}
}
#[test]
fn test_expand_env_var_embedded() {
unsafe {
std::env::set_var("TEST_VAR_EMBEDDED", "value");
}
let result = expand_env_var_string("prefix_${TEST_VAR_EMBEDDED}_suffix");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "prefix_value_suffix");
unsafe {
std::env::remove_var("TEST_VAR_EMBEDDED");
}
}
#[test]
fn test_expand_env_var_multiple() {
unsafe {
std::env::set_var("VAR1", "value1");
std::env::set_var("VAR2", "value2");
}
let result = expand_env_var_string("${VAR1}_${VAR2}");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "value1_value2");
unsafe {
std::env::remove_var("VAR1");
std::env::remove_var("VAR2");
}
}
#[test]
fn test_expand_env_var_missing() {
let result = expand_env_var_string("${NONEXISTENT_VAR}");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("environment variable not set")
);
}
#[test]
fn test_expand_env_var_empty_name() {
let result = expand_env_var_string("${}");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("empty environment variable name")
);
}
#[test]
fn test_expand_env_var_escaped() {
let result = expand_env_var_string("\\$NOT_A_VAR");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "$NOT_A_VAR");
}
#[test]
fn test_expand_env_var_no_vars() {
let result = expand_env_var_string("plain_text");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "plain_text");
}
#[test]
fn test_expand_env_var_underscore() {
unsafe {
std::env::set_var("TEST_VAR_NAME", "value");
}
let result = expand_env_var_string("$TEST_VAR_NAME");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "value");
unsafe {
std::env::remove_var("TEST_VAR_NAME");
}
}
#[test]
fn test_expand_env_var_mixed_syntax() {
unsafe {
std::env::set_var("VAR1_MIXED", "v1");
std::env::set_var("VAR2_MIXED", "v2");
}
let result = expand_env_var_string("$VAR1_MIXED and ${VAR2_MIXED}");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "v1 and v2");
unsafe {
std::env::remove_var("VAR1_MIXED");
std::env::remove_var("VAR2_MIXED");
}
}
}