| 1 | //! Configuration schema, defaults, validation and loading. |
| 2 | //! |
| 3 | //! Every key is optional. Unknown keys are rejected so typos surface at load |
| 4 | //! time instead of being silently ignored. |
| 5 | |
| 6 | use std::fmt; |
| 7 | use std::net::SocketAddr; |
| 8 | use std::path::{Path, PathBuf}; |
| 9 | use std::str::FromStr; |
| 10 | use std::sync::Arc; |
| 11 | |
| 12 | use serde::Deserialize; |
| 13 | |
| 14 | /// Well-known config location used in production. |
| 15 | pub const SYSTEM_CONFIG: &str = "/etc/ferrenium/ferrenium.toml"; |
| 16 | |
| 17 | #[derive(Debug, thiserror::Error)] |
| 18 | pub enum ConfigError { |
| 19 | #[error("cannot read {path}: {source}")] |
| 20 | Io { |
| 21 | path: PathBuf, |
| 22 | source: std::io::Error, |
| 23 | }, |
| 24 | #[error("{path}: {message}")] |
| 25 | Parse { path: PathBuf, message: String }, |
| 26 | #[error("invalid configuration:\n - {}", .0.join("\n - "))] |
| 27 | Invalid(Vec<String>), |
| 28 | } |
| 29 | |
| 30 | #[derive(Debug, Clone, Default, Deserialize)] |
| 31 | #[serde(deny_unknown_fields, default)] |
| 32 | pub struct Config { |
| 33 | pub server: ServerConfig, |
| 34 | pub site: SiteConfig, |
| 35 | pub repos: ReposConfig, |
| 36 | /// `[[repo]]` per-repository overrides. |
| 37 | #[serde(rename = "repo")] |
| 38 | pub repo_overrides: Vec<RepoOverride>, |
| 39 | pub ui: UiConfig, |
| 40 | pub cache: CacheConfig, |
| 41 | pub watch: WatchConfig, |
| 42 | pub limits: LimitsConfig, |
| 43 | pub log: LogConfig, |
| 44 | |
| 45 | /// File the config was loaded from (`None` = built-in defaults). |
| 46 | #[serde(skip)] |
| 47 | pub source: Option<PathBuf>, |
| 48 | /// Contents of `custom_css` / `header` / `footer`, read at load time so |
| 49 | /// that a reload picks up edits and requests never touch the filesystem. |
| 50 | #[serde(skip)] |
| 51 | pub custom: Customization, |
| 52 | } |
| 53 | |
| 54 | #[derive(Debug, Clone, Default)] |
| 55 | pub struct Customization { |
| 56 | pub css: Option<Arc<str>>, |
| 57 | pub header: Option<Arc<str>>, |
| 58 | pub footer: Option<Arc<str>>, |
| 59 | } |
| 60 | |
| 61 | #[derive(Debug, Clone, Deserialize)] |
| 62 | #[serde(deny_unknown_fields, default)] |
| 63 | pub struct ServerConfig { |
| 64 | pub listen: Listen, |
| 65 | pub base_url: Option<String>, |
| 66 | pub base_path: String, |
| 67 | pub trusted_proxy: bool, |
| 68 | pub compression: bool, |
| 69 | } |
| 70 | |
| 71 | impl Default for ServerConfig { |
| 72 | fn default() -> Self { |
| 73 | Self { |
| 74 | listen: Listen::Tcp(SocketAddr::from(([127, 0, 0, 1], 8080))), |
| 75 | base_url: None, |
| 76 | base_path: "/".into(), |
| 77 | trusted_proxy: false, |
| 78 | compression: true, |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | #[derive(Debug, Clone, Deserialize)] |
| 84 | #[serde(deny_unknown_fields, default)] |
| 85 | pub struct SiteConfig { |
| 86 | pub title: String, |
| 87 | pub description: String, |
| 88 | pub clone_urls: Vec<String>, |
| 89 | pub custom_css: Option<PathBuf>, |
| 90 | pub header: Option<PathBuf>, |
| 91 | pub footer: Option<PathBuf>, |
| 92 | } |
| 93 | |
| 94 | impl Default for SiteConfig { |
| 95 | fn default() -> Self { |
| 96 | Self { |
| 97 | title: "ferrenium".into(), |
| 98 | description: String::new(), |
| 99 | clone_urls: Vec::new(), |
| 100 | custom_css: None, |
| 101 | header: None, |
| 102 | footer: None, |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | #[derive(Debug, Clone, Deserialize)] |
| 108 | #[serde(deny_unknown_fields, default)] |
| 109 | pub struct ReposConfig { |
| 110 | pub scan_path: PathBuf, |
| 111 | pub max_depth: u32, |
| 112 | pub follow_symlinks: bool, |
| 113 | pub section_from_path: bool, |
| 114 | pub hide: Vec<String>, |
| 115 | pub snapshots: Vec<SnapshotFormat>, |
| 116 | pub https_clone: bool, |
| 117 | } |
| 118 | |
| 119 | impl Default for ReposConfig { |
| 120 | fn default() -> Self { |
| 121 | Self { |
| 122 | scan_path: "/home/git".into(), |
| 123 | max_depth: 3, |
| 124 | follow_symlinks: false, |
| 125 | section_from_path: false, |
| 126 | hide: Vec::new(), |
| 127 | snapshots: vec![SnapshotFormat::TarGz, SnapshotFormat::Zip], |
| 128 | https_clone: true, |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] |
| 134 | pub enum SnapshotFormat { |
| 135 | #[serde(rename = "tar.gz")] |
| 136 | TarGz, |
| 137 | #[serde(rename = "zip")] |
| 138 | Zip, |
| 139 | } |
| 140 | |
| 141 | #[derive(Debug, Clone, Default, Deserialize)] |
| 142 | #[serde(deny_unknown_fields)] |
| 143 | pub struct RepoOverride { |
| 144 | pub name: String, |
| 145 | pub description: Option<String>, |
| 146 | pub owner: Option<String>, |
| 147 | pub section: Option<String>, |
| 148 | pub hidden: Option<bool>, |
| 149 | pub readme: Option<String>, |
| 150 | pub default_branch: Option<String>, |
| 151 | } |
| 152 | |
| 153 | #[derive(Debug, Clone, Deserialize)] |
| 154 | #[serde(deny_unknown_fields, default)] |
| 155 | pub struct UiConfig { |
| 156 | pub theme: Theme, |
| 157 | pub code_font: String, |
| 158 | pub max_blob_kb: u64, |
| 159 | pub max_highlight_kb: u64, |
| 160 | } |
| 161 | |
| 162 | impl Default for UiConfig { |
| 163 | fn default() -> Self { |
| 164 | Self { |
| 165 | theme: Theme::Auto, |
| 166 | code_font: DEFAULT_CODE_FONT.into(), |
| 167 | max_blob_kb: 1024, |
| 168 | max_highlight_kb: 256, |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | pub const DEFAULT_CODE_FONT: &str = "Space Mono"; |
| 174 | |
| 175 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] |
| 176 | #[serde(rename_all = "lowercase")] |
| 177 | pub enum Theme { |
| 178 | #[default] |
| 179 | Auto, |
| 180 | Dark, |
| 181 | Light, |
| 182 | } |
| 183 | |
| 184 | impl Theme { |
| 185 | pub fn as_str(self) -> &'static str { |
| 186 | match self { |
| 187 | Theme::Auto => "auto", |
| 188 | Theme::Dark => "dark", |
| 189 | Theme::Light => "light", |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | #[derive(Debug, Clone, Deserialize)] |
| 195 | #[serde(deny_unknown_fields, default)] |
| 196 | pub struct CacheConfig { |
| 197 | pub size_mb: u64, |
| 198 | } |
| 199 | |
| 200 | impl Default for CacheConfig { |
| 201 | fn default() -> Self { |
| 202 | Self { size_mb: 64 } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | #[derive(Debug, Clone, Deserialize)] |
| 207 | #[serde(deny_unknown_fields, default)] |
| 208 | pub struct WatchConfig { |
| 209 | pub enabled: bool, |
| 210 | pub debounce_ms: u64, |
| 211 | /// Full rescan interval; 0 disables polling. |
| 212 | pub poll_interval_secs: u64, |
| 213 | } |
| 214 | |
| 215 | impl Default for WatchConfig { |
| 216 | fn default() -> Self { |
| 217 | Self { |
| 218 | enabled: true, |
| 219 | debounce_ms: 500, |
| 220 | poll_interval_secs: 300, |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | #[derive(Debug, Clone, Deserialize)] |
| 226 | #[serde(deny_unknown_fields, default)] |
| 227 | pub struct LimitsConfig { |
| 228 | /// 0 = 2 × available CPUs. |
| 229 | pub git_parallel: usize, |
| 230 | pub heavy_parallel: usize, |
| 231 | pub request_timeout_secs: u64, |
| 232 | } |
| 233 | |
| 234 | impl Default for LimitsConfig { |
| 235 | fn default() -> Self { |
| 236 | Self { |
| 237 | git_parallel: 0, |
| 238 | heavy_parallel: 2, |
| 239 | request_timeout_secs: 30, |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | #[derive(Debug, Clone, Deserialize)] |
| 245 | #[serde(deny_unknown_fields, default)] |
| 246 | pub struct LogConfig { |
| 247 | pub level: String, |
| 248 | } |
| 249 | |
| 250 | impl Default for LogConfig { |
| 251 | fn default() -> Self { |
| 252 | Self { |
| 253 | level: "info".into(), |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | /// Where the server listens: a TCP socket address or `unix:/abs/path`. |
| 259 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] |
| 260 | #[serde(try_from = "String")] |
| 261 | pub enum Listen { |
| 262 | Tcp(SocketAddr), |
| 263 | Unix(PathBuf), |
| 264 | } |
| 265 | |
| 266 | impl FromStr for Listen { |
| 267 | type Err = String; |
| 268 | |
| 269 | fn from_str(s: &str) -> Result<Self, Self::Err> { |
| 270 | if let Some(path) = s.strip_prefix("unix:") { |
| 271 | let path = PathBuf::from(path); |
| 272 | if !path.is_absolute() { |
| 273 | return Err(format!("unix socket path must be absolute: {s:?}")); |
| 274 | } |
| 275 | return Ok(Listen::Unix(path)); |
| 276 | } |
| 277 | s.parse::<SocketAddr>().map(Listen::Tcp).map_err(|_| { |
| 278 | format!("invalid listen address {s:?} (expected HOST:PORT or unix:/path/to.sock)") |
| 279 | }) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | impl TryFrom<String> for Listen { |
| 284 | type Error = String; |
| 285 | |
| 286 | fn try_from(s: String) -> Result<Self, Self::Error> { |
| 287 | s.parse() |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | impl fmt::Display for Listen { |
| 292 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 293 | match self { |
| 294 | Listen::Tcp(addr) => write!(f, "http://{addr}"), |
| 295 | Listen::Unix(path) => write!(f, "unix:{}", path.display()), |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | /// Find the config file: explicit path (flag or `$FERRENIUM_CONFIG`), then the |
| 301 | /// system location, then `$XDG_CONFIG_HOME/ferrenium/ferrenium.toml`. |
| 302 | /// |
| 303 | /// Returns `None` when no explicit path was given and no file exists, in which |
| 304 | /// case the built-in defaults apply. |
| 305 | pub fn resolve_path(explicit: Option<&Path>) -> Option<PathBuf> { |
| 306 | if let Some(path) = explicit { |
| 307 | return Some(path.to_path_buf()); |
| 308 | } |
| 309 | let system = PathBuf::from(SYSTEM_CONFIG); |
| 310 | if system.is_file() { |
| 311 | return Some(system); |
| 312 | } |
| 313 | let xdg = std::env::var_os("XDG_CONFIG_HOME") |
| 314 | .map(PathBuf::from) |
| 315 | .filter(|p| p.is_absolute()) |
| 316 | .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; |
| 317 | let user = xdg.join("ferrenium/ferrenium.toml"); |
| 318 | user.is_file().then_some(user) |
| 319 | } |
| 320 | |
| 321 | impl Config { |
| 322 | /// Load, normalize and validate the config at `path` (or the defaults). |
| 323 | pub fn load(path: Option<&Path>) -> Result<Config, ConfigError> { |
| 324 | let mut config = match path { |
| 325 | Some(path) => { |
| 326 | let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io { |
| 327 | path: path.to_path_buf(), |
| 328 | source, |
| 329 | })?; |
| 330 | let mut config = Config::parse(&text).map_err(|message| ConfigError::Parse { |
| 331 | path: path.to_path_buf(), |
| 332 | message, |
| 333 | })?; |
| 334 | config.source = Some(path.to_path_buf()); |
| 335 | config |
| 336 | } |
| 337 | None => Config::default(), |
| 338 | }; |
| 339 | config.finish()?; |
| 340 | Ok(config) |
| 341 | } |
| 342 | |
| 343 | /// Parse TOML without validation. Errors carry line/column and a snippet. |
| 344 | pub fn parse(text: &str) -> Result<Config, String> { |
| 345 | toml::from_str(text).map_err(|e| e.to_string()) |
| 346 | } |
| 347 | |
| 348 | /// Directory that relative paths in the config are resolved against. |
| 349 | pub fn base_dir(&self) -> PathBuf { |
| 350 | match self.source.as_deref().and_then(Path::parent) { |
| 351 | Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(), |
| 352 | _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | /// Normalize values, resolve relative paths, read customization files and |
| 357 | /// validate. All problems are reported together. |
| 358 | pub fn finish(&mut self) -> Result<(), ConfigError> { |
| 359 | let mut errors = Vec::new(); |
| 360 | let base_dir = self.base_dir(); |
| 361 | |
| 362 | // server |
| 363 | let bp = self.server.base_path.trim(); |
| 364 | if !bp.starts_with('/') { |
| 365 | errors.push(format!("server.base_path must start with '/': {bp:?}")); |
| 366 | } else if bp.contains("//") || bp.split('/').any(|s| s == "." || s == "..") { |
| 367 | errors.push(format!("server.base_path is not a clean path: {bp:?}")); |
| 368 | } |
| 369 | let mut bp = bp.trim_end_matches('/').to_string(); |
| 370 | bp.push('/'); |
| 371 | self.server.base_path = bp; |
| 372 | |
| 373 | if let Some(url) = &mut self.server.base_url { |
| 374 | let trimmed = url.trim().trim_end_matches('/').to_string(); |
| 375 | if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) { |
| 376 | errors.push(format!( |
| 377 | "server.base_url must start with http:// or https://: {url:?}" |
| 378 | )); |
| 379 | } |
| 380 | *url = trimmed; |
| 381 | } |
| 382 | |
| 383 | // site |
| 384 | if self.site.title.trim().is_empty() { |
| 385 | errors.push("site.title must not be empty".into()); |
| 386 | } |
| 387 | for (i, url) in self.site.clone_urls.iter().enumerate() { |
| 388 | if !url.contains("{name}") { |
| 389 | errors.push(format!( |
| 390 | "site.clone_urls[{i}] must contain the {{name}} placeholder: {url:?}" |
| 391 | )); |
| 392 | } |
| 393 | } |
| 394 | self.custom = Customization { |
| 395 | css: read_custom( |
| 396 | &base_dir, |
| 397 | "site.custom_css", |
| 398 | &mut self.site.custom_css, |
| 399 | &mut errors, |
| 400 | ), |
| 401 | header: read_custom(&base_dir, "site.header", &mut self.site.header, &mut errors), |
| 402 | footer: read_custom(&base_dir, "site.footer", &mut self.site.footer, &mut errors), |
| 403 | }; |
| 404 | |
| 405 | // repos |
| 406 | if self.repos.scan_path.is_relative() { |
| 407 | self.repos.scan_path = base_dir.join(&self.repos.scan_path); |
| 408 | } |
| 409 | if !(1..=16).contains(&self.repos.max_depth) { |
| 410 | errors.push(format!( |
| 411 | "repos.max_depth must be between 1 and 16, got {}", |
| 412 | self.repos.max_depth |
| 413 | )); |
| 414 | } |
| 415 | let mut seen = std::collections::HashSet::new(); |
| 416 | for (i, repo) in self.repo_overrides.iter_mut().enumerate() { |
| 417 | let name = repo.name.trim().trim_matches('/').trim_end_matches(".git"); |
| 418 | if name.is_empty() { |
| 419 | errors.push(format!("[[repo]] #{} has an empty name", i + 1)); |
| 420 | continue; |
| 421 | } |
| 422 | repo.name = name.to_string(); |
| 423 | if !seen.insert(repo.name.clone()) { |
| 424 | errors.push(format!( |
| 425 | "[[repo]] {:?} is defined more than once", |
| 426 | repo.name |
| 427 | )); |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | // ui |
| 432 | if self.ui.code_font.trim().is_empty() { |
| 433 | errors.push("ui.code_font must not be empty".into()); |
| 434 | } else if self |
| 435 | .ui |
| 436 | .code_font |
| 437 | .chars() |
| 438 | .any(|c| matches!(c, '"' | '\'' | ';' | '{' | '}' | '<' | '>' | '\\')) |
| 439 | { |
| 440 | errors.push(format!( |
| 441 | "ui.code_font contains characters not allowed in a font name: {:?}", |
| 442 | self.ui.code_font |
| 443 | )); |
| 444 | } |
| 445 | if self.ui.max_blob_kb == 0 { |
| 446 | errors.push("ui.max_blob_kb must be greater than 0".into()); |
| 447 | } |
| 448 | |
| 449 | // cache / watch / limits |
| 450 | if self.cache.size_mb == 0 { |
| 451 | errors.push("cache.size_mb must be greater than 0".into()); |
| 452 | } |
| 453 | if self.watch.debounce_ms > 60_000 { |
| 454 | errors.push("watch.debounce_ms must be at most 60000".into()); |
| 455 | } |
| 456 | if self.limits.heavy_parallel == 0 { |
| 457 | errors.push("limits.heavy_parallel must be at least 1".into()); |
| 458 | } |
| 459 | if self.limits.request_timeout_secs == 0 { |
| 460 | errors.push("limits.request_timeout_secs must be at least 1".into()); |
| 461 | } |
| 462 | |
| 463 | // log |
| 464 | if let Err(e) = tracing_subscriber::EnvFilter::try_new(&self.log.level) { |
| 465 | errors.push(format!("log.level {:?} is invalid: {e}", self.log.level)); |
| 466 | } |
| 467 | |
| 468 | if errors.is_empty() { |
| 469 | Ok(()) |
| 470 | } else { |
| 471 | Err(ConfigError::Invalid(errors)) |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | /// Effective number of concurrent git workers. |
| 476 | pub fn git_parallel(&self) -> usize { |
| 477 | match self.limits.git_parallel { |
| 478 | 0 => std::thread::available_parallelism().map_or(4, |n| n.get() * 2), |
| 479 | n => n, |
| 480 | } |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | /// Resolve a customization file path against `base_dir` and read it. |
| 485 | fn read_custom( |
| 486 | base_dir: &Path, |
| 487 | key: &str, |
| 488 | path: &mut Option<PathBuf>, |
| 489 | errors: &mut Vec<String>, |
| 490 | ) -> Option<Arc<str>> { |
| 491 | let p = path.as_mut()?; |
| 492 | if p.is_relative() { |
| 493 | *p = base_dir.join(&*p); |
| 494 | } |
| 495 | match std::fs::read_to_string(&*p) { |
| 496 | Ok(text) => Some(text.into()), |
| 497 | Err(e) => { |
| 498 | errors.push(format!("{key}: cannot read {}: {e}", p.display())); |
| 499 | None |
| 500 | } |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | #[cfg(test)] |
| 505 | mod tests { |
| 506 | use super::*; |
| 507 | |
| 508 | fn finish(text: &str) -> Result<Config, ConfigError> { |
| 509 | let mut c = Config::parse(text).map_err(|message| ConfigError::Parse { |
| 510 | path: "test.toml".into(), |
| 511 | message, |
| 512 | })?; |
| 513 | c.finish()?; |
| 514 | Ok(c) |
| 515 | } |
| 516 | |
| 517 | #[test] |
| 518 | fn empty_config_uses_defaults() { |
| 519 | let c = finish("").unwrap(); |
| 520 | assert_eq!(c.server.listen, "127.0.0.1:8080".parse().unwrap()); |
| 521 | assert_eq!(c.server.base_path, "/"); |
| 522 | assert_eq!(c.repos.scan_path, PathBuf::from("/home/git")); |
| 523 | assert_eq!(c.repos.max_depth, 3); |
| 524 | assert_eq!(c.ui.theme, Theme::Auto); |
| 525 | assert_eq!(c.cache.size_mb, 64); |
| 526 | assert_eq!( |
| 527 | c.repos.snapshots, |
| 528 | vec![SnapshotFormat::TarGz, SnapshotFormat::Zip] |
| 529 | ); |
| 530 | assert!(c.git_parallel() >= 1); |
| 531 | } |
| 532 | |
| 533 | #[test] |
| 534 | fn example_config_is_valid() { |
| 535 | let c = finish(crate::examples::CONFIG).unwrap(); |
| 536 | assert_eq!(c.site.title, "krolyxon"); |
| 537 | } |
| 538 | |
| 539 | #[test] |
| 540 | fn unknown_keys_are_rejected_with_position() { |
| 541 | let err = finish("[server]\nlisten = \"127.0.0.1:1\"\nlistn = 1\n").unwrap_err(); |
| 542 | let msg = err.to_string(); |
| 543 | assert!(msg.contains("unknown field `listn`"), "{msg}"); |
| 544 | assert!(msg.contains("line 3"), "{msg}"); |
| 545 | |
| 546 | let err = finish("[sever]\n").unwrap_err().to_string(); |
| 547 | assert!(err.contains("unknown field `sever`"), "{err}"); |
| 548 | } |
| 549 | |
| 550 | #[test] |
| 551 | fn listen_parsing() { |
| 552 | assert_eq!( |
| 553 | "unix:/run/ferrenium/ferrenium.sock".parse::<Listen>(), |
| 554 | Ok(Listen::Unix("/run/ferrenium/ferrenium.sock".into())) |
| 555 | ); |
| 556 | assert!("unix:relative.sock".parse::<Listen>().is_err()); |
| 557 | assert!("localhost".parse::<Listen>().is_err()); |
| 558 | assert_eq!( |
| 559 | "[::1]:9000".parse::<Listen>(), |
| 560 | Ok(Listen::Tcp("[::1]:9000".parse().unwrap())) |
| 561 | ); |
| 562 | let err = finish("[server]\nlisten = \"nope\"\n") |
| 563 | .unwrap_err() |
| 564 | .to_string(); |
| 565 | assert!(err.contains("invalid listen address"), "{err}"); |
| 566 | } |
| 567 | |
| 568 | #[test] |
| 569 | fn base_path_and_url_are_normalized() { |
| 570 | let c = finish("[server]\nbase_path = \"/git\"\nbase_url = \"https://x.org/\"\n").unwrap(); |
| 571 | assert_eq!(c.server.base_path, "/git/"); |
| 572 | assert_eq!(c.server.base_url.as_deref(), Some("https://x.org")); |
| 573 | assert_eq!( |
| 574 | finish("[server]\nbase_path = \"/\"\n") |
| 575 | .unwrap() |
| 576 | .server |
| 577 | .base_path, |
| 578 | "/" |
| 579 | ); |
| 580 | } |
| 581 | |
| 582 | #[test] |
| 583 | fn validation_reports_all_errors() { |
| 584 | let err = finish( |
| 585 | r#" |
| 586 | [server] |
| 587 | base_path = "git" |
| 588 | base_url = "ftp://x" |
| 589 | [repos] |
| 590 | max_depth = 0 |
| 591 | [ui] |
| 592 | theme = "dark" |
| 593 | code_font = "x; } body { display: none" |
| 594 | [cache] |
| 595 | size_mb = 0 |
| 596 | [[repo]] |
| 597 | name = "a.git" |
| 598 | [[repo]] |
| 599 | name = "a" |
| 600 | "#, |
| 601 | ) |
| 602 | .unwrap_err(); |
| 603 | let ConfigError::Invalid(errors) = err else { |
| 604 | panic!("expected validation errors, got {err}"); |
| 605 | }; |
| 606 | let all = errors.join("\n"); |
| 607 | for needle in [ |
| 608 | "base_path", |
| 609 | "base_url", |
| 610 | "max_depth", |
| 611 | "code_font", |
| 612 | "size_mb", |
| 613 | "defined more than once", |
| 614 | ] { |
| 615 | assert!(all.contains(needle), "missing {needle:?} in:\n{all}"); |
| 616 | } |
| 617 | } |
| 618 | |
| 619 | #[test] |
| 620 | fn invalid_enum_values_are_rejected() { |
| 621 | assert!(finish("[ui]\ntheme = \"purple\"\n").is_err()); |
| 622 | assert!(finish("[repos]\nsnapshots = [\"rar\"]\n").is_err()); |
| 623 | } |
| 624 | |
| 625 | #[test] |
| 626 | fn custom_files_resolve_relative_to_config() { |
| 627 | let dir = tempfile::tempdir().unwrap(); |
| 628 | std::fs::write(dir.path().join("custom.css"), "body{}").unwrap(); |
| 629 | let path = dir.path().join("ferrenium.toml"); |
| 630 | std::fs::write( |
| 631 | &path, |
| 632 | "[site]\ncustom_css = \"custom.css\"\n[repos]\nscan_path = \"repos\"\n", |
| 633 | ) |
| 634 | .unwrap(); |
| 635 | let c = Config::load(Some(&path)).unwrap(); |
| 636 | assert_eq!(c.custom.css.as_deref(), Some("body{}")); |
| 637 | assert_eq!(c.repos.scan_path, dir.path().join("repos")); |
| 638 | |
| 639 | std::fs::write(&path, "[site]\nfooter = \"missing.html\"\n").unwrap(); |
| 640 | let err = Config::load(Some(&path)).unwrap_err().to_string(); |
| 641 | assert!(err.contains("site.footer"), "{err}"); |
| 642 | } |
| 643 | } |