use serde::{Deserialize, Serialize, Serializer};
use std::{
net::IpAddr,
path::{Path, PathBuf},
};
use figment::{
Figment,
providers::{Env, Format, Serialized, Toml},
};
use crate::errors::ConfigError;
const DEFAULT_PORT: u16 = 5200;
const DEFAULT_OEMBED_TIMEOUT_MS: u64 = 500;
const DEFAULT_OEMBED_CACHE_SIZE: usize = 2 * 1024 * 1024; const DEFAULT_MEDIA_CACHE_SIZE: usize = 64 * 1024 * 1024; const DEFAULT_UPLOAD_MAX_BYTES: usize = 25 * 1024 * 1024;
fn default_media_cache_size() -> usize {
DEFAULT_MEDIA_CACHE_SIZE
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SortField {
pub field: String,
#[serde(default = "default_sort_order")]
pub order: String,
#[serde(default = "default_sort_compare")]
pub compare: String,
}
fn default_sort_order() -> String {
"asc".to_string()
}
fn default_sort_compare() -> String {
"string".to_string()
}
fn default_link_tracking() -> bool {
true
}
fn default_relationship_tracking() -> bool {
true
}
pub fn default_incomplete_markers() -> Vec<String> {
vec![
"TK".to_string(),
"TODO".to_string(),
"FIXME".to_string(),
"XXX".to_string(),
]
}
fn default_build_tag_pages() -> bool {
true
}
fn default_sidebar_style() -> String {
"panel".to_string()
}
const DEFAULT_SIDEBAR_MAX_ITEMS: usize = 100;
fn default_sidebar_max_items() -> usize {
DEFAULT_SIDEBAR_MAX_ITEMS
}
fn default_graph_depth() -> usize {
2
}
fn default_upload_max_bytes() -> usize {
DEFAULT_UPLOAD_MAX_BYTES
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TagSource {
pub field: String,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub label_plural: Option<String>,
}
impl TagSource {
pub fn singular_label(&self) -> String {
if let Some(ref label) = self.label {
return label.clone();
}
let field_name = self.field.rsplit('.').next().unwrap_or(&self.field);
title_case(field_name)
}
pub fn plural_label(&self) -> String {
if let Some(ref label) = self.label_plural {
return label.clone();
}
format!("{}s", self.singular_label())
}
pub fn url_source(&self) -> String {
crate::wikilink::sanitize_path_component(&self.field.to_lowercase())
}
}
fn title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let base = s.strip_suffix('s').unwrap_or(s);
if base.is_empty() {
return "S".to_string();
}
let mut chars = base.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => String::new(),
}
}
pub fn default_tag_sources() -> Vec<TagSource> {
vec![TagSource {
field: "tags".to_string(),
label: None,
label_plural: None,
}]
}
pub fn tag_sources_to_set(sources: &[TagSource]) -> std::collections::HashSet<String> {
sources.iter().map(|s| s.field.clone()).collect()
}
pub fn tag_sources_to_url_sources(sources: &[TagSource]) -> Vec<String> {
sources.iter().map(|s| s.url_source()).collect()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RelationType {
pub name: String,
#[serde(default)]
pub symmetric: bool,
#[serde(default)]
pub inverse: Option<String>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub label_plural: Option<String>,
}
impl RelationType {
pub fn singular_label(&self) -> String {
self.label
.clone()
.unwrap_or_else(|| title_case_word(&self.name))
}
pub fn plural_label(&self) -> String {
self.label_plural
.clone()
.unwrap_or_else(|| format!("{}s", self.singular_label()))
}
}
fn title_case_word(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => String::new(),
}
}
pub fn default_relationship_types() -> Vec<RelationType> {
vec![
RelationType {
name: "parent".to_string(),
symmetric: false,
inverse: Some("child".to_string()),
label: Some("Parent".to_string()),
label_plural: Some("Parents".to_string()),
},
RelationType {
name: "child".to_string(),
symmetric: false,
inverse: Some("parent".to_string()),
label: Some("Child".to_string()),
label_plural: Some("Children".to_string()),
},
RelationType {
name: "spouse".to_string(),
symmetric: true,
inverse: None,
label: Some("Spouse".to_string()),
label_plural: Some("Spouses".to_string()),
},
RelationType {
name: "sibling".to_string(),
symmetric: true,
inverse: None,
label: Some("Sibling".to_string()),
label_plural: Some("Siblings".to_string()),
},
]
}
impl Default for SortField {
fn default() -> Self {
Self {
field: "title".to_string(),
order: default_sort_order(),
compare: default_sort_compare(),
}
}
}
pub fn default_sort_config() -> Vec<SortField> {
vec![SortField::default()]
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IpArray(pub [u8; 4]);
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
pub root_dir: PathBuf,
pub host: IpArray,
pub port: u16,
pub static_folder: String,
pub markdown_extensions: Vec<String>,
pub theme: String,
pub index_file: String,
pub ignore_dirs: Vec<String>,
pub ignore_globs: Vec<String>,
pub watcher_ignore_dirs: Vec<String>,
pub oembed_timeout_ms: u64,
pub oembed_cache_size: usize,
#[serde(default = "default_media_cache_size")]
pub media_cache_size: usize,
#[serde(default)]
pub template_folder: Option<PathBuf>,
#[serde(default = "default_sort_config")]
pub sort: Vec<SortField>,
#[serde(default)]
pub build_concurrency: Option<usize>,
#[serde(default)]
pub transcode: bool,
#[serde(default)]
pub skip_link_checks: bool,
#[serde(default = "default_link_tracking")]
pub link_tracking: bool,
#[serde(default = "default_tag_sources")]
pub tag_sources: Vec<TagSource>,
#[serde(default = "default_relationship_tracking")]
pub relationship_tracking: bool,
#[serde(default = "default_relationship_types")]
pub relationship_types: Vec<RelationType>,
#[serde(default = "default_build_tag_pages")]
pub build_tag_pages: bool,
#[serde(default = "default_sidebar_style")]
pub sidebar_style: String,
#[serde(default = "default_sidebar_max_items")]
pub sidebar_max_items: usize,
#[serde(default = "default_graph_depth")]
pub graph_depth: usize,
#[serde(default)]
pub title_prefix: String,
#[serde(default)]
pub title_suffix: String,
#[serde(default = "default_incomplete_markers")]
pub incomplete_markers: Vec<String>,
#[serde(default)]
pub mark_incomplete: Option<bool>,
#[serde(default)]
pub edit_enabled: bool,
#[serde(default)]
pub edit_token_hash: Option<String>,
#[serde(default)]
pub edit_require_token_on_loopback: bool,
#[serde(default = "default_upload_max_bytes")]
pub upload_max_bytes: usize,
}
impl std::fmt::Display for IpArray {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let [a, b, c, d] = self.0;
write!(f, "{a}.{b}.{c}.{d}")
}
}
impl<'de> Deserialize<'de> for IpArray {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let ip_str = String::deserialize(deserializer)?;
let ip: IpAddr = ip_str.parse().map_err(serde::de::Error::custom)?;
match ip {
IpAddr::V4(v4) => Ok(IpArray(v4.octets())),
IpAddr::V6(_) => Err(serde::de::Error::custom("IPv6 addresses are not supported")),
}
}
}
impl Serialize for IpArray {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let ip = std::net::Ipv4Addr::from(self.0);
serializer.serialize_str(&ip.to_string())
}
}
impl Default for Config {
fn default() -> Self {
Config {
root_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
host: IpArray([127, 0, 0, 1]),
port: DEFAULT_PORT,
static_folder: "static".to_string(),
markdown_extensions: vec!["md".to_string()],
theme: "default".to_string(),
index_file: "index.md".to_string(),
ignore_dirs: [
"target",
"result",
"build",
"node_modules",
"ci",
"templates",
".git",
".github",
"dist",
"out",
"coverage",
]
.into_iter()
.map(|x| x.to_string())
.collect(),
ignore_globs: [
"*.log", "*.bak", "*.lock", "*.sh", "*.css", "*.scss", "*.js", "*.ts",
]
.into_iter()
.map(|x| x.to_string())
.collect(),
watcher_ignore_dirs: [".direnv", ".git", "result", "target", "build"]
.into_iter()
.map(|x| x.to_string())
.collect(),
oembed_timeout_ms: DEFAULT_OEMBED_TIMEOUT_MS,
oembed_cache_size: DEFAULT_OEMBED_CACHE_SIZE,
media_cache_size: DEFAULT_MEDIA_CACHE_SIZE,
template_folder: None,
sort: default_sort_config(),
build_concurrency: None, transcode: false, skip_link_checks: false, link_tracking: true, tag_sources: default_tag_sources(),
relationship_tracking: true, relationship_types: default_relationship_types(),
build_tag_pages: true, sidebar_style: default_sidebar_style(),
sidebar_max_items: default_sidebar_max_items(),
graph_depth: default_graph_depth(),
title_prefix: String::new(),
title_suffix: String::new(),
incomplete_markers: default_incomplete_markers(),
mark_incomplete: None,
edit_enabled: false,
edit_token_hash: None,
edit_require_token_on_loopback: false,
upload_max_bytes: DEFAULT_UPLOAD_MAX_BYTES,
}
}
}
fn is_home_dir(path: &Path) -> bool {
let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
let Some(home) = std::env::var_os(home_var).map(PathBuf::from) else {
return false;
};
if path == home {
return true;
}
match (path.canonicalize(), home.canonicalize()) {
(Ok(path), Ok(home)) => path == home,
_ => false,
}
}
pub fn find_root_dir(start_path: &Path) -> PathBuf {
const DIR_MARKERS: &[&str] = &[".mbr", ".git", ".zk", ".obsidian"];
const FILE_MARKERS: &[&str] = &["book.toml", "mkdocs.yml", "docusaurus.config.js"];
let dir = if start_path.is_dir() {
start_path
} else {
start_path.parent().unwrap_or(start_path)
};
for marker in DIR_MARKERS {
if let Some(root) = dir
.ancestors()
.find(|a| a.join(marker).is_dir())
.map(|p| p.to_path_buf())
{
if is_home_dir(&root) {
continue;
}
return root;
}
}
for marker in FILE_MARKERS {
if let Some(root) = dir
.ancestors()
.find(|a| a.join(marker).is_file())
.map(|p| p.to_path_buf())
{
if is_home_dir(&root) {
continue;
}
return root;
}
}
dir.to_path_buf()
}
impl Config {
pub fn read(search_config_from: &Path) -> Result<Self, crate::MbrError> {
let default_config = Config::default();
let root_dir = find_root_dir(search_config_from);
let figment = Figment::new()
.merge(Serialized::defaults(default_config))
.merge(Toml::file(root_dir.join(".mbr/config.toml")))
.merge(Env::prefixed("MBR_"));
let mut config: Config = figment
.extract()
.map_err(|e| ConfigError::ParseFailed(Box::new(e)))?;
tracing::debug!("Loaded config: {:?}", &config);
config.root_dir = root_dir;
config.reject_repo_supplied_absolute_static_folder(&figment)?;
config.validate()?;
config.log_external_static_folder();
Ok(config)
}
fn reject_repo_supplied_absolute_static_folder(
&self,
figment: &Figment,
) -> Result<(), ConfigError> {
if !is_rooted(Path::new(&self.static_folder)) {
return Ok(());
}
let from_file = figment
.find_metadata("static_folder")
.and_then(|metadata| metadata.source.as_ref())
.and_then(figment::Source::file_path);
match from_file {
Some(source) => Err(invalid_static_folder(
&self.static_folder,
&format!(
"an absolute path is only honored from the MBR_STATIC_FOLDER environment \
variable, not from a repository config file ({})",
source.display()
),
)),
None => Ok(()),
}
}
fn log_external_static_folder(&self) {
if let Ok(StaticOverlay::External(dir)) =
resolve_static_overlay(&self.root_dir, &self.static_folder)
{
tracing::info!(
"static_folder {:?} resolves outside the markdown root ({}): serving and indexing assets from {}",
self.static_folder,
self.root_dir.display(),
dir.display()
);
}
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.validate_static_folder()?;
if self.port == 0 {
return Err(ConfigError::InvalidPort { port: self.port });
}
if self.sidebar_max_items == 0 {
return Err(ConfigError::InvalidSidebarMaxItems {
value: self.sidebar_max_items,
});
}
if !(1..=5).contains(&self.graph_depth) {
return Err(ConfigError::InvalidGraphDepth {
value: self.graph_depth,
});
}
if matches!(self.build_concurrency, Some(0)) {
return Err(ConfigError::InvalidBuildConcurrency { value: 0 });
}
if self.edit_enabled
&& !std::net::Ipv4Addr::from(self.host.0).is_loopback()
&& self.edit_token_hash.is_none()
{
return Err(ConfigError::EditingRequiresToken);
}
Ok(())
}
fn validate_static_folder(&self) -> Result<(), ConfigError> {
resolve_static_overlay(&self.root_dir, &self.static_folder).map(|_| ())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StaticOverlay {
WithinRoot,
External(PathBuf),
}
pub fn resolve_static_overlay(
root_dir: &Path,
static_folder: &str,
) -> Result<StaticOverlay, ConfigError> {
if static_folder.is_empty() {
return Ok(StaticOverlay::WithinRoot);
}
let root = resolve_existing_or_lexical(root_dir);
let dir = resolve_existing_or_lexical(&root.join(static_folder));
if dir.starts_with(&root) {
return Ok(StaticOverlay::WithinRoot);
}
if is_rooted(Path::new(static_folder)) {
return Ok(StaticOverlay::External(dir));
}
let Some(parent) = root.parent() else {
return Err(invalid_static_folder(
static_folder,
"resolves outside the markdown root, which has no parent directory",
));
};
if is_home_dir(parent) {
return Err(invalid_static_folder(
static_folder,
"resolves outside the markdown root and into the home directory",
));
}
if parent.parent().is_none() {
return Err(invalid_static_folder(
static_folder,
"resolves outside the markdown root and into the filesystem root",
));
}
if dir == parent {
return Err(invalid_static_folder(
static_folder,
"resolves to the parent of the markdown root, which would expose every \
sibling directory; name a specific sibling instead",
));
}
if !dir.starts_with(parent) {
return Err(invalid_static_folder(
static_folder,
"reaches past the parent of the markdown root; only a peer of the root is allowed",
));
}
Ok(StaticOverlay::External(dir))
}
fn is_rooted(path: &Path) -> bool {
use std::path::Component;
matches!(
path.components().next(),
Some(Component::RootDir | Component::Prefix(_))
)
}
fn resolve_existing_or_lexical(path: &Path) -> PathBuf {
path.canonicalize()
.unwrap_or_else(|_| lexically_normalize(path))
}
fn lexically_normalize(path: &Path) -> PathBuf {
use std::path::Component;
path.components()
.fold(PathBuf::new(), |mut normalized, component| {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() && !is_rooted(&normalized) {
normalized.push(Component::ParentDir);
}
}
other => normalized.push(other),
}
normalized
})
}
fn invalid_static_folder(value: &str, reason: &str) -> ConfigError {
ConfigError::ParseFailed(Box::new(figment::Error::from(format!(
"Invalid static_folder {value:?}: {reason}"
))))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_title_case() {
assert_eq!(title_case("tags"), "Tag");
assert_eq!(title_case("performers"), "Performer");
assert_eq!(title_case("category"), "Category");
assert_eq!(title_case("Tag"), "Tag");
assert_eq!(title_case("s"), "S");
assert_eq!(title_case(""), "");
}
#[test]
fn test_tag_source_singular_label_explicit() {
let source = TagSource {
field: "taxonomy.performers".to_string(),
label: Some("Performer".to_string()),
label_plural: None,
};
assert_eq!(source.singular_label(), "Performer");
}
#[test]
fn test_tag_source_singular_label_derived() {
let source = TagSource {
field: "tags".to_string(),
label: None,
label_plural: None,
};
assert_eq!(source.singular_label(), "Tag");
}
#[test]
fn test_tag_source_singular_label_derived_nested() {
let source = TagSource {
field: "taxonomy.performers".to_string(),
label: None,
label_plural: None,
};
assert_eq!(source.singular_label(), "Performer");
}
#[test]
fn test_tag_source_plural_label_explicit() {
let source = TagSource {
field: "taxonomy.performers".to_string(),
label: None,
label_plural: Some("Performers".to_string()),
};
assert_eq!(source.plural_label(), "Performers");
}
#[test]
fn test_tag_source_plural_label_derived() {
let source = TagSource {
field: "tags".to_string(),
label: None,
label_plural: None,
};
assert_eq!(source.plural_label(), "Tags");
}
#[test]
fn test_tag_source_url_source() {
let source = TagSource {
field: "Tags".to_string(),
label: None,
label_plural: None,
};
assert_eq!(source.url_source(), "tags");
let source = TagSource {
field: "taxonomy.Performers".to_string(),
label: None,
label_plural: None,
};
assert_eq!(source.url_source(), "taxonomy.performers");
}
#[test]
fn test_default_tag_sources() {
let sources = default_tag_sources();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].field, "tags");
assert_eq!(sources[0].singular_label(), "Tag");
assert_eq!(sources[0].plural_label(), "Tags");
assert_eq!(sources[0].url_source(), "tags");
}
#[test]
fn test_config_default_has_tag_sources() {
let config = Config::default();
assert_eq!(config.tag_sources.len(), 1);
assert_eq!(config.tag_sources[0].field, "tags");
assert!(config.build_tag_pages);
}
#[test]
fn test_config_default_has_relationship_types() {
let config = Config::default();
assert!(config.relationship_tracking);
assert_eq!(config.relationship_types.len(), 4);
let names: Vec<&str> = config
.relationship_types
.iter()
.map(|t| t.name.as_str())
.collect();
assert!(names.contains(&"parent"));
assert!(names.contains(&"child"));
assert!(names.contains(&"spouse"));
assert!(names.contains(&"sibling"));
}
#[test]
fn test_default_relationship_types_semantics() {
let types = default_relationship_types();
let parent = types.iter().find(|t| t.name == "parent").unwrap();
assert!(!parent.symmetric);
assert_eq!(parent.inverse.as_deref(), Some("child"));
assert_eq!(parent.plural_label(), "Parents");
let child = types.iter().find(|t| t.name == "child").unwrap();
assert_eq!(child.inverse.as_deref(), Some("parent"));
assert_eq!(child.plural_label(), "Children");
let spouse = types.iter().find(|t| t.name == "spouse").unwrap();
assert!(spouse.symmetric);
assert!(spouse.inverse.is_none());
}
#[test]
fn test_relation_type_label_derivation() {
let rel = RelationType {
name: "cousin".to_string(),
symmetric: true,
inverse: None,
label: None,
label_plural: None,
};
assert_eq!(rel.singular_label(), "Cousin");
assert_eq!(rel.plural_label(), "Cousins");
}
#[test]
fn test_relation_type_deserialization_minimal() {
let json = r#"{"name": "friend", "symmetric": true}"#;
let rel: RelationType = serde_json::from_str(json).unwrap();
assert_eq!(rel.name, "friend");
assert!(rel.symmetric);
assert!(rel.inverse.is_none());
assert!(rel.label.is_none());
}
#[test]
fn test_tag_source_serialization() {
let source = TagSource {
field: "taxonomy.tags".to_string(),
label: Some("Tag".to_string()),
label_plural: Some("Tags".to_string()),
};
let json = serde_json::to_string(&source).unwrap();
let parsed: TagSource = serde_json::from_str(&json).unwrap();
assert_eq!(source, parsed);
}
#[test]
fn test_tag_source_deserialization_minimal() {
let json = r#"{"field": "tags"}"#;
let source: TagSource = serde_json::from_str(json).unwrap();
assert_eq!(source.field, "tags");
assert!(source.label.is_none());
assert!(source.label_plural.is_none());
}
#[test]
fn test_validate_default_config_passes() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_port_zero_fails() {
let config = Config {
port: 0,
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::InvalidPort { port: 0 }));
}
#[test]
fn test_validate_valid_ports_pass() {
let config = Config {
port: 1,
..Default::default()
};
assert!(config.validate().is_ok());
let config = Config {
port: 80,
..Default::default()
};
assert!(config.validate().is_ok());
let config = Config {
port: 443,
..Default::default()
};
assert!(config.validate().is_ok());
let config = Config {
port: 65535,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_sidebar_max_items_zero_fails() {
let config = Config {
sidebar_max_items: 0,
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidSidebarMaxItems { value: 0 }
));
}
#[test]
fn test_validate_valid_sidebar_max_items_pass() {
let config = Config {
sidebar_max_items: 1,
..Default::default()
};
assert!(config.validate().is_ok());
let config = Config {
sidebar_max_items: 100,
..Default::default()
};
assert!(config.validate().is_ok());
let config = Config {
sidebar_max_items: 10000,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_default_graph_depth_is_2() {
let config = Config::default();
assert_eq!(config.graph_depth, 2);
}
#[test]
fn test_validate_graph_depth_zero_fails() {
let config = Config {
graph_depth: 0,
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::InvalidGraphDepth { value: 0 }));
}
#[test]
fn test_validate_graph_depth_six_fails() {
let config = Config {
graph_depth: 6,
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::InvalidGraphDepth { value: 6 }));
}
#[test]
fn test_validate_graph_depth_bounds_pass() {
let config = Config {
graph_depth: 1,
..Default::default()
};
assert!(config.validate().is_ok());
let config = Config {
graph_depth: 5,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_build_concurrency_zero_fails() {
let config = Config {
build_concurrency: Some(0),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidBuildConcurrency { value: 0 }
));
}
#[test]
fn test_validate_build_concurrency_none_passes() {
let config = Config {
build_concurrency: None, ..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_valid_build_concurrency_pass() {
let config = Config {
build_concurrency: Some(1),
..Default::default()
};
assert!(config.validate().is_ok());
let config = Config {
build_concurrency: Some(8),
..Default::default()
};
assert!(config.validate().is_ok());
let config = Config {
build_concurrency: Some(32),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_default_title_prefix_empty() {
let config = Config::default();
assert_eq!(config.title_prefix, "");
}
#[test]
fn test_default_title_suffix_empty() {
let config = Config::default();
assert_eq!(config.title_suffix, "");
}
#[test]
fn test_validate_editing_non_loopback_without_token_fails() {
let config = Config {
edit_enabled: true,
host: IpArray([0, 0, 0, 0]),
edit_token_hash: None,
..Default::default()
};
assert!(matches!(
config.validate(),
Err(ConfigError::EditingRequiresToken)
));
}
#[test]
fn test_validate_editing_non_loopback_with_token_passes() {
let config = Config {
edit_enabled: true,
host: IpArray([0, 0, 0, 0]),
edit_token_hash: Some("$argon2id$fake".to_string()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_editing_loopback_without_token_passes() {
let config = Config {
edit_enabled: true,
host: IpArray([127, 0, 0, 1]),
edit_token_hash: None,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_oembed_cache_size_zero_is_valid() {
let config = Config {
oembed_cache_size: 0,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_media_cache_size_default_is_independent_of_oembed() {
let config = Config::default();
assert_eq!(config.media_cache_size, 64 * 1024 * 1024);
assert_ne!(
config.media_cache_size, config.oembed_cache_size,
"media cache must not inherit the oembed text-metadata budget"
);
}
#[test]
fn test_disabling_oembed_cache_keeps_media_cache_enabled() {
let config = Config {
oembed_cache_size: 0,
..Default::default()
};
assert!(config.validate().is_ok());
assert_eq!(config.media_cache_size, 64 * 1024 * 1024);
}
#[test]
fn test_media_cache_size_config_file_layering() {
let without: Config = Figment::new()
.merge(Serialized::defaults(Config::default()))
.merge(Toml::string("port = 5201"))
.extract()
.expect("config without the key parses");
assert_eq!(without.media_cache_size, 64 * 1024 * 1024);
let with: Config = Figment::new()
.merge(Serialized::defaults(Config::default()))
.merge(Toml::string("media_cache_size = 1048576"))
.extract()
.expect("explicit value parses");
assert_eq!(with.media_cache_size, 1024 * 1024);
}
#[test]
fn test_find_root_dir_file_without_markers_returns_parent() {
let tmp = tempfile::tempdir().unwrap();
let file_path = tmp.path().join("test.md");
std::fs::write(&file_path, "# Hello").unwrap();
let root = find_root_dir(&file_path);
assert!(
root.is_dir(),
"root_dir should be a directory, got: {root:?}"
);
assert_eq!(root, tmp.path());
}
#[test]
fn test_find_root_dir_directory_without_markers_returns_itself() {
let tmp = tempfile::tempdir().unwrap();
let root = find_root_dir(tmp.path());
assert!(root.is_dir());
assert!(
root.is_dir(),
"root_dir should be a directory, got: {root:?}"
);
}
#[test]
fn test_find_root_dir_with_git_marker_returns_marker_parent() {
let tmp = tempfile::tempdir().unwrap();
let nested = tmp.path().join("sub").join("dir");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let file_path = nested.join("test.md");
std::fs::write(&file_path, "# Hello").unwrap();
let root = find_root_dir(&file_path);
assert_eq!(root, tmp.path().to_path_buf());
assert!(root.is_dir());
}
#[test]
fn test_is_home_dir() {
let _guard = env_lock();
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
assert!(is_home_dir(&home));
assert!(!is_home_dir(Path::new("/tmp")));
}
}
fn peer_layout() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project/content");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(tmp.path().join("project/static")).unwrap();
(tmp, root)
}
fn config_with_static(root: &Path, static_folder: &str) -> Config {
Config {
root_dir: root.to_path_buf(),
static_folder: static_folder.to_string(),
..Default::default()
}
}
#[test]
fn test_validate_static_folder_parent_escape_fails() {
let (_tmp, root) = peer_layout();
for value in ["..", "../..", "../../assets", "assets/../../.."] {
let err = match config_with_static(&root, value).validate() {
Err(err) => err,
Ok(()) => panic!("escaping static_folder must be rejected: {value:?}"),
};
assert!(
format!("{err:?}").contains("static_folder"),
"error should name static_folder, got: {err:?}"
);
}
}
#[test]
fn test_validate_static_folder_into_home_fails() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().into_owned();
let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
let root = home.path().join("notes");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(home.path().join(".ssh")).unwrap();
for value in ["..", "../.ssh"] {
assert!(
config_with_static(&root, value).validate().is_err(),
"static_folder {value:?} resolving into $HOME must be rejected"
);
}
std::fs::create_dir(root.join("static")).unwrap();
assert!(
config_with_static(&root, "static").validate().is_ok(),
"an in-root static folder must still work under $HOME"
);
}
#[test]
fn test_validate_static_folder_filesystem_root_boundary_fails() {
let root = if cfg!(windows) { r"C:\notes" } else { "/notes" };
assert!(
config_with_static(Path::new(root), "../etc")
.validate()
.is_err(),
"a root whose parent is the filesystem root must not reach outside itself"
);
}
#[test]
fn test_validate_static_folder_absolute_is_deferred_to_provenance() {
assert!(is_rooted(Path::new("/etc")), "`/etc` is rooted everywhere");
assert!(
is_rooted(Path::new(r"C:\etc")) || !cfg!(windows),
"a drive-prefixed path is rooted on Windows"
);
assert!(!is_rooted(Path::new("static")));
assert!(!is_rooted(Path::new("../static")));
let (_tmp, root) = peer_layout();
let absolute = if cfg!(windows) { r"C:\etc" } else { "/etc" };
for value in [absolute, "/etc"] {
assert!(
config_with_static(&root, value).validate().is_ok(),
"validate must defer an absolute static_folder {value:?} to provenance"
);
}
}
#[cfg(unix)]
#[test]
fn test_validate_static_folder_symlink_escape_fails() {
let unrelated = tempfile::tempdir().unwrap();
let secrets = unrelated.path().join("secrets");
std::fs::create_dir(&secrets).unwrap();
let (_tmp, root) = peer_layout();
std::os::unix::fs::symlink(&secrets, root.join("static")).unwrap();
assert!(
config_with_static(&root, "static").validate().is_err(),
"a static_folder symlinked past the peer boundary must be rejected"
);
}
#[cfg(unix)]
#[test]
fn test_validate_static_folder_symlink_to_peer_passes() {
let (tmp, root) = peer_layout();
std::os::unix::fs::symlink(tmp.path().join("project/static"), root.join("assets")).unwrap();
assert!(
config_with_static(&root, "assets").validate().is_ok(),
"a static_folder symlinked to a peer of the root must be accepted"
);
}
#[test]
fn test_validate_static_folder_relative_passes() {
let (_tmp, root) = peer_layout();
for value in ["", "static", "assets", "public/assets", "./static"] {
assert!(
config_with_static(&root, value).validate().is_ok(),
"relative static_folder {value:?} must be accepted"
);
}
}
#[test]
fn test_validate_static_folder_peer_passes() {
let (tmp, root) = peer_layout();
std::fs::create_dir_all(tmp.path().join("project/static/videos")).unwrap();
for value in ["../static", "../static/videos", "./../static"] {
assert!(
config_with_static(&root, value).validate().is_ok(),
"peer static_folder {value:?} must be accepted"
);
}
}
#[test]
fn test_validate_static_folder_nonexistent_paths_use_lexical_fallback() {
let (_tmp, root) = peer_layout();
assert!(
config_with_static(&root, "../not-created-yet")
.validate()
.is_ok(),
"a peer that does not exist yet is still a peer"
);
assert!(
config_with_static(&root, "../../not-created-yet")
.validate()
.is_err(),
"a nonexistent path past the boundary must still be rejected"
);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
struct EnvVars {
saved: Vec<(String, Option<std::ffi::OsString>)>,
}
impl EnvVars {
fn set(pairs: &[(&str, &str)]) -> Self {
let saved = pairs
.iter()
.map(|(key, value)| {
let previous = std::env::var_os(key);
unsafe { std::env::set_var(key, value) };
((*key).to_string(), previous)
})
.collect();
Self { saved }
}
}
impl Drop for EnvVars {
fn drop(&mut self) {
for (key, previous) in &self.saved {
unsafe {
match previous {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
}
#[test]
fn test_find_root_dir_home_marker_does_not_abort_search() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().into_owned();
let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
std::fs::create_dir(home.path().join(".git")).unwrap();
let vault = home.path().join("notes");
std::fs::create_dir(&vault).unwrap();
std::fs::create_dir(vault.join(".obsidian")).unwrap();
let leaf = vault.join("projects");
std::fs::create_dir(&leaf).unwrap();
let note = leaf.join("plan.md");
std::fs::write(¬e, "# Plan").unwrap();
assert_eq!(
find_root_dir(¬e),
vault,
"the `.obsidian` vault must win once the `$HOME` `.git` is skipped"
);
}
#[test]
fn test_find_root_dir_only_home_marker_falls_back() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
let home_str = home.path().to_string_lossy().into_owned();
let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
std::fs::create_dir(home.path().join(".git")).unwrap();
let leaf = home.path().join("notes/projects");
std::fs::create_dir_all(&leaf).unwrap();
let note = leaf.join("plan.md");
std::fs::write(¬e, "# Plan").unwrap();
assert_eq!(find_root_dir(¬e), leaf);
}
fn repo_with_mbr_dir(config_toml: Option<&str>) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let mbr = dir.path().join(".mbr");
std::fs::create_dir(&mbr).unwrap();
if let Some(contents) = config_toml {
std::fs::write(mbr.join("config.toml"), contents).unwrap();
}
dir
}
#[test]
fn test_config_read_defaults_only() {
let _guard = env_lock();
let repo = repo_with_mbr_dir(None);
let config = Config::read(repo.path()).expect("defaults must load");
assert_eq!(config.theme, Config::default().theme);
assert_eq!(config.port, DEFAULT_PORT);
assert_eq!(config.root_dir, repo.path());
}
#[test]
fn test_config_read_toml_overrides_defaults() {
let _guard = env_lock();
let repo = repo_with_mbr_dir(Some("theme = \"amber\"\nport = 5321\n"));
let config = Config::read(repo.path()).expect("toml must load");
assert_eq!(config.theme, "amber");
assert_eq!(config.port, 5321);
}
#[test]
fn test_config_read_env_overrides_defaults() {
let _guard = env_lock();
let repo = repo_with_mbr_dir(None);
let _env = EnvVars::set(&[("MBR_THEME", "cyan"), ("MBR_PORT", "5322")]);
let config = Config::read(repo.path()).expect("env must load");
assert_eq!(config.theme, "cyan");
assert_eq!(config.port, 5322);
}
#[test]
fn test_config_read_env_beats_toml_beats_defaults() {
let _guard = env_lock();
let repo = repo_with_mbr_dir(Some(
"theme = \"amber\"\nport = 5321\nindex_file = \"home.md\"\n",
));
let _env = EnvVars::set(&[("MBR_THEME", "cyan")]);
let config = Config::read(repo.path()).expect("all three layers must load");
assert_eq!(config.theme, "cyan", "env must win over the config file");
assert_eq!(
config.index_file, "home.md",
"config file must win over defaults where env is silent"
);
assert_eq!(
config.port, 5321,
"config file must win over defaults where env is silent"
);
assert_eq!(
config.markdown_extensions,
Config::default().markdown_extensions,
"untouched keys keep the compiled-in default"
);
}
#[test]
fn test_config_read_malformed_toml_returns_parse_failed() {
let _guard = env_lock();
let repo = repo_with_mbr_dir(Some("theme = \nport = [not valid toml\n"));
let err = Config::read(repo.path()).expect_err("malformed toml must fail");
assert!(
matches!(err, crate::MbrError::Config(ConfigError::ParseFailed(_))),
"expected ConfigError::ParseFailed, got: {err:?}"
);
}
#[test]
fn test_config_read_rejects_escaping_static_folder_from_toml() {
let _guard = env_lock();
let repo = repo_with_mbr_dir(Some("static_folder = \"../..\"\n"));
assert!(
Config::read(repo.path()).is_err(),
"a repo-supplied static_folder that escapes the root must be rejected"
);
}
#[test]
fn test_config_read_rejects_absolute_static_folder_from_toml() {
let _guard = env_lock();
let absolute = if cfg!(windows) { r"C:\\etc" } else { "/etc" };
let repo = repo_with_mbr_dir(Some(&format!("static_folder = \"{absolute}\"\n")));
let err = Config::read(repo.path())
.expect_err("an absolute static_folder from the repo config must be rejected");
assert!(
format!("{err:?}").contains("static_folder"),
"error should name static_folder, got: {err:?}"
);
}
#[test]
fn test_config_read_accepts_absolute_static_folder_from_env() {
let _guard = env_lock();
let repo = repo_with_mbr_dir(None);
let assets = tempfile::tempdir().unwrap();
let assets_str = assets.path().to_string_lossy().into_owned();
let _env = EnvVars::set(&[("MBR_STATIC_FOLDER", &assets_str)]);
let config = Config::read(repo.path())
.expect("an absolute static_folder from the environment must be accepted");
assert_eq!(config.static_folder, assets_str);
}
#[test]
fn test_config_read_env_absolute_static_folder_overrides_toml() {
let _guard = env_lock();
let repo = repo_with_mbr_dir(Some("static_folder = \"/etc\"\n"));
let assets = tempfile::tempdir().unwrap();
let assets_str = assets.path().to_string_lossy().into_owned();
let _env = EnvVars::set(&[("MBR_STATIC_FOLDER", &assets_str)]);
let config = Config::read(repo.path())
.expect("the operator's absolute static_folder must win over the repo's");
assert_eq!(config.static_folder, assets_str);
}
#[test]
fn test_config_read_accepts_peer_static_folder_from_toml() {
let _guard = env_lock();
let project = tempfile::tempdir().unwrap();
let content = project.path().join("content");
std::fs::create_dir_all(content.join(".mbr")).unwrap();
std::fs::write(
content.join(".mbr/config.toml"),
"static_folder = \"../static\"\n",
)
.unwrap();
std::fs::create_dir(project.path().join("static")).unwrap();
let config = Config::read(&content).expect("a peer static folder must load");
assert_eq!(config.static_folder, "../static");
assert_eq!(config.root_dir, content);
}
}