| 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 | /// Commits per log page. |
| 161 | pub log_page: usize, |
| 162 | } |
| 163 | |
| 164 | impl Default for UiConfig { |
| 165 | fn default() -> Self { |
| 166 | Self { |
| 167 | theme: Theme::Auto, |
| 168 | code_font: DEFAULT_CODE_FONT.into(), |
| 169 | max_blob_kb: 1024, |
| 170 | max_highlight_kb: 256, |
| 171 | log_page: 50, |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | pub const DEFAULT_CODE_FONT: &str = "Space Mono"; |
| 177 | |
| 178 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] |
| 179 | #[serde(rename_all = "lowercase")] |
| 180 | pub enum Theme { |
| 181 | #[default] |
| 182 | Auto, |
| 183 | Dark, |
| 184 | Light, |
| 185 | } |
| 186 | |
| 187 | impl Theme { |
| 188 | pub fn as_str(self) -> &'static str { |
| 189 | match self { |
| 190 | Theme::Auto => "auto", |
| 191 | Theme::Dark => "dark", |
| 192 | Theme::Light => "light", |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | #[derive(Debug, Clone, Deserialize)] |
| 198 | #[serde(deny_unknown_fields, default)] |
| 199 | pub struct CacheConfig { |
| 200 | pub size_mb: u64, |
| 201 | } |
| 202 | |
| 203 | impl Default for CacheConfig { |
| 204 | fn default() -> Self { |
| 205 | Self { size_mb: 64 } |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | #[derive(Debug, Clone, Deserialize)] |
| 210 | #[serde(deny_unknown_fields, default)] |
| 211 | pub struct WatchConfig { |
| 212 | pub enabled: bool, |
| 213 | pub debounce_ms: u64, |
| 214 | /// Full rescan interval; 0 disables polling. |
| 215 | pub poll_interval_secs: u64, |
| 216 | } |
| 217 | |
| 218 | impl Default for WatchConfig { |
| 219 | fn default() -> Self { |
| 220 | Self { |
| 221 | enabled: true, |
| 222 | debounce_ms: 500, |
| 223 | poll_interval_secs: 300, |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | #[derive(Debug, Clone, Deserialize)] |
| 229 | #[serde(deny_unknown_fields, default)] |
| 230 | pub struct LimitsConfig { |
| 231 | /// 0 = available CPUs, at most 8. |
| 232 | pub git_parallel: usize, |
| 233 | pub heavy_parallel: usize, |
| 234 | /// Seconds a heavy request may wait for a permit before a 503. |
| 235 | pub heavy_wait_secs: u64, |
| 236 | /// Requests in flight at once; more are refused with a 503. |
| 237 | pub max_requests: usize, |
| 238 | pub request_timeout_secs: u64, |
| 239 | /// Commits a filtered log (path or grep) examines per page. |
| 240 | pub log_scan: usize, |
| 241 | /// Above these, a commit's diff is not shown in full. |
| 242 | pub diff_files: usize, |
| 243 | pub diff_lines: usize, |
| 244 | pub diff_file_lines: usize, |
| 245 | } |
| 246 | |
| 247 | impl Default for LimitsConfig { |
| 248 | fn default() -> Self { |
| 249 | Self { |
| 250 | git_parallel: 0, |
| 251 | heavy_parallel: 2, |
| 252 | heavy_wait_secs: 10, |
| 253 | max_requests: 512, |
| 254 | request_timeout_secs: 30, |
| 255 | log_scan: 10_000, |
| 256 | diff_files: 300, |
| 257 | diff_lines: 10_000, |
| 258 | diff_file_lines: 2_000, |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | #[derive(Debug, Clone, Deserialize)] |
| 264 | #[serde(deny_unknown_fields, default)] |
| 265 | pub struct LogConfig { |
| 266 | pub level: String, |
| 267 | } |
| 268 | |
| 269 | impl Default for LogConfig { |
| 270 | fn default() -> Self { |
| 271 | Self { |
| 272 | level: "info".into(), |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | /// Where the server listens: a TCP socket address or `unix:/abs/path`. |
| 278 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] |
| 279 | #[serde(try_from = "String")] |
| 280 | pub enum Listen { |
| 281 | Tcp(SocketAddr), |
| 282 | Unix(PathBuf), |
| 283 | } |
| 284 | |
| 285 | impl FromStr for Listen { |
| 286 | type Err = String; |
| 287 | |
| 288 | fn from_str(s: &str) -> Result<Self, Self::Err> { |
| 289 | if let Some(path) = s.strip_prefix("unix:") { |
| 290 | let path = PathBuf::from(path); |
| 291 | if !path.is_absolute() { |
| 292 | return Err(format!("unix socket path must be absolute: {s:?}")); |
| 293 | } |
| 294 | return Ok(Listen::Unix(path)); |
| 295 | } |
| 296 | s.parse::<SocketAddr>().map(Listen::Tcp).map_err(|_| { |
| 297 | format!("invalid listen address {s:?} (expected HOST:PORT or unix:/path/to.sock)") |
| 298 | }) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | impl TryFrom<String> for Listen { |
| 303 | type Error = String; |
| 304 | |
| 305 | fn try_from(s: String) -> Result<Self, Self::Error> { |
| 306 | s.parse() |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | impl fmt::Display for Listen { |
| 311 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 312 | match self { |
| 313 | Listen::Tcp(addr) => write!(f, "http://{addr}"), |
| 314 | Listen::Unix(path) => write!(f, "unix:{}", path.display()), |
| 315 | } |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | /// Find the config file: explicit path (flag or `$FERRENIUM_CONFIG`), then the |
| 320 | /// system location, then `$XDG_CONFIG_HOME/ferrenium/ferrenium.toml`. |
| 321 | /// |
| 322 | /// Returns `None` when no explicit path was given and no file exists, in which |
| 323 | /// case the built-in defaults apply. |
| 324 | pub fn resolve_path(explicit: Option<&Path>) -> Option<PathBuf> { |
| 325 | if let Some(path) = explicit { |
| 326 | return Some(path.to_path_buf()); |
| 327 | } |
| 328 | let system = PathBuf::from(SYSTEM_CONFIG); |
| 329 | if system.is_file() { |
| 330 | return Some(system); |
| 331 | } |
| 332 | let xdg = std::env::var_os("XDG_CONFIG_HOME") |
| 333 | .map(PathBuf::from) |
| 334 | .filter(|p| p.is_absolute()) |
| 335 | .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; |
| 336 | let user = xdg.join("ferrenium/ferrenium.toml"); |
| 337 | user.is_file().then_some(user) |
| 338 | } |
| 339 | |
| 340 | impl Config { |
| 341 | /// Load, normalize and validate the config at `path` (or the defaults). |
| 342 | pub fn load(path: Option<&Path>) -> Result<Config, ConfigError> { |
| 343 | let mut config = match path { |
| 344 | Some(path) => { |
| 345 | let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io { |
| 346 | path: path.to_path_buf(), |
| 347 | source, |
| 348 | })?; |
| 349 | let mut config = Config::parse(&text).map_err(|message| ConfigError::Parse { |
| 350 | path: path.to_path_buf(), |
| 351 | message, |
| 352 | })?; |
| 353 | config.source = Some(path.to_path_buf()); |
| 354 | config |
| 355 | } |
| 356 | None => Config::default(), |
| 357 | }; |
| 358 | config.finish()?; |
| 359 | Ok(config) |
| 360 | } |
| 361 | |
| 362 | /// Parse TOML without validation. Errors carry line/column and a snippet. |
| 363 | pub fn parse(text: &str) -> Result<Config, String> { |
| 364 | toml::from_str(text).map_err(|e| e.to_string()) |
| 365 | } |
| 366 | |
| 367 | /// Directory that relative paths in the config are resolved against. |
| 368 | pub fn base_dir(&self) -> PathBuf { |
| 369 | match self.source.as_deref().and_then(Path::parent) { |
| 370 | Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(), |
| 371 | _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | /// Normalize values, resolve relative paths, read customization files and |
| 376 | /// validate. All problems are reported together. |
| 377 | pub fn finish(&mut self) -> Result<(), ConfigError> { |
| 378 | let mut errors = Vec::new(); |
| 379 | let base_dir = self.base_dir(); |
| 380 | |
| 381 | // server |
| 382 | let bp = self.server.base_path.trim(); |
| 383 | if !bp.starts_with('/') { |
| 384 | errors.push(format!("server.base_path must start with '/': {bp:?}")); |
| 385 | } else if bp.contains("//") || bp.split('/').any(|s| s == "." || s == "..") { |
| 386 | errors.push(format!("server.base_path is not a clean path: {bp:?}")); |
| 387 | } |
| 388 | let mut bp = bp.trim_end_matches('/').to_string(); |
| 389 | bp.push('/'); |
| 390 | self.server.base_path = bp; |
| 391 | |
| 392 | if let Some(url) = &mut self.server.base_url { |
| 393 | let trimmed = url.trim().trim_end_matches('/').to_string(); |
| 394 | if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) { |
| 395 | errors.push(format!( |
| 396 | "server.base_url must start with http:// or https://: {url:?}" |
| 397 | )); |
| 398 | } |
| 399 | *url = trimmed; |
| 400 | } |
| 401 | |
| 402 | // site |
| 403 | if self.site.title.trim().is_empty() { |
| 404 | errors.push("site.title must not be empty".into()); |
| 405 | } |
| 406 | for (i, url) in self.site.clone_urls.iter().enumerate() { |
| 407 | if !url.contains("{name}") { |
| 408 | errors.push(format!( |
| 409 | "site.clone_urls[{i}] must contain the {{name}} placeholder: {url:?}" |
| 410 | )); |
| 411 | } |
| 412 | } |
| 413 | self.custom = Customization { |
| 414 | css: read_custom( |
| 415 | &base_dir, |
| 416 | "site.custom_css", |
| 417 | &mut self.site.custom_css, |
| 418 | &mut errors, |
| 419 | ), |
| 420 | header: read_custom(&base_dir, "site.header", &mut self.site.header, &mut errors), |
| 421 | footer: read_custom(&base_dir, "site.footer", &mut self.site.footer, &mut errors), |
| 422 | }; |
| 423 | |
| 424 | // repos |
| 425 | if self.repos.scan_path.is_relative() { |
| 426 | self.repos.scan_path = base_dir.join(&self.repos.scan_path); |
| 427 | } |
| 428 | if !(1..=16).contains(&self.repos.max_depth) { |
| 429 | errors.push(format!( |
| 430 | "repos.max_depth must be between 1 and 16, got {}", |
| 431 | self.repos.max_depth |
| 432 | )); |
| 433 | } |
| 434 | let mut seen = std::collections::HashSet::new(); |
| 435 | for (i, repo) in self.repo_overrides.iter_mut().enumerate() { |
| 436 | let name = repo.name.trim().trim_matches('/').trim_end_matches(".git"); |
| 437 | if name.is_empty() { |
| 438 | errors.push(format!("[[repo]] #{} has an empty name", i + 1)); |
| 439 | continue; |
| 440 | } |
| 441 | repo.name = name.to_string(); |
| 442 | if !seen.insert(repo.name.clone()) { |
| 443 | errors.push(format!( |
| 444 | "[[repo]] {:?} is defined more than once", |
| 445 | repo.name |
| 446 | )); |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | // ui |
| 451 | if self.ui.code_font.trim().is_empty() { |
| 452 | errors.push("ui.code_font must not be empty".into()); |
| 453 | } else if self |
| 454 | .ui |
| 455 | .code_font |
| 456 | .chars() |
| 457 | .any(|c| matches!(c, '"' | '\'' | ';' | '{' | '}' | '<' | '>' | '\\')) |
| 458 | { |
| 459 | errors.push(format!( |
| 460 | "ui.code_font contains characters not allowed in a font name: {:?}", |
| 461 | self.ui.code_font |
| 462 | )); |
| 463 | } |
| 464 | if self.ui.max_blob_kb == 0 { |
| 465 | errors.push("ui.max_blob_kb must be greater than 0".into()); |
| 466 | } |
| 467 | |
| 468 | // cache / watch / limits |
| 469 | if self.cache.size_mb == 0 { |
| 470 | errors.push("cache.size_mb must be greater than 0".into()); |
| 471 | } |
| 472 | if self.watch.debounce_ms > 60_000 { |
| 473 | errors.push("watch.debounce_ms must be at most 60000".into()); |
| 474 | } |
| 475 | if self.limits.heavy_parallel == 0 { |
| 476 | errors.push("limits.heavy_parallel must be at least 1".into()); |
| 477 | } |
| 478 | if self.limits.max_requests == 0 { |
| 479 | errors.push("limits.max_requests must be at least 1".into()); |
| 480 | } |
| 481 | if self.limits.request_timeout_secs == 0 { |
| 482 | errors.push("limits.request_timeout_secs must be at least 1".into()); |
| 483 | } |
| 484 | if !(1..=1000).contains(&self.ui.log_page) { |
| 485 | errors.push("ui.log_page must be between 1 and 1000".into()); |
| 486 | } |
| 487 | for (key, value) in [ |
| 488 | ("log_scan", self.limits.log_scan), |
| 489 | ("diff_files", self.limits.diff_files), |
| 490 | ("diff_lines", self.limits.diff_lines), |
| 491 | ("diff_file_lines", self.limits.diff_file_lines), |
| 492 | ] { |
| 493 | if value == 0 { |
| 494 | errors.push(format!("limits.{key} must be at least 1")); |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | // log |
| 499 | if let Err(e) = tracing_subscriber::EnvFilter::try_new(&self.log.level) { |
| 500 | errors.push(format!("log.level {:?} is invalid: {e}", self.log.level)); |
| 501 | } |
| 502 | |
| 503 | if errors.is_empty() { |
| 504 | Ok(()) |
| 505 | } else { |
| 506 | Err(ConfigError::Invalid(errors)) |
| 507 | } |
| 508 | } |
| 509 | |
| 510 | /// Effective number of concurrent git workers. |
| 511 | pub fn git_parallel(&self) -> usize { |
| 512 | match self.limits.git_parallel { |
| 513 | // Git work is CPU-bound; more workers than cores only add memory. |
| 514 | 0 => std::thread::available_parallelism().map_or(4, |n| n.get().min(8)), |
| 515 | n => n, |
| 516 | } |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | /// Resolve a customization file path against `base_dir` and read it. |
| 521 | fn read_custom( |
| 522 | base_dir: &Path, |
| 523 | key: &str, |
| 524 | path: &mut Option<PathBuf>, |
| 525 | errors: &mut Vec<String>, |
| 526 | ) -> Option<Arc<str>> { |
| 527 | let p = path.as_mut()?; |
| 528 | if p.is_relative() { |
| 529 | *p = base_dir.join(&*p); |
| 530 | } |
| 531 | match std::fs::read_to_string(&*p) { |
| 532 | Ok(text) => Some(text.into()), |
| 533 | Err(e) => { |
| 534 | errors.push(format!("{key}: cannot read {}: {e}", p.display())); |
| 535 | None |
| 536 | } |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | #[cfg(test)] |
| 541 | mod tests { |
| 542 | use super::*; |
| 543 | |
| 544 | fn finish(text: &str) -> Result<Config, ConfigError> { |
| 545 | let mut c = Config::parse(text).map_err(|message| ConfigError::Parse { |
| 546 | path: "test.toml".into(), |
| 547 | message, |
| 548 | })?; |
| 549 | c.finish()?; |
| 550 | Ok(c) |
| 551 | } |
| 552 | |
| 553 | #[test] |
| 554 | fn empty_config_uses_defaults() { |
| 555 | let c = finish("").unwrap(); |
| 556 | assert_eq!(c.server.listen, "127.0.0.1:8080".parse().unwrap()); |
| 557 | assert_eq!(c.server.base_path, "/"); |
| 558 | assert_eq!(c.repos.scan_path, PathBuf::from("/home/git")); |
| 559 | assert_eq!(c.repos.max_depth, 3); |
| 560 | assert_eq!(c.ui.theme, Theme::Auto); |
| 561 | assert_eq!(c.cache.size_mb, 64); |
| 562 | assert_eq!( |
| 563 | c.repos.snapshots, |
| 564 | vec![SnapshotFormat::TarGz, SnapshotFormat::Zip] |
| 565 | ); |
| 566 | assert!(c.git_parallel() >= 1); |
| 567 | } |
| 568 | |
| 569 | #[test] |
| 570 | fn example_config_is_valid() { |
| 571 | let c = finish(crate::examples::CONFIG).unwrap(); |
| 572 | assert_eq!(c.site.title, "ferrenium"); |
| 573 | } |
| 574 | |
| 575 | #[test] |
| 576 | fn unknown_keys_are_rejected_with_position() { |
| 577 | let err = finish("[server]\nlisten = \"127.0.0.1:1\"\nlistn = 1\n").unwrap_err(); |
| 578 | let msg = err.to_string(); |
| 579 | assert!(msg.contains("unknown field `listn`"), "{msg}"); |
| 580 | assert!(msg.contains("line 3"), "{msg}"); |
| 581 | |
| 582 | let err = finish("[sever]\n").unwrap_err().to_string(); |
| 583 | assert!(err.contains("unknown field `sever`"), "{err}"); |
| 584 | } |
| 585 | |
| 586 | #[test] |
| 587 | fn listen_parsing() { |
| 588 | assert_eq!( |
| 589 | "unix:/run/ferrenium/ferrenium.sock".parse::<Listen>(), |
| 590 | Ok(Listen::Unix("/run/ferrenium/ferrenium.sock".into())) |
| 591 | ); |
| 592 | assert!("unix:relative.sock".parse::<Listen>().is_err()); |
| 593 | assert!("localhost".parse::<Listen>().is_err()); |
| 594 | assert_eq!( |
| 595 | "[::1]:9000".parse::<Listen>(), |
| 596 | Ok(Listen::Tcp("[::1]:9000".parse().unwrap())) |
| 597 | ); |
| 598 | let err = finish("[server]\nlisten = \"nope\"\n") |
| 599 | .unwrap_err() |
| 600 | .to_string(); |
| 601 | assert!(err.contains("invalid listen address"), "{err}"); |
| 602 | } |
| 603 | |
| 604 | #[test] |
| 605 | fn base_path_and_url_are_normalized() { |
| 606 | let c = finish("[server]\nbase_path = \"/git\"\nbase_url = \"https://x.org/\"\n").unwrap(); |
| 607 | assert_eq!(c.server.base_path, "/git/"); |
| 608 | assert_eq!(c.server.base_url.as_deref(), Some("https://x.org")); |
| 609 | assert_eq!( |
| 610 | finish("[server]\nbase_path = \"/\"\n") |
| 611 | .unwrap() |
| 612 | .server |
| 613 | .base_path, |
| 614 | "/" |
| 615 | ); |
| 616 | } |
| 617 | |
| 618 | #[test] |
| 619 | fn validation_reports_all_errors() { |
| 620 | let err = finish( |
| 621 | r#" |
| 622 | [server] |
| 623 | base_path = "git" |
| 624 | base_url = "ftp://x" |
| 625 | [repos] |
| 626 | max_depth = 0 |
| 627 | [ui] |
| 628 | theme = "dark" |
| 629 | code_font = "x; } body { display: none" |
| 630 | [cache] |
| 631 | size_mb = 0 |
| 632 | [[repo]] |
| 633 | name = "a.git" |
| 634 | [[repo]] |
| 635 | name = "a" |
| 636 | "#, |
| 637 | ) |
| 638 | .unwrap_err(); |
| 639 | let ConfigError::Invalid(errors) = err else { |
| 640 | panic!("expected validation errors, got {err}"); |
| 641 | }; |
| 642 | let all = errors.join("\n"); |
| 643 | for needle in [ |
| 644 | "base_path", |
| 645 | "base_url", |
| 646 | "max_depth", |
| 647 | "code_font", |
| 648 | "size_mb", |
| 649 | "defined more than once", |
| 650 | ] { |
| 651 | assert!(all.contains(needle), "missing {needle:?} in:\n{all}"); |
| 652 | } |
| 653 | } |
| 654 | |
| 655 | #[test] |
| 656 | fn invalid_enum_values_are_rejected() { |
| 657 | assert!(finish("[ui]\ntheme = \"purple\"\n").is_err()); |
| 658 | assert!(finish("[repos]\nsnapshots = [\"rar\"]\n").is_err()); |
| 659 | } |
| 660 | |
| 661 | #[test] |
| 662 | fn custom_files_resolve_relative_to_config() { |
| 663 | let dir = tempfile::tempdir().unwrap(); |
| 664 | std::fs::write(dir.path().join("custom.css"), "body{}").unwrap(); |
| 665 | let path = dir.path().join("ferrenium.toml"); |
| 666 | std::fs::write( |
| 667 | &path, |
| 668 | "[site]\ncustom_css = \"custom.css\"\n[repos]\nscan_path = \"repos\"\n", |
| 669 | ) |
| 670 | .unwrap(); |
| 671 | let c = Config::load(Some(&path)).unwrap(); |
| 672 | assert_eq!(c.custom.css.as_deref(), Some("body{}")); |
| 673 | assert_eq!(c.repos.scan_path, dir.path().join("repos")); |
| 674 | |
| 675 | std::fs::write(&path, "[site]\nfooter = \"missing.html\"\n").unwrap(); |
| 676 | let err = Config::load(Some(&path)).unwrap_err().to_string(); |
| 677 | assert!(err.contains("site.footer"), "{err}"); |
| 678 | } |
| 679 | } |