1use serde::{Deserialize, Serialize, Serializer};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::{
4 net::IpAddr,
5 path::{Path, PathBuf},
6};
7
8use figment::{
9 Figment,
10 providers::{Env, Format, Serialized, Toml},
11};
12
13use crate::errors::ConfigError;
14use crate::task_query::IncludeFilter;
15
16const DEFAULT_PORT: u16 = 5200;
17const DEFAULT_OEMBED_TIMEOUT_MS: u64 = 500;
18const 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 {
32 DEFAULT_MEDIA_CACHE_SIZE
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct SortField {
38 pub field: String,
40 #[serde(default = "default_sort_order")]
42 pub order: String,
43 #[serde(default = "default_sort_compare")]
45 pub compare: String,
46}
47
48fn default_sort_order() -> String {
49 "asc".to_string()
50}
51
52fn default_sort_compare() -> String {
53 "string".to_string()
54}
55
56fn default_link_tracking() -> bool {
57 true
58}
59
60fn default_relationship_tracking() -> bool {
61 true
62}
63
64fn default_tasks_enabled() -> bool {
65 true
66}
67
68fn default_review_enabled() -> bool {
69 true
70}
71
72fn default_tasks_stamp_done() -> bool {
73 true
74}
75
76fn default_tasks_default_include() -> IncludeFilter {
83 IncludeFilter::Tasks
84}
85
86pub fn default_incomplete_markers() -> Vec<String> {
94 vec![
95 "TK".to_string(),
96 "TODO:".to_string(),
97 "FIXME".to_string(),
98 "XXXX".to_string(),
99 ]
100}
101
102fn default_build_tag_pages() -> bool {
103 true
104}
105
106fn default_sidebar_style() -> String {
107 "panel".to_string()
108}
109
110const DEFAULT_SIDEBAR_MAX_ITEMS: usize = 100;
111
112fn default_sidebar_max_items() -> usize {
113 DEFAULT_SIDEBAR_MAX_ITEMS
114}
115
116fn default_graph_depth() -> usize {
117 2
118}
119
120const DEFAULT_FLASHCARDS_CONCENTRIC_THRESHOLD: f64 = 0.7;
123
124fn default_flashcards_concentric_threshold() -> f64 {
125 DEFAULT_FLASHCARDS_CONCENTRIC_THRESHOLD
126}
127
128fn default_flashcards_progress_indicators() -> bool {
129 true
130}
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
145#[serde(rename_all = "lowercase")]
146pub enum MenuBarVisibility {
147 #[default]
149 Auto,
150 Always,
152 Never,
154}
155
156fn default_gui_menu_bar() -> MenuBarVisibility {
158 MenuBarVisibility::Auto
159}
160
161fn default_upload_max_bytes() -> usize {
162 DEFAULT_UPLOAD_MAX_BYTES
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183pub struct TagSource {
184 pub field: String,
187
188 #[serde(default)]
191 pub label: Option<String>,
192
193 #[serde(default)]
196 pub label_plural: Option<String>,
197}
198
199impl TagSource {
200 pub fn singular_label(&self) -> String {
206 if let Some(ref label) = self.label {
207 return label.clone();
208 }
209
210 let field_name = self.field.rsplit('.').next().unwrap_or(&self.field);
212
213 title_case(field_name)
215 }
216
217 pub fn plural_label(&self) -> String {
223 if let Some(ref label) = self.label_plural {
224 return label.clone();
225 }
226
227 format!("{}s", self.singular_label())
229 }
230
231 pub fn url_source(&self) -> String {
237 crate::wikilink::sanitize_path_component(&self.field.to_lowercase())
238 }
239}
240
241fn title_case(s: &str) -> String {
246 if s.is_empty() {
247 return String::new();
248 }
249
250 let base = s.strip_suffix('s').unwrap_or(s);
252 if base.is_empty() {
253 return "S".to_string();
254 }
255
256 let mut chars = base.chars();
258 match chars.next() {
259 Some(first) => first.to_uppercase().chain(chars).collect(),
260 None => String::new(),
261 }
262}
263
264pub fn default_tag_sources() -> Vec<TagSource> {
268 vec![TagSource {
269 field: "tags".to_string(),
270 label: None,
271 label_plural: None,
272 }]
273}
274
275pub fn tag_sources_to_set(sources: &[TagSource]) -> std::collections::HashSet<String> {
280 sources.iter().map(|s| s.field.clone()).collect()
281}
282
283pub fn tag_sources_to_url_sources(sources: &[TagSource]) -> Vec<String> {
288 sources.iter().map(|s| s.url_source()).collect()
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312pub struct RelationType {
313 pub name: String,
315
316 #[serde(default)]
318 pub symmetric: bool,
319
320 #[serde(default)]
322 pub inverse: Option<String>,
323
324 #[serde(default)]
326 pub label: Option<String>,
327
328 #[serde(default)]
331 pub label_plural: Option<String>,
332
333 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub hierarchy: Option<Hierarchy>,
343
344 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub category: Option<String>,
349}
350
351#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
353#[serde(rename_all = "lowercase")]
354pub enum Hierarchy {
355 Up,
357 Down,
359}
360
361impl Hierarchy {
362 pub fn flipped(self) -> Self {
364 match self {
365 Self::Up => Self::Down,
366 Self::Down => Self::Up,
367 }
368 }
369
370 pub fn as_str(self) -> &'static str {
372 match self {
373 Self::Up => "up",
374 Self::Down => "down",
375 }
376 }
377}
378
379impl RelationType {
380 pub fn singular_label(&self) -> String {
382 self.label
383 .clone()
384 .unwrap_or_else(|| title_case_word(&self.name))
385 }
386
387 pub fn plural_label(&self) -> String {
389 self.label_plural
390 .clone()
391 .unwrap_or_else(|| format!("{}s", self.singular_label()))
392 }
393}
394
395fn title_case_word(s: &str) -> String {
398 let mut chars = s.chars();
399 match chars.next() {
400 Some(first) => first.to_uppercase().chain(chars).collect(),
401 None => String::new(),
402 }
403}
404
405pub fn default_relationship_types() -> Vec<RelationType> {
423 use Hierarchy::{Down, Up};
424 type Row = (
426 &'static str,
427 Option<&'static str>,
428 bool,
429 Option<Hierarchy>,
430 &'static str,
431 &'static str,
432 &'static str,
433 );
434 const FAMILY: &str = "family";
435 const WORK: &str = "work";
436 let table: [Row; 11] = [
437 (
438 "parent",
439 Some("child"),
440 false,
441 Some(Up),
442 FAMILY,
443 "Parent",
444 "Parents",
445 ),
446 (
447 "child",
448 Some("parent"),
449 false,
450 Some(Down),
451 FAMILY,
452 "Child",
453 "Children",
454 ),
455 ("spouse", None, true, None, FAMILY, "Spouse", "Spouses"),
456 ("sibling", None, true, None, FAMILY, "Sibling", "Siblings"),
457 (
458 "reports_to",
459 Some("manages"),
460 false,
461 Some(Up),
462 WORK,
463 "Reports to",
464 "Reports to",
465 ),
466 (
467 "manages",
468 Some("reports_to"),
469 false,
470 Some(Down),
471 WORK,
472 "Manages",
473 "Manages",
474 ),
475 (
476 "assistant",
477 Some("assists"),
478 false,
479 Some(Down),
480 WORK,
481 "Assistant",
482 "Assistants",
483 ),
484 (
485 "assists",
486 Some("assistant"),
487 false,
488 Some(Up),
489 WORK,
490 "Assists",
491 "Assists",
492 ),
493 (
494 "employer",
495 Some("employee"),
496 false,
497 Some(Up),
498 WORK,
499 "Employer",
500 "Employers",
501 ),
502 (
503 "employee",
504 Some("employer"),
505 false,
506 Some(Down),
507 WORK,
508 "Employee",
509 "Employees",
510 ),
511 (
512 "colleague",
513 None,
514 true,
515 None,
516 WORK,
517 "Colleague",
518 "Colleagues",
519 ),
520 ];
521 table
522 .into_iter()
523 .map(
524 |(name, inverse, symmetric, hierarchy, category, label, label_plural)| RelationType {
525 name: name.to_string(),
526 symmetric,
527 inverse: inverse.map(str::to_string),
528 label: Some(label.to_string()),
529 label_plural: Some(label_plural.to_string()),
530 hierarchy,
531 category: Some(category.to_string()),
532 },
533 )
534 .collect()
535}
536
537impl Default for SortField {
538 fn default() -> Self {
539 Self {
540 field: "title".to_string(),
541 order: default_sort_order(),
542 compare: default_sort_compare(),
543 }
544 }
545}
546
547pub fn default_sort_config() -> Vec<SortField> {
549 vec![SortField::default()]
550}
551
552#[derive(Debug, Clone, Copy, PartialEq)]
553pub struct IpArray(pub [u8; 4]);
554
555#[derive(Debug, Deserialize, Serialize, Clone)]
556pub struct Config {
557 pub root_dir: PathBuf,
558 #[serde(skip)]
569 pub explicit_hidden_dirs: Vec<PathBuf>,
570 #[serde(skip)]
585 pub no_symlink_mounts: bool,
586 pub host: IpArray,
587 pub port: u16,
588 pub static_folder: String,
589 pub markdown_extensions: Vec<String>,
590 pub theme: String,
591 pub index_file: String,
592 pub ignore_dirs: Vec<String>,
593 pub ignore_globs: Vec<String>,
594 pub watcher_ignore_dirs: Vec<String>,
597 pub oembed_timeout_ms: u64,
602 pub oembed_cache_size: usize,
606 #[serde(default = "default_media_cache_size")]
615 pub media_cache_size: usize,
616 #[serde(default)]
619 pub template_folder: Option<PathBuf>,
620 #[serde(default = "default_sort_config")]
623 pub sort: Vec<SortField>,
624 #[serde(default)]
627 pub build_concurrency: Option<usize>,
628 #[serde(default)]
632 pub transcode: bool,
633 #[serde(default)]
637 pub skip_link_checks: bool,
638 #[serde(default = "default_link_tracking")]
644 pub link_tracking: bool,
645 #[serde(default = "default_tag_sources")]
649 pub tag_sources: Vec<TagSource>,
650 #[serde(default = "default_relationship_tracking")]
655 pub relationship_tracking: bool,
656 #[serde(default = "default_relationship_types")]
660 pub relationship_types: Vec<RelationType>,
661 #[serde(default = "default_build_tag_pages")]
666 pub build_tag_pages: bool,
667 #[serde(default = "default_sidebar_style")]
671 pub sidebar_style: String,
672 #[serde(default = "default_sidebar_max_items")]
675 pub sidebar_max_items: usize,
676 #[serde(default = "default_graph_depth")]
680 pub graph_depth: usize,
681 #[serde(default = "default_flashcards_concentric_threshold")]
685 pub flashcards_concentric_threshold: f64,
686 #[serde(default = "default_flashcards_progress_indicators")]
690 pub flashcards_progress_indicators: bool,
691 #[serde(default = "default_gui_menu_bar")]
698 pub gui_menu_bar: MenuBarVisibility,
699 #[serde(default)]
702 pub title_prefix: String,
703 #[serde(default)]
706 pub title_suffix: String,
707 #[serde(default = "default_incomplete_markers")]
714 pub incomplete_markers: Vec<String>,
715 #[serde(default)]
719 pub mark_incomplete: Option<bool>,
720 #[serde(default = "default_tasks_enabled")]
727 pub tasks_enabled: bool,
728 #[serde(default = "default_review_enabled")]
741 pub review_enabled: bool,
742 #[serde(default = "default_tasks_stamp_done")]
751 pub tasks_stamp_done: bool,
752 #[serde(default = "default_tasks_default_include")]
767 pub tasks_default_include: IncludeFilter,
768 #[serde(default)]
787 pub tasks_ignore_globs: Vec<String>,
788 #[serde(default)]
800 pub edit_enabled: bool,
801 #[serde(default)]
806 pub edit_token_hash: Option<String>,
807 #[serde(default)]
810 pub edit_require_token_on_loopback: bool,
811 #[serde(default = "default_upload_max_bytes")]
815 pub upload_max_bytes: usize,
816}
817
818impl std::fmt::Display for IpArray {
819 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
820 let [a, b, c, d] = self.0;
821 write!(f, "{a}.{b}.{c}.{d}")
822 }
823}
824
825impl<'de> Deserialize<'de> for IpArray {
826 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
827 where
828 D: serde::Deserializer<'de>,
829 {
830 let ip_str = String::deserialize(deserializer)?;
831 let ip: IpAddr = ip_str.parse().map_err(serde::de::Error::custom)?;
832
833 match ip {
834 IpAddr::V4(v4) => Ok(IpArray(v4.octets())),
835 IpAddr::V6(_) => Err(serde::de::Error::custom("IPv6 addresses are not supported")),
836 }
837 }
838}
839
840impl Serialize for IpArray {
841 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
842 where
843 S: Serializer,
844 {
845 let ip = std::net::Ipv4Addr::from(self.0);
846 serializer.serialize_str(&ip.to_string())
847 }
848}
849
850impl Default for Config {
851 fn default() -> Self {
852 Config {
853 root_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
854 explicit_hidden_dirs: Vec::new(),
855 no_symlink_mounts: false,
856 host: IpArray([127, 0, 0, 1]),
857 port: DEFAULT_PORT,
858 static_folder: "static".to_string(),
859 markdown_extensions: vec!["md".to_string()],
860 theme: "default".to_string(),
861 index_file: "index.md".to_string(),
862 ignore_dirs: [
863 "target",
864 "result",
865 "build",
866 "node_modules",
867 "ci",
868 "templates",
869 ".git",
870 ".github",
871 "dist",
872 "out",
873 "coverage",
874 ]
875 .into_iter()
876 .map(|x| x.to_string())
877 .collect(),
878 ignore_globs: [
879 "*.log", "*.bak", "*.lock", "*.sh", "*.css", "*.scss", "*.js", "*.ts",
880 ]
881 .into_iter()
882 .map(|x| x.to_string())
883 .collect(),
884 watcher_ignore_dirs: [".direnv", ".git", "result", "target", "build"]
885 .into_iter()
886 .map(|x| x.to_string())
887 .collect(),
888 oembed_timeout_ms: DEFAULT_OEMBED_TIMEOUT_MS,
889 oembed_cache_size: DEFAULT_OEMBED_CACHE_SIZE,
890 media_cache_size: DEFAULT_MEDIA_CACHE_SIZE,
891 template_folder: None,
892 sort: default_sort_config(),
893 build_concurrency: None, transcode: false, skip_link_checks: false, link_tracking: true, tag_sources: default_tag_sources(),
898 relationship_tracking: true, relationship_types: default_relationship_types(),
900 build_tag_pages: true, sidebar_style: default_sidebar_style(),
902 sidebar_max_items: default_sidebar_max_items(),
903 graph_depth: default_graph_depth(),
904 flashcards_concentric_threshold: default_flashcards_concentric_threshold(),
905 flashcards_progress_indicators: default_flashcards_progress_indicators(),
906 gui_menu_bar: default_gui_menu_bar(),
907 title_prefix: String::new(),
908 title_suffix: String::new(),
909 incomplete_markers: default_incomplete_markers(),
910 mark_incomplete: None,
911 tasks_enabled: true, review_enabled: true, tasks_stamp_done: true, tasks_default_include: default_tasks_default_include(),
915 tasks_ignore_globs: Vec::new(), edit_enabled: false,
917 edit_token_hash: None,
918 edit_require_token_on_loopback: false,
919 upload_max_bytes: DEFAULT_UPLOAD_MAX_BYTES,
920 }
921 }
922}
923
924pub(crate) fn home_dir() -> Option<PathBuf> {
931 let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
932 std::env::var_os(home_var).map(PathBuf::from)
933}
934
935fn is_home_dir(path: &Path) -> bool {
943 let Some(home) = home_dir() else {
944 return false;
945 };
946 if path == home {
947 return true;
948 }
949 match (path.canonicalize(), home.canonicalize()) {
950 (Ok(path), Ok(home)) => path == home,
951 _ => false,
952 }
953}
954
955fn contains_home_dir(path: &Path) -> bool {
968 if is_home_dir(path) {
969 return true;
970 }
971 if path.as_os_str().is_empty() {
975 return false;
976 }
977 let Some(home) = home_dir() else {
978 return false;
979 };
980 if home.starts_with(path) {
981 return true;
982 }
983 match (path.canonicalize(), home.canonicalize()) {
984 (Ok(path), Ok(home)) => home.starts_with(path),
985 _ => false,
986 }
987}
988
989pub const NO_SYMLINK_MOUNTS_ENV: &str = "MBR_NO_SYMLINK_MOUNTS";
991
992static NO_SYMLINK_MOUNTS_FOR_PROCESS: AtomicBool = AtomicBool::new(false);
995
996pub fn disable_symlink_mounts_for_process() {
1005 NO_SYMLINK_MOUNTS_FOR_PROCESS.store(true, Ordering::Relaxed);
1006}
1007
1008static EDITING_DISABLED_FOR_PROCESS: AtomicBool = AtomicBool::new(false);
1011
1012pub fn disable_editing_for_process() {
1020 EDITING_DISABLED_FOR_PROCESS.store(true, Ordering::Relaxed);
1021}
1022
1023pub fn no_symlink_mounts_from_env() -> bool {
1029 std::env::var_os(NO_SYMLINK_MOUNTS_ENV).is_some_and(|value| env_flag_is_set(&value))
1030}
1031
1032fn env_flag_is_set(value: &std::ffi::OsStr) -> bool {
1035 let value = value.to_string_lossy();
1036 !matches!(
1037 value.trim().to_ascii_lowercase().as_str(),
1038 "" | "0" | "false" | "no" | "off"
1039 )
1040}
1041
1042pub fn explicit_hidden_dirs(root_dir: &Path, target: &Path) -> Vec<PathBuf> {
1066 let Ok(relative) = target.strip_prefix(root_dir) else {
1067 return Vec::new();
1068 };
1069
1070 relative
1071 .components()
1072 .scan(PathBuf::new(), |walked, component| {
1073 walked.push(component);
1074 Some(walked.clone())
1075 })
1076 .filter(|chain| {
1077 chain
1078 .file_name()
1079 .and_then(|name| name.to_str())
1080 .is_some_and(|name| name.starts_with('.'))
1081 })
1082 .collect()
1083}
1084
1085pub fn find_root_dir(start_path: &Path) -> PathBuf {
1094 const DIR_MARKERS: &[&str] = &[".mbr", ".git", ".zk", ".obsidian"];
1095 const FILE_MARKERS: &[&str] = &["book.toml", "mkdocs.yml", "docusaurus.config.js"];
1096
1097 let dir = if start_path.is_dir() {
1098 start_path
1099 } else {
1100 start_path.parent().unwrap_or(start_path)
1101 };
1102
1103 for marker in DIR_MARKERS {
1104 if let Some(root) = dir
1105 .ancestors()
1106 .find(|a| a.join(marker).is_dir())
1107 .map(|p| p.to_path_buf())
1108 {
1109 if is_home_dir(&root) {
1113 continue;
1114 }
1115 return root;
1116 }
1117 }
1118
1119 for marker in FILE_MARKERS {
1120 if let Some(root) = dir
1121 .ancestors()
1122 .find(|a| a.join(marker).is_file())
1123 .map(|p| p.to_path_buf())
1124 {
1125 if is_home_dir(&root) {
1126 continue;
1127 }
1128 return root;
1129 }
1130 }
1131
1132 dir.to_path_buf()
1133}
1134
1135impl Config {
1136 pub fn read(search_config_from: &Path) -> Result<Self, crate::MbrError> {
1149 Self::read_with_root(
1150 find_root_dir(search_config_from),
1151 search_config_from,
1152 Config::default(),
1153 )
1154 }
1155
1156 pub fn read_for_gui(search_config_from: &Path) -> Result<Self, crate::MbrError> {
1171 Self::read_with_root(
1172 find_root_dir(search_config_from),
1173 search_config_from,
1174 Config::gui_defaults(),
1175 )
1176 }
1177
1178 pub fn gui_defaults() -> Self {
1181 Config {
1182 edit_enabled: true,
1183 ..Config::default()
1184 }
1185 }
1186
1187 pub fn read_at_root(root_dir: &Path) -> Result<Self, crate::MbrError> {
1196 Self::read_with_root(root_dir.to_path_buf(), root_dir, Config::default())
1197 }
1198
1199 fn read_with_root(
1200 root_dir: PathBuf,
1201 search_config_from: &Path,
1202 default_config: Config,
1203 ) -> Result<Self, crate::MbrError> {
1204 let explicit = Figment::new()
1207 .merge(Toml::file(root_dir.join(".mbr/config.toml")))
1208 .merge(Env::prefixed("MBR_"));
1209 let edit_enabled_is_explicit = explicit.find_value("edit_enabled").is_ok();
1210 let figment = Figment::new()
1214 .merge(Serialized::defaults(default_config))
1215 .merge(explicit);
1216 let mut config: Config = figment
1217 .extract()
1218 .map_err(|e| ConfigError::ParseFailed(Box::new(e)))?;
1219 tracing::debug!("Loaded config: {:?}", &config);
1220 if !edit_enabled_is_explicit && config.editing_needs_token() {
1221 tracing::warn!(
1222 "In-browser editing is off: it is on by default here, but host {} is not \
1223 loopback and no edit_token_hash is configured. Set edit_token_hash (see \
1224 `mbr --generate-edit-token`) or bind to 127.0.0.1 to edit.",
1225 config.host
1226 );
1227 config.edit_enabled = false;
1228 }
1229 if EDITING_DISABLED_FOR_PROCESS.load(Ordering::Relaxed) {
1230 config.edit_enabled = false;
1231 }
1232 config.root_dir = root_dir;
1233 config.explicit_hidden_dirs = explicit_hidden_dirs(&config.root_dir, search_config_from);
1238 config.no_symlink_mounts =
1241 no_symlink_mounts_from_env() || NO_SYMLINK_MOUNTS_FOR_PROCESS.load(Ordering::Relaxed);
1242 config.reject_repo_supplied_absolute_static_folder(&figment)?;
1243 config.validate()?;
1244 config.log_external_static_folder();
1245 Ok(config)
1246 }
1247
1248 fn reject_repo_supplied_absolute_static_folder(
1260 &self,
1261 figment: &Figment,
1262 ) -> Result<(), ConfigError> {
1263 if !is_rooted(Path::new(&self.static_folder)) {
1264 return Ok(());
1265 }
1266
1267 let from_file = figment
1268 .find_metadata("static_folder")
1269 .and_then(|metadata| metadata.source.as_ref())
1270 .and_then(figment::Source::file_path);
1271
1272 match from_file {
1273 Some(source) => Err(invalid_static_folder(
1274 &self.static_folder,
1275 &format!(
1276 "an absolute path is only honored from the MBR_STATIC_FOLDER environment \
1277 variable, not from a repository config file ({})",
1278 source.display()
1279 ),
1280 )),
1281 None => Ok(()),
1282 }
1283 }
1284
1285 fn log_external_static_folder(&self) {
1293 if let Ok(StaticOverlay::External(dir)) =
1294 resolve_static_overlay(&self.root_dir, &self.static_folder)
1295 {
1296 let within_peer = self
1297 .root_dir
1298 .parent()
1299 .is_some_and(|parent| dir.starts_with(parent));
1300 if within_peer {
1301 tracing::info!(
1302 "static_folder {:?} resolves outside the markdown root ({}): serving and indexing assets from {}",
1303 self.static_folder,
1304 self.root_dir.display(),
1305 dir.display()
1306 );
1307 } else {
1308 tracing::warn!(
1309 "static_folder {:?} resolves two levels above the markdown root ({}): serving and indexing assets from {}",
1310 self.static_folder,
1311 self.root_dir.display(),
1312 dir.display()
1313 );
1314 }
1315 }
1316 }
1317
1318 pub fn validate(&self) -> Result<(), ConfigError> {
1333 self.validate_static_folder()?;
1334
1335 if self.port == 0 {
1337 return Err(ConfigError::InvalidPort { port: self.port });
1338 }
1339
1340 if self.sidebar_max_items == 0 {
1342 return Err(ConfigError::InvalidSidebarMaxItems {
1343 value: self.sidebar_max_items,
1344 });
1345 }
1346
1347 if !(1..=5).contains(&self.graph_depth) {
1350 return Err(ConfigError::InvalidGraphDepth {
1351 value: self.graph_depth,
1352 });
1353 }
1354
1355 if !(self.flashcards_concentric_threshold > 0.0
1358 && self.flashcards_concentric_threshold <= 1.0)
1359 {
1360 return Err(ConfigError::InvalidFlashcardsConcentricThreshold {
1361 value: self.flashcards_concentric_threshold,
1362 });
1363 }
1364
1365 if matches!(self.build_concurrency, Some(0)) {
1367 return Err(ConfigError::InvalidBuildConcurrency { value: 0 });
1368 }
1369
1370 if let Some((pattern, reason)) = self.tasks_ignore_globs.iter().find_map(|pattern| {
1375 glob::Pattern::new(pattern)
1376 .err()
1377 .map(|e| (pattern.clone(), e.to_string()))
1378 }) {
1379 return Err(ConfigError::InvalidTasksIgnoreGlob { pattern, reason });
1380 }
1381
1382 if self.editing_needs_token() {
1385 return Err(ConfigError::EditingRequiresToken);
1386 }
1387
1388 Ok(())
1389 }
1390
1391 pub fn editing_needs_token(&self) -> bool {
1395 self.edit_enabled
1396 && !std::net::Ipv4Addr::from(self.host.0).is_loopback()
1397 && self.edit_token_hash.is_none()
1398 }
1399
1400 fn validate_static_folder(&self) -> Result<(), ConfigError> {
1404 resolve_static_overlay(&self.root_dir, &self.static_folder).map(|_| ())
1405 }
1406}
1407
1408#[derive(Debug, Clone, PartialEq, Eq)]
1410pub enum StaticOverlay {
1411 WithinRoot,
1413 External(PathBuf),
1416}
1417
1418pub fn resolve_static_overlay(
1465 root_dir: &Path,
1466 static_folder: &str,
1467) -> Result<StaticOverlay, ConfigError> {
1468 if static_folder.is_empty() {
1469 return Ok(StaticOverlay::WithinRoot);
1470 }
1471
1472 let root = resolve_existing_or_lexical(root_dir);
1473 let dir = resolve_existing_or_lexical(&root.join(static_folder));
1476
1477 if dir.starts_with(&root) {
1478 return Ok(StaticOverlay::WithinRoot);
1479 }
1480 if is_rooted(Path::new(static_folder)) {
1481 return Ok(StaticOverlay::External(dir));
1482 }
1483
1484 let (anchor, stop) = static_folder_anchor(&root);
1485
1486 if !dir.starts_with(&anchor) {
1490 return Err(invalid_static_folder(static_folder, &stop.refusal(&anchor)));
1491 }
1492 if dir == anchor {
1496 return Err(invalid_static_folder(
1497 static_folder,
1498 &ExternalFolderRefusal::ContainsRoot(root.clone()).to_string(),
1499 ));
1500 }
1501 if let Some(refusal) = external_folder_refusal(&dir, &[&root]) {
1507 return Err(invalid_static_folder(static_folder, &refusal.to_string()));
1508 }
1509
1510 Ok(StaticOverlay::External(dir))
1511}
1512
1513#[derive(Debug, Clone, PartialEq, Eq)]
1517pub enum ExternalFolderRefusal {
1518 FilesystemRoot,
1520 HomeDir,
1522 ContainsRoot(PathBuf),
1526 Hidden(String),
1529}
1530
1531impl std::fmt::Display for ExternalFolderRefusal {
1532 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1533 match self {
1534 Self::FilesystemRoot => write!(f, "it is the filesystem root"),
1535 Self::HomeDir => write!(f, "it is the home directory, or contains it"),
1536 Self::ContainsRoot(root) => write!(
1537 f,
1538 "resolves to a directory that contains the markdown root ({}), which would \
1539 expose every sibling of the root — and the markdown source itself; name a \
1540 specific directory instead",
1541 root.display()
1542 ),
1543 Self::Hidden(name) => write!(
1544 f,
1545 "it is inside the hidden directory {name:?}; hidden files are never served"
1546 ),
1547 }
1548 }
1549}
1550
1551pub fn external_folder_refusal(
1575 dir: &Path,
1576 protected_roots: &[&Path],
1577) -> Option<ExternalFolderRefusal> {
1578 let home = home_dir().map(|home| home.canonicalize().unwrap_or(home));
1579 external_folder_refusal_with_home(dir, protected_roots, home.as_deref())
1580}
1581
1582pub fn external_folder_refusal_with_home(
1585 dir: &Path,
1586 protected_roots: &[&Path],
1587 home: Option<&Path>,
1588) -> Option<ExternalFolderRefusal> {
1589 if dir.parent().is_none() {
1590 return Some(ExternalFolderRefusal::FilesystemRoot);
1591 }
1592 if home.is_some_and(|home| home.starts_with(dir)) {
1593 return Some(ExternalFolderRefusal::HomeDir);
1594 }
1595 if let Some(root) = protected_roots.iter().find(|root| root.starts_with(dir)) {
1596 return Some(ExternalFolderRefusal::ContainsRoot(root.to_path_buf()));
1597 }
1598 let shared = protected_roots.first().map_or(0, |root| {
1599 dir.components()
1600 .zip(root.components())
1601 .take_while(|(a, b)| a == b)
1602 .count()
1603 });
1604 dir.components()
1605 .skip(shared)
1606 .filter_map(|component| match component {
1607 std::path::Component::Normal(name) => name.to_str(),
1608 _ => None,
1609 })
1610 .find(|name| name.starts_with('.'))
1611 .map(|name| ExternalFolderRefusal::Hidden(name.to_string()))
1612}
1613
1614const MAX_STATIC_FOLDER_ASCENT: usize = 2;
1627
1628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1631enum AscentStop {
1632 Budget,
1635 RootHasNoParent,
1637 FilesystemRoot,
1639 HomeDir,
1641}
1642
1643impl AscentStop {
1644 fn refusal(self, anchor: &Path) -> String {
1650 match self {
1651 Self::RootHasNoParent => {
1652 "resolves outside the markdown root, which has no parent directory".to_string()
1653 }
1654 Self::HomeDir => format!(
1655 "resolves outside {}, and the next directory up is the home directory",
1656 anchor.display()
1657 ),
1658 Self::FilesystemRoot => format!(
1659 "resolves outside {}, and the next directory up is the filesystem root",
1660 anchor.display()
1661 ),
1662 Self::Budget => format!(
1663 "resolves outside {}; an overlay may reach at most \
1664 {MAX_STATIC_FOLDER_ASCENT} levels above the markdown root",
1665 anchor.display()
1666 ),
1667 }
1668 }
1669}
1670
1671fn static_folder_anchor(root: &Path) -> (PathBuf, AscentStop) {
1680 let mut anchor = root;
1681 for _ in 0..MAX_STATIC_FOLDER_ASCENT {
1682 let Some(parent) = anchor.parent() else {
1685 return (anchor.to_path_buf(), AscentStop::RootHasNoParent);
1686 };
1687 if parent.parent().is_none() {
1692 return (anchor.to_path_buf(), AscentStop::FilesystemRoot);
1693 }
1694 if contains_home_dir(parent) {
1695 return (anchor.to_path_buf(), AscentStop::HomeDir);
1696 }
1697 anchor = parent;
1698 }
1699 (anchor.to_path_buf(), AscentStop::Budget)
1700}
1701
1702fn is_rooted(path: &Path) -> bool {
1708 use std::path::Component;
1709 matches!(
1710 path.components().next(),
1711 Some(Component::RootDir | Component::Prefix(_))
1712 )
1713}
1714
1715pub(crate) fn resolve_existing_or_lexical(path: &Path) -> PathBuf {
1718 path.canonicalize()
1719 .unwrap_or_else(|_| lexically_normalize(path))
1720}
1721
1722fn lexically_normalize(path: &Path) -> PathBuf {
1728 use std::path::Component;
1729 path.components()
1730 .fold(PathBuf::new(), |mut normalized, component| {
1731 match component {
1732 Component::CurDir => {}
1733 Component::ParentDir => {
1734 if !normalized.pop() && !is_rooted(&normalized) {
1738 normalized.push(Component::ParentDir);
1739 }
1740 }
1741 other => normalized.push(other),
1742 }
1743 normalized
1744 })
1745}
1746
1747fn invalid_static_folder(value: &str, reason: &str) -> ConfigError {
1754 ConfigError::ParseFailed(Box::new(figment::Error::from(format!(
1755 "Invalid static_folder {value:?}: {reason}"
1756 ))))
1757}
1758
1759#[cfg(test)]
1760mod tests {
1761 use super::*;
1762
1763 #[test]
1764 fn test_title_case() {
1765 assert_eq!(title_case("tags"), "Tag");
1766 assert_eq!(title_case("performers"), "Performer");
1767 assert_eq!(title_case("category"), "Category");
1768 assert_eq!(title_case("Tag"), "Tag");
1769 assert_eq!(title_case("s"), "S");
1770 assert_eq!(title_case(""), "");
1771 }
1772
1773 #[test]
1774 fn test_tag_source_singular_label_explicit() {
1775 let source = TagSource {
1776 field: "taxonomy.performers".to_string(),
1777 label: Some("Performer".to_string()),
1778 label_plural: None,
1779 };
1780 assert_eq!(source.singular_label(), "Performer");
1781 }
1782
1783 #[test]
1784 fn test_tag_source_singular_label_derived() {
1785 let source = TagSource {
1786 field: "tags".to_string(),
1787 label: None,
1788 label_plural: None,
1789 };
1790 assert_eq!(source.singular_label(), "Tag");
1791 }
1792
1793 #[test]
1794 fn test_tag_source_singular_label_derived_nested() {
1795 let source = TagSource {
1796 field: "taxonomy.performers".to_string(),
1797 label: None,
1798 label_plural: None,
1799 };
1800 assert_eq!(source.singular_label(), "Performer");
1801 }
1802
1803 #[test]
1804 fn test_tag_source_plural_label_explicit() {
1805 let source = TagSource {
1806 field: "taxonomy.performers".to_string(),
1807 label: None,
1808 label_plural: Some("Performers".to_string()),
1809 };
1810 assert_eq!(source.plural_label(), "Performers");
1811 }
1812
1813 #[test]
1814 fn test_tag_source_plural_label_derived() {
1815 let source = TagSource {
1816 field: "tags".to_string(),
1817 label: None,
1818 label_plural: None,
1819 };
1820 assert_eq!(source.plural_label(), "Tags");
1821 }
1822
1823 #[test]
1824 fn test_tag_source_url_source() {
1825 let source = TagSource {
1826 field: "Tags".to_string(),
1827 label: None,
1828 label_plural: None,
1829 };
1830 assert_eq!(source.url_source(), "tags");
1831
1832 let source = TagSource {
1833 field: "taxonomy.Performers".to_string(),
1834 label: None,
1835 label_plural: None,
1836 };
1837 assert_eq!(source.url_source(), "taxonomy.performers");
1838 }
1839
1840 #[test]
1841 fn test_default_tag_sources() {
1842 let sources = default_tag_sources();
1843 assert_eq!(sources.len(), 1);
1844 assert_eq!(sources[0].field, "tags");
1845 assert_eq!(sources[0].singular_label(), "Tag");
1846 assert_eq!(sources[0].plural_label(), "Tags");
1847 assert_eq!(sources[0].url_source(), "tags");
1848 }
1849
1850 #[test]
1851 fn test_config_default_has_tag_sources() {
1852 let config = Config::default();
1853 assert_eq!(config.tag_sources.len(), 1);
1854 assert_eq!(config.tag_sources[0].field, "tags");
1855 assert!(config.build_tag_pages);
1856 }
1857
1858 #[test]
1859 fn test_config_default_has_relationship_types() {
1860 let config = Config::default();
1861 assert!(config.relationship_tracking);
1862 let names: Vec<&str> = config
1863 .relationship_types
1864 .iter()
1865 .map(|t| t.name.as_str())
1866 .collect();
1867 assert_eq!(
1868 names,
1869 [
1870 "parent",
1871 "child",
1872 "spouse",
1873 "sibling",
1874 "reports_to",
1875 "manages",
1876 "assistant",
1877 "assists",
1878 "employer",
1879 "employee",
1880 "colleague",
1881 ]
1882 );
1883 }
1884
1885 #[test]
1886 fn test_default_relationship_types_semantics() {
1887 let types = default_relationship_types();
1888 let parent = types.iter().find(|t| t.name == "parent").unwrap();
1889 assert!(!parent.symmetric);
1890 assert_eq!(parent.inverse.as_deref(), Some("child"));
1891 assert_eq!(parent.plural_label(), "Parents");
1892
1893 let child = types.iter().find(|t| t.name == "child").unwrap();
1894 assert_eq!(child.inverse.as_deref(), Some("parent"));
1895 assert_eq!(child.plural_label(), "Children");
1896
1897 let spouse = types.iter().find(|t| t.name == "spouse").unwrap();
1898 assert!(spouse.symmetric);
1899 assert!(spouse.inverse.is_none());
1900 }
1901
1902 #[test]
1905 fn test_default_relationship_types_pairs_are_coherent() {
1906 let types = default_relationship_types();
1907 let by_name = |n: &str| types.iter().find(|t| t.name == n).unwrap();
1908 for t in &types {
1909 assert!(t.category.is_some(), "{} has no category", t.name);
1910 if t.symmetric {
1911 assert!(t.hierarchy.is_none(), "{} is symmetric", t.name);
1912 continue;
1913 }
1914 let inverse = by_name(t.inverse.as_deref().expect("inverse"));
1915 assert_eq!(inverse.inverse.as_deref(), Some(t.name.as_str()));
1916 assert_eq!(inverse.hierarchy, t.hierarchy.map(Hierarchy::flipped));
1917 assert_eq!(inverse.category, t.category);
1918 }
1919 assert_eq!(by_name("reports_to").hierarchy, Some(Hierarchy::Up));
1920 assert_eq!(by_name("assistant").hierarchy, Some(Hierarchy::Down));
1921 assert_eq!(by_name("employer").category.as_deref(), Some("work"));
1922 assert_eq!(by_name("parent").category.as_deref(), Some("family"));
1923 assert_eq!(by_name("reports_to").plural_label(), "Reports to");
1924 }
1925
1926 #[test]
1927 fn test_relation_type_hierarchy_and_category_deserialize() {
1928 let json =
1929 r#"{"name": "mentor", "inverse": "mentee", "hierarchy": "up", "category": "work"}"#;
1930 let rel: RelationType = serde_json::from_str(json).unwrap();
1931 assert_eq!(rel.hierarchy, Some(Hierarchy::Up));
1932 assert_eq!(rel.category.as_deref(), Some("work"));
1933
1934 let bad = r#"{"name": "mentor", "hierarchy": "sideways"}"#;
1935 assert!(serde_json::from_str::<RelationType>(bad).is_err());
1936 }
1937
1938 #[test]
1939 fn test_relation_type_label_derivation() {
1940 let rel = RelationType {
1941 name: "cousin".to_string(),
1942 symmetric: true,
1943 inverse: None,
1944 label: None,
1945 label_plural: None,
1946 hierarchy: None,
1947 category: None,
1948 };
1949 assert_eq!(rel.singular_label(), "Cousin");
1951 assert_eq!(rel.plural_label(), "Cousins");
1952 }
1953
1954 #[test]
1955 fn test_relation_type_deserialization_minimal() {
1956 let json = r#"{"name": "friend", "symmetric": true}"#;
1957 let rel: RelationType = serde_json::from_str(json).unwrap();
1958 assert_eq!(rel.name, "friend");
1959 assert!(rel.symmetric);
1960 assert!(rel.inverse.is_none());
1961 assert!(rel.label.is_none());
1962 }
1963
1964 #[test]
1965 fn test_tag_source_serialization() {
1966 let source = TagSource {
1967 field: "taxonomy.tags".to_string(),
1968 label: Some("Tag".to_string()),
1969 label_plural: Some("Tags".to_string()),
1970 };
1971
1972 let json = serde_json::to_string(&source).unwrap();
1973 let parsed: TagSource = serde_json::from_str(&json).unwrap();
1974 assert_eq!(source, parsed);
1975 }
1976
1977 #[test]
1978 fn test_tag_source_deserialization_minimal() {
1979 let json = r#"{"field": "tags"}"#;
1980 let source: TagSource = serde_json::from_str(json).unwrap();
1981 assert_eq!(source.field, "tags");
1982 assert!(source.label.is_none());
1983 assert!(source.label_plural.is_none());
1984 }
1985
1986 #[test]
1989 fn test_validate_default_config_passes() {
1990 let config = Config::default();
1991 assert!(config.validate().is_ok());
1992 }
1993
1994 #[test]
1995 fn test_validate_port_zero_fails() {
1996 let config = Config {
1997 port: 0,
1998 ..Default::default()
1999 };
2000 let result = config.validate();
2001 assert!(result.is_err());
2002 let err = result.unwrap_err();
2003 assert!(matches!(err, ConfigError::InvalidPort { port: 0 }));
2004 }
2005
2006 #[test]
2007 fn test_validate_valid_ports_pass() {
2008 let config = Config {
2010 port: 1,
2011 ..Default::default()
2012 };
2013 assert!(config.validate().is_ok());
2014
2015 let config = Config {
2017 port: 80,
2018 ..Default::default()
2019 };
2020 assert!(config.validate().is_ok());
2021
2022 let config = Config {
2023 port: 443,
2024 ..Default::default()
2025 };
2026 assert!(config.validate().is_ok());
2027
2028 let config = Config {
2030 port: 65535,
2031 ..Default::default()
2032 };
2033 assert!(config.validate().is_ok());
2034 }
2035
2036 #[test]
2037 fn test_validate_sidebar_max_items_zero_fails() {
2038 let config = Config {
2039 sidebar_max_items: 0,
2040 ..Default::default()
2041 };
2042 let result = config.validate();
2043 assert!(result.is_err());
2044 let err = result.unwrap_err();
2045 assert!(matches!(
2046 err,
2047 ConfigError::InvalidSidebarMaxItems { value: 0 }
2048 ));
2049 }
2050
2051 #[test]
2052 fn test_validate_valid_sidebar_max_items_pass() {
2053 let config = Config {
2054 sidebar_max_items: 1,
2055 ..Default::default()
2056 };
2057 assert!(config.validate().is_ok());
2058
2059 let config = Config {
2060 sidebar_max_items: 100,
2061 ..Default::default()
2062 };
2063 assert!(config.validate().is_ok());
2064
2065 let config = Config {
2066 sidebar_max_items: 10000,
2067 ..Default::default()
2068 };
2069 assert!(config.validate().is_ok());
2070 }
2071
2072 #[test]
2073 fn test_default_gui_menu_bar_is_auto() {
2074 let config = Config::default();
2075 assert_eq!(config.gui_menu_bar, MenuBarVisibility::Auto);
2076 }
2077
2078 #[test]
2081 fn test_gui_menu_bar_parses_each_variant() {
2082 for (text, expected) in [
2083 ("auto", MenuBarVisibility::Auto),
2084 ("always", MenuBarVisibility::Always),
2085 ("never", MenuBarVisibility::Never),
2086 ] {
2087 let parsed: MenuBarVisibility = serde_json::from_str(&format!("\"{text}\"")).unwrap();
2088 assert_eq!(parsed, expected, "parsing {text}");
2089 }
2090 }
2091
2092 #[test]
2093 fn test_gui_menu_bar_rejects_unknown_value() {
2094 let parsed: Result<MenuBarVisibility, _> = serde_json::from_str("\"sometimes\"");
2095 assert!(parsed.is_err(), "an unknown visibility must not parse");
2096 }
2097
2098 #[test]
2101 fn test_gui_menu_bar_from_config_file() {
2102 let config: Config = Figment::from(Serialized::defaults(Config::default()))
2103 .merge(Toml::string("gui_menu_bar = \"always\""))
2104 .extract()
2105 .unwrap();
2106 assert_eq!(config.gui_menu_bar, MenuBarVisibility::Always);
2107 }
2108
2109 #[test]
2110 fn test_default_graph_depth_is_2() {
2111 let config = Config::default();
2112 assert_eq!(config.graph_depth, 2);
2113 }
2114
2115 #[test]
2116 fn test_validate_graph_depth_zero_fails() {
2117 let config = Config {
2118 graph_depth: 0,
2119 ..Default::default()
2120 };
2121 let result = config.validate();
2122 assert!(result.is_err());
2123 let err = result.unwrap_err();
2124 assert!(matches!(err, ConfigError::InvalidGraphDepth { value: 0 }));
2125 }
2126
2127 #[test]
2128 fn test_validate_graph_depth_six_fails() {
2129 let config = Config {
2130 graph_depth: 6,
2131 ..Default::default()
2132 };
2133 let result = config.validate();
2134 assert!(result.is_err());
2135 let err = result.unwrap_err();
2136 assert!(matches!(err, ConfigError::InvalidGraphDepth { value: 6 }));
2137 }
2138
2139 #[test]
2140 fn test_validate_graph_depth_bounds_pass() {
2141 let config = Config {
2142 graph_depth: 1,
2143 ..Default::default()
2144 };
2145 assert!(config.validate().is_ok());
2146
2147 let config = Config {
2148 graph_depth: 5,
2149 ..Default::default()
2150 };
2151 assert!(config.validate().is_ok());
2152 }
2153
2154 #[test]
2155 fn test_default_flashcards_options() {
2156 let config = Config::default();
2157 assert!((config.flashcards_concentric_threshold - 0.7).abs() < f64::EPSILON);
2158 assert!(config.flashcards_progress_indicators);
2159 }
2160
2161 #[test]
2162 fn test_validate_flashcards_concentric_threshold_bounds() {
2163 for bad in [0.0, -0.5, 1.01, f64::NAN, f64::INFINITY] {
2164 let config = Config {
2165 flashcards_concentric_threshold: bad,
2166 ..Default::default()
2167 };
2168 assert!(
2169 matches!(
2170 config.validate(),
2171 Err(ConfigError::InvalidFlashcardsConcentricThreshold { .. })
2172 ),
2173 "{bad} must be rejected"
2174 );
2175 }
2176 for good in [0.01, 0.5, 1.0] {
2177 let config = Config {
2178 flashcards_concentric_threshold: good,
2179 ..Default::default()
2180 };
2181 assert!(config.validate().is_ok(), "{good} must be accepted");
2182 }
2183 }
2184
2185 #[test]
2186 fn test_config_read_flashcards_options_from_toml() {
2187 let _guard = env_lock();
2188 let repo = repo_with_mbr_dir(Some(
2189 "flashcards_concentric_threshold = 0.85\nflashcards_progress_indicators = false\n",
2190 ));
2191
2192 let config = Config::read(repo.path()).expect("toml must load");
2193
2194 assert!((config.flashcards_concentric_threshold - 0.85).abs() < f64::EPSILON);
2195 assert!(!config.flashcards_progress_indicators);
2196 }
2197
2198 #[test]
2199 fn test_config_read_flashcards_options_from_env() {
2200 let _guard = env_lock();
2201 let repo = repo_with_mbr_dir(Some("flashcards_concentric_threshold = 0.9\n"));
2202 let _env = EnvVars::set(&[
2203 ("MBR_FLASHCARDS_CONCENTRIC_THRESHOLD", "0.6"),
2204 ("MBR_FLASHCARDS_PROGRESS_INDICATORS", "false"),
2205 ]);
2206
2207 let config = Config::read(repo.path()).expect("env must load");
2208
2209 assert!((config.flashcards_concentric_threshold - 0.6).abs() < f64::EPSILON);
2210 assert!(!config.flashcards_progress_indicators);
2211 }
2212
2213 #[test]
2214 fn test_config_read_rejects_out_of_range_flashcards_threshold() {
2215 let _guard = env_lock();
2216 let repo = repo_with_mbr_dir(Some("flashcards_concentric_threshold = 1.5\n"));
2217
2218 let err = Config::read(repo.path()).expect_err("1.5 must abort loading");
2219
2220 assert!(
2221 matches!(
2222 err,
2223 crate::MbrError::Config(ConfigError::InvalidFlashcardsConcentricThreshold { .. })
2224 ),
2225 "got: {err:?}"
2226 );
2227 }
2228
2229 #[test]
2230 fn test_validate_build_concurrency_zero_fails() {
2231 let config = Config {
2232 build_concurrency: Some(0),
2233 ..Default::default()
2234 };
2235 let result = config.validate();
2236 assert!(result.is_err());
2237 let err = result.unwrap_err();
2238 assert!(matches!(
2239 err,
2240 ConfigError::InvalidBuildConcurrency { value: 0 }
2241 ));
2242 }
2243
2244 #[test]
2245 fn test_validate_build_concurrency_none_passes() {
2246 let config = Config {
2247 build_concurrency: None, ..Default::default()
2249 };
2250 assert!(config.validate().is_ok());
2251 }
2252
2253 #[test]
2254 fn test_validate_valid_build_concurrency_pass() {
2255 let config = Config {
2256 build_concurrency: Some(1),
2257 ..Default::default()
2258 };
2259 assert!(config.validate().is_ok());
2260
2261 let config = Config {
2262 build_concurrency: Some(8),
2263 ..Default::default()
2264 };
2265 assert!(config.validate().is_ok());
2266
2267 let config = Config {
2268 build_concurrency: Some(32),
2269 ..Default::default()
2270 };
2271 assert!(config.validate().is_ok());
2272 }
2273
2274 #[test]
2275 fn test_default_title_prefix_empty() {
2276 let config = Config::default();
2277 assert_eq!(config.title_prefix, "");
2278 }
2279
2280 #[test]
2281 fn test_default_title_suffix_empty() {
2282 let config = Config::default();
2283 assert_eq!(config.title_suffix, "");
2284 }
2285
2286 #[test]
2287 fn test_validate_editing_non_loopback_without_token_fails() {
2288 let config = Config {
2289 edit_enabled: true,
2290 host: IpArray([0, 0, 0, 0]),
2291 edit_token_hash: None,
2292 ..Default::default()
2293 };
2294 assert!(matches!(
2295 config.validate(),
2296 Err(ConfigError::EditingRequiresToken)
2297 ));
2298 }
2299
2300 #[test]
2301 fn test_validate_editing_non_loopback_with_token_passes() {
2302 let config = Config {
2303 edit_enabled: true,
2304 host: IpArray([0, 0, 0, 0]),
2305 edit_token_hash: Some("$argon2id$fake".to_string()),
2306 ..Default::default()
2307 };
2308 assert!(config.validate().is_ok());
2309 }
2310
2311 #[test]
2312 fn test_validate_editing_loopback_without_token_passes() {
2313 let config = Config {
2314 edit_enabled: true,
2315 host: IpArray([127, 0, 0, 1]),
2316 edit_token_hash: None,
2317 ..Default::default()
2318 };
2319 assert!(config.validate().is_ok());
2320 }
2321
2322 #[test]
2323 fn test_validate_oembed_cache_size_zero_is_valid() {
2324 let config = Config {
2326 oembed_cache_size: 0,
2327 ..Default::default()
2328 };
2329 assert!(config.validate().is_ok());
2330 }
2331
2332 #[test]
2335 fn test_media_cache_size_default_is_independent_of_oembed() {
2336 let config = Config::default();
2337 assert_eq!(config.media_cache_size, 64 * 1024 * 1024);
2338 assert_ne!(
2339 config.media_cache_size, config.oembed_cache_size,
2340 "media cache must not inherit the oembed text-metadata budget"
2341 );
2342 }
2343
2344 #[test]
2347 fn test_disabling_oembed_cache_keeps_media_cache_enabled() {
2348 let config = Config {
2349 oembed_cache_size: 0,
2350 ..Default::default()
2351 };
2352 assert!(config.validate().is_ok());
2353 assert_eq!(config.media_cache_size, 64 * 1024 * 1024);
2354 }
2355
2356 #[test]
2359 fn test_media_cache_size_config_file_layering() {
2360 let without: Config = Figment::new()
2361 .merge(Serialized::defaults(Config::default()))
2362 .merge(Toml::string("port = 5201"))
2363 .extract()
2364 .expect("config without the key parses");
2365 assert_eq!(without.media_cache_size, 64 * 1024 * 1024);
2366
2367 let with: Config = Figment::new()
2368 .merge(Serialized::defaults(Config::default()))
2369 .merge(Toml::string("media_cache_size = 1048576"))
2370 .extract()
2371 .expect("explicit value parses");
2372 assert_eq!(with.media_cache_size, 1024 * 1024);
2373 }
2374
2375 #[test]
2378 fn test_find_root_dir_file_without_markers_returns_parent() {
2379 let tmp = tempfile::tempdir().unwrap();
2381 let file_path = tmp.path().join("test.md");
2382 std::fs::write(&file_path, "# Hello").unwrap();
2383
2384 let root = find_root_dir(&file_path);
2385
2386 assert!(
2388 root.is_dir(),
2389 "root_dir should be a directory, got: {root:?}"
2390 );
2391 assert_eq!(root, tmp.path());
2392 }
2393
2394 #[test]
2395 fn test_find_root_dir_directory_without_markers_returns_itself() {
2396 let tmp = tempfile::tempdir().unwrap();
2397
2398 let root = find_root_dir(tmp.path());
2399
2400 assert!(root.is_dir());
2401 assert!(
2404 root.is_dir(),
2405 "root_dir should be a directory, got: {root:?}"
2406 );
2407 }
2408
2409 #[test]
2410 fn test_find_root_dir_with_git_marker_returns_marker_parent() {
2411 let tmp = tempfile::tempdir().unwrap();
2413 let nested = tmp.path().join("sub").join("dir");
2414 std::fs::create_dir_all(&nested).unwrap();
2415 std::fs::create_dir(tmp.path().join(".git")).unwrap();
2416
2417 let file_path = nested.join("test.md");
2418 std::fs::write(&file_path, "# Hello").unwrap();
2419
2420 let root = find_root_dir(&file_path);
2421
2422 assert_eq!(root, tmp.path().to_path_buf());
2424 assert!(root.is_dir());
2425 }
2426
2427 #[test]
2428 fn test_is_home_dir() {
2429 let _guard = env_lock();
2432 if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
2433 assert!(is_home_dir(&home));
2434 assert!(!is_home_dir(Path::new("/tmp")));
2435 }
2436 }
2437
2438 fn peer_layout() -> (tempfile::TempDir, PathBuf) {
2450 let tmp = tempfile::tempdir().unwrap();
2451 let root = tmp.path().join("project/content");
2452 std::fs::create_dir_all(&root).unwrap();
2453 std::fs::create_dir_all(tmp.path().join("project/static")).unwrap();
2454 (tmp, root)
2455 }
2456
2457 fn config_with_static(root: &Path, static_folder: &str) -> Config {
2458 Config {
2459 root_dir: root.to_path_buf(),
2460 static_folder: static_folder.to_string(),
2461 ..Default::default()
2462 }
2463 }
2464
2465 #[test]
2474 fn test_validate_static_folder_parent_escape_fails() {
2475 let (_tmp, root) = peer_layout();
2476 for value in [
2477 "..",
2478 "../..",
2479 "../../../assets",
2480 "assets/../../../..",
2481 "assets/../../..",
2482 ] {
2483 let err = match config_with_static(&root, value).validate() {
2484 Err(err) => err,
2485 Ok(()) => panic!("escaping static_folder must be rejected: {value:?}"),
2486 };
2487 assert!(
2488 format!("{err:?}").contains("static_folder"),
2489 "error should name static_folder, got: {err:?}"
2490 );
2491 }
2492 }
2493
2494 #[test]
2499 fn test_validate_static_folder_into_home_fails() {
2500 let _guard = env_lock();
2501 let home = tempfile::tempdir().unwrap();
2502 let home_str = home.path().to_string_lossy().into_owned();
2506 let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
2507
2508 let root = home.path().join("notes");
2509 std::fs::create_dir(&root).unwrap();
2510 std::fs::create_dir(home.path().join(".ssh")).unwrap();
2511
2512 for value in ["..", "../.ssh"] {
2513 assert!(
2514 config_with_static(&root, value).validate().is_err(),
2515 "static_folder {value:?} resolving into $HOME must be rejected"
2516 );
2517 }
2518
2519 std::fs::create_dir(root.join("static")).unwrap();
2522 assert!(
2523 config_with_static(&root, "static").validate().is_ok(),
2524 "an in-root static folder must still work under $HOME"
2525 );
2526
2527 let nested = home.path().join("project/content");
2531 std::fs::create_dir_all(&nested).unwrap();
2532 std::fs::create_dir(home.path().join("project/static")).unwrap();
2533 assert!(
2534 config_with_static(&nested, "../static").validate().is_ok(),
2535 "a peer overlay one level below $HOME must still be accepted"
2536 );
2537 for value in ["../../static", "../../.ssh", "../.."] {
2538 assert!(
2539 config_with_static(&nested, value).validate().is_err(),
2540 "static_folder {value:?} must not climb through $HOME"
2541 );
2542 }
2543 }
2544
2545 #[test]
2549 fn test_validate_static_folder_filesystem_root_boundary_fails() {
2550 let (root, nested) = if cfg!(windows) {
2551 (r"C:\notes", r"C:\srv\notes")
2552 } else {
2553 ("/notes", "/srv/notes")
2554 };
2555 assert!(
2556 config_with_static(Path::new(root), "../etc")
2557 .validate()
2558 .is_err(),
2559 "a root whose parent is the filesystem root must not reach outside itself"
2560 );
2561 assert!(
2562 config_with_static(Path::new(nested), "../../etc")
2563 .validate()
2564 .is_err(),
2565 "the climb must stop below the filesystem root rather than spending its budget"
2566 );
2567 }
2568
2569 #[test]
2576 fn test_validate_static_folder_absolute_is_deferred_to_provenance() {
2577 assert!(is_rooted(Path::new("/etc")), "`/etc` is rooted everywhere");
2578 assert!(
2579 is_rooted(Path::new(r"C:\etc")) || !cfg!(windows),
2580 "a drive-prefixed path is rooted on Windows"
2581 );
2582 assert!(!is_rooted(Path::new("static")));
2583 assert!(!is_rooted(Path::new("../static")));
2584
2585 let (_tmp, root) = peer_layout();
2586 let absolute = if cfg!(windows) { r"C:\etc" } else { "/etc" };
2587 for value in [absolute, "/etc"] {
2588 assert!(
2589 config_with_static(&root, value).validate().is_ok(),
2590 "validate must defer an absolute static_folder {value:?} to provenance"
2591 );
2592 }
2593 }
2594
2595 #[cfg(unix)]
2600 #[test]
2601 fn test_validate_static_folder_symlink_escape_fails() {
2602 let unrelated = tempfile::tempdir().unwrap();
2603 let secrets = unrelated.path().join("secrets");
2604 std::fs::create_dir(&secrets).unwrap();
2605
2606 let (_tmp, root) = peer_layout();
2607 std::os::unix::fs::symlink(&secrets, root.join("static")).unwrap();
2608
2609 assert!(
2610 config_with_static(&root, "static").validate().is_err(),
2611 "a static_folder symlinked past the peer boundary must be rejected"
2612 );
2613 }
2614
2615 #[cfg(unix)]
2619 #[test]
2620 fn test_validate_static_folder_symlink_to_peer_passes() {
2621 let (tmp, root) = peer_layout();
2622 std::os::unix::fs::symlink(tmp.path().join("project/static"), root.join("assets")).unwrap();
2623
2624 assert!(
2625 config_with_static(&root, "assets").validate().is_ok(),
2626 "a static_folder symlinked to a peer of the root must be accepted"
2627 );
2628 }
2629
2630 #[test]
2631 fn test_validate_static_folder_relative_passes() {
2632 let (_tmp, root) = peer_layout();
2633 for value in ["", "static", "assets", "public/assets", "./static"] {
2634 assert!(
2635 config_with_static(&root, value).validate().is_ok(),
2636 "relative static_folder {value:?} must be accepted"
2637 );
2638 }
2639 }
2640
2641 #[test]
2645 fn test_validate_static_folder_peer_passes() {
2646 let (tmp, root) = peer_layout();
2647 std::fs::create_dir_all(tmp.path().join("project/static/videos")).unwrap();
2648
2649 for value in ["../static", "../static/videos", "./../static"] {
2650 assert!(
2651 config_with_static(&root, value).validate().is_ok(),
2652 "peer static_folder {value:?} must be accepted"
2653 );
2654 }
2655 }
2656
2657 fn two_deep_layout() -> (tempfile::TempDir, PathBuf) {
2666 let tmp = tempfile::tempdir().unwrap();
2667 let root = tmp.path().join("project/src/routes");
2668 std::fs::create_dir_all(&root).unwrap();
2669 std::fs::create_dir_all(tmp.path().join("project/static/images")).unwrap();
2670 (tmp, root)
2671 }
2672
2673 #[test]
2676 fn test_validate_static_folder_two_levels_up_passes() {
2677 let (_tmp, root) = two_deep_layout();
2678
2679 for value in [
2680 "../../static",
2681 "../../static/images",
2682 "./../../static",
2683 "../../not-created-yet",
2684 ] {
2685 assert!(
2686 config_with_static(&root, value).validate().is_ok(),
2687 "two-level static_folder {value:?} must be accepted"
2688 );
2689 }
2690 }
2691
2692 #[test]
2697 fn test_validate_static_folder_ancestor_of_root_fails() {
2698 let (_tmp, root) = two_deep_layout();
2699
2700 for value in ["..", "../..", "../../src", "../../src/routes/.."] {
2701 let err = match config_with_static(&root, value).validate() {
2702 Err(err) => err,
2703 Ok(()) => panic!("an ancestor of the markdown root must be rejected: {value:?}"),
2704 };
2705 assert!(
2706 format!("{err:?}").contains("contains the markdown root"),
2707 "error should say the value contains the root, got: {err:?}"
2708 );
2709 }
2710 }
2711
2712 #[test]
2716 fn test_validate_static_folder_three_levels_up_fails() {
2717 let (_tmp, root) = two_deep_layout();
2718
2719 for value in ["../../../elsewhere", "../../../../elsewhere"] {
2720 let err = match config_with_static(&root, value).validate() {
2721 Err(err) => err,
2722 Ok(()) => {
2723 panic!("static_folder past the ascent budget must be rejected: {value:?}")
2724 }
2725 };
2726 assert!(
2727 format!("{err:?}").contains("levels above the markdown root"),
2728 "error should name the ascent limit, got: {err:?}"
2729 );
2730 }
2731 }
2732
2733 #[test]
2738 fn test_static_folder_anchor_never_leaves_the_root_line() {
2739 let (tmp, peer_root) = peer_layout();
2740 let (tmp2, deep_root) = two_deep_layout();
2741
2742 for root in [
2743 peer_root.as_path(),
2744 deep_root.as_path(),
2745 tmp.path(),
2746 tmp2.path(),
2747 Path::new("/"),
2748 Path::new("/notes"),
2749 ] {
2750 let (anchor, _) = static_folder_anchor(root);
2751 assert!(
2752 root.starts_with(&anchor),
2753 "anchor {} is not an ancestor of root {}",
2754 anchor.display(),
2755 root.display()
2756 );
2757 let climbed = root.components().count() - anchor.components().count();
2758 assert!(
2759 climbed <= MAX_STATIC_FOLDER_ASCENT,
2760 "anchor {} climbed {climbed} levels above {}",
2761 anchor.display(),
2762 root.display()
2763 );
2764 }
2765 }
2766
2767 #[test]
2772 fn test_ascent_stops_at_a_directory_containing_home() {
2773 let _guard = env_lock();
2774 let tmp = tempfile::tempdir().unwrap();
2775 let users = tmp.path().join("Users");
2776 let home = users.join("alice");
2777 std::fs::create_dir_all(&home).unwrap();
2778 std::fs::create_dir_all(users.join("bob")).unwrap();
2779
2780 let home_str = home.to_string_lossy().into_owned();
2781 let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
2782
2783 let beside_home = users.join("notes");
2787 std::fs::create_dir(&beside_home).unwrap();
2788 let (anchor, stop) = static_folder_anchor(&beside_home);
2789 assert_eq!(
2790 anchor, beside_home,
2791 "the climb must not step into a directory holding $HOME"
2792 );
2793 assert_eq!(stop, AscentStop::HomeDir);
2794 assert!(
2795 config_with_static(&beside_home, "../alice")
2796 .validate()
2797 .is_err(),
2798 "$HOME must not be reachable from a sibling of it"
2799 );
2800
2801 let root = home.join("notes");
2804 std::fs::create_dir(&root).unwrap();
2805 assert_eq!(static_folder_anchor(&root).1, AscentStop::HomeDir);
2806 assert!(
2807 config_with_static(&root, "../../bob").validate().is_err(),
2808 "another account under the same Users directory must not be reachable"
2809 );
2810 }
2811
2812 #[test]
2817 fn test_validate_static_folder_relative_root_dir() {
2818 let root = PathBuf::from("no-such-root-8f2c/content");
2819 for value in ["../../x", "../../../x"] {
2820 assert!(
2821 config_with_static(&root, value).validate().is_err(),
2822 "a relative root must not reach outside itself: {value:?}"
2823 );
2824 }
2825 }
2826
2827 #[cfg(windows)]
2830 #[test]
2831 fn test_validate_static_folder_unc_share_boundary() {
2832 let root = Path::new(r"\\server\share\proj\content");
2833 assert!(
2834 config_with_static(root, r"..\..\x").validate().is_err(),
2835 "the climb must stop at a UNC share root"
2836 );
2837 }
2838
2839 #[test]
2843 fn test_static_folder_refusal_messages_name_the_wall() {
2844 let anchor = Path::new("/project");
2845
2846 assert!(
2847 AscentStop::RootHasNoParent
2848 .refusal(anchor)
2849 .contains("no parent directory")
2850 );
2851 assert!(
2852 AscentStop::HomeDir
2853 .refusal(anchor)
2854 .contains("the home directory")
2855 );
2856 assert!(
2857 AscentStop::FilesystemRoot
2858 .refusal(anchor)
2859 .contains("the filesystem root")
2860 );
2861
2862 let budget = AscentStop::Budget.refusal(anchor);
2863 assert!(budget.contains("levels above the markdown root"));
2864 for stop in [
2867 AscentStop::HomeDir,
2868 AscentStop::FilesystemRoot,
2869 AscentStop::Budget,
2870 ] {
2871 assert!(
2872 stop.refusal(anchor).contains("/project"),
2873 "refusal should name the anchor: {}",
2874 stop.refusal(anchor)
2875 );
2876 }
2877 }
2878
2879 #[test]
2883 fn test_validate_static_folder_nonexistent_paths_use_lexical_fallback() {
2884 let (_tmp, root) = peer_layout();
2885 for value in ["../not-created-yet", "../../not-created-yet"] {
2886 assert!(
2887 config_with_static(&root, value).validate().is_ok(),
2888 "a not-yet-created directory within the ascent budget is still allowed: {value:?}"
2889 );
2890 }
2891 assert!(
2892 config_with_static(&root, "../../../not-created-yet")
2893 .validate()
2894 .is_err(),
2895 "a nonexistent path past the ascent budget must still be rejected"
2896 );
2897 }
2898
2899 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2905
2906 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2907 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
2910 }
2911
2912 struct EnvVars {
2915 saved: Vec<(String, Option<std::ffi::OsString>)>,
2916 }
2917
2918 impl EnvVars {
2919 fn set(pairs: &[(&str, &str)]) -> Self {
2920 let saved = pairs
2921 .iter()
2922 .map(|(key, value)| {
2923 let previous = std::env::var_os(key);
2924 unsafe { std::env::set_var(key, value) };
2928 ((*key).to_string(), previous)
2929 })
2930 .collect();
2931 Self { saved }
2932 }
2933 }
2934
2935 impl Drop for EnvVars {
2936 fn drop(&mut self) {
2937 for (key, previous) in &self.saved {
2938 unsafe {
2940 match previous {
2941 Some(value) => std::env::set_var(key, value),
2942 None => std::env::remove_var(key),
2943 }
2944 }
2945 }
2946 }
2947 }
2948
2949 #[test]
2953 fn test_find_root_dir_home_marker_does_not_abort_search() {
2954 let _guard = env_lock();
2955 let home = tempfile::tempdir().unwrap();
2956 let home_str = home.path().to_string_lossy().into_owned();
2957 let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
2958
2959 std::fs::create_dir(home.path().join(".git")).unwrap();
2961 let vault = home.path().join("notes");
2962 std::fs::create_dir(&vault).unwrap();
2963 std::fs::create_dir(vault.join(".obsidian")).unwrap();
2964 let leaf = vault.join("projects");
2965 std::fs::create_dir(&leaf).unwrap();
2966 let note = leaf.join("plan.md");
2967 std::fs::write(¬e, "# Plan").unwrap();
2968
2969 assert_eq!(
2970 find_root_dir(¬e),
2971 vault,
2972 "the `.obsidian` vault must win once the `$HOME` `.git` is skipped"
2973 );
2974 }
2975
2976 #[test]
2979 fn test_find_root_dir_only_home_marker_falls_back() {
2980 let _guard = env_lock();
2981 let home = tempfile::tempdir().unwrap();
2982 let home_str = home.path().to_string_lossy().into_owned();
2983 let _env = EnvVars::set(&[("HOME", &home_str), ("USERPROFILE", &home_str)]);
2984
2985 std::fs::create_dir(home.path().join(".git")).unwrap();
2986 let leaf = home.path().join("notes/projects");
2987 std::fs::create_dir_all(&leaf).unwrap();
2988 let note = leaf.join("plan.md");
2989 std::fs::write(¬e, "# Plan").unwrap();
2990
2991 assert_eq!(find_root_dir(¬e), leaf);
2992 }
2993
2994 #[test]
2999 fn explicit_hidden_dirs_target_equal_to_root_is_empty() {
3000 let root = Path::new("/repo");
3001 assert_eq!(explicit_hidden_dirs(root, root), Vec::<PathBuf>::new());
3002 }
3003
3004 #[test]
3005 fn explicit_hidden_dirs_target_is_a_dot_dir() {
3006 assert_eq!(
3007 explicit_hidden_dirs(Path::new("/repo"), Path::new("/repo/.scratch")),
3008 vec![PathBuf::from(".scratch")]
3009 );
3010 }
3011
3012 #[test]
3016 fn explicit_hidden_dirs_target_nested_under_a_dot_dir() {
3017 assert_eq!(
3018 explicit_hidden_dirs(Path::new("/repo"), Path::new("/repo/.scratch/sub")),
3019 vec![PathBuf::from(".scratch")]
3020 );
3021 }
3022
3023 #[test]
3027 fn explicit_hidden_dirs_returns_every_hidden_link_in_the_chain() {
3028 assert_eq!(
3029 explicit_hidden_dirs(Path::new("/repo"), Path::new("/repo/.a/plain/.b/notes.md")),
3030 vec![PathBuf::from(".a"), PathBuf::from(".a/plain/.b")]
3031 );
3032 }
3033
3034 #[test]
3035 fn explicit_hidden_dirs_target_under_a_normal_dir_is_empty() {
3036 assert_eq!(
3037 explicit_hidden_dirs(Path::new("/repo"), Path::new("/repo/docs/guide.md")),
3038 Vec::<PathBuf>::new()
3039 );
3040 }
3041
3042 #[test]
3045 fn explicit_hidden_dirs_target_outside_root_is_empty() {
3046 assert_eq!(
3047 explicit_hidden_dirs(Path::new("/repo"), Path::new("/elsewhere/.scratch")),
3048 Vec::<PathBuf>::new()
3049 );
3050 }
3051
3052 #[test]
3055 fn test_config_read_derives_explicit_hidden_dirs_from_the_target() {
3056 let _guard = env_lock();
3057 let repo = repo_with_mbr_dir(Some("explicit_hidden_dirs = [\".sneaky\"]\n"));
3058 let scratch = repo.path().join(".scratch");
3059 std::fs::create_dir(&scratch).unwrap();
3060
3061 let config = Config::read(&scratch).expect("config must load");
3062
3063 assert_eq!(config.root_dir, repo.path());
3064 assert_eq!(config.explicit_hidden_dirs, vec![PathBuf::from(".scratch")]);
3065 }
3066
3067 #[test]
3070 fn test_config_read_at_root_ignores_enclosing_repositories() {
3071 let _guard = env_lock();
3072 let repo = repo_with_mbr_dir(Some("index_file = \"home.md\"\n"));
3073 let sub = repo.path().join("notes");
3074 std::fs::create_dir(&sub).unwrap();
3075
3076 let climbed = Config::read(&sub).expect("config must load");
3077 assert_eq!(climbed.root_dir, repo.path());
3078 assert_eq!(climbed.index_file, "home.md");
3079
3080 let pinned = Config::read_at_root(&sub).expect("config must load");
3081 assert_eq!(pinned.root_dir, sub);
3082 assert_eq!(pinned.index_file, Config::default().index_file);
3083 assert!(pinned.explicit_hidden_dirs.is_empty());
3084 }
3085
3086 #[test]
3087 fn test_config_read_leaves_explicit_hidden_dirs_empty_for_a_plain_target() {
3088 let _guard = env_lock();
3089 let repo = repo_with_mbr_dir(None);
3090
3091 let config = Config::read(repo.path()).expect("config must load");
3092
3093 assert!(config.explicit_hidden_dirs.is_empty());
3094 }
3095
3096 fn repo_with_mbr_dir(config_toml: Option<&str>) -> tempfile::TempDir {
3101 let dir = tempfile::tempdir().unwrap();
3102 let mbr = dir.path().join(".mbr");
3103 std::fs::create_dir(&mbr).unwrap();
3104 if let Some(contents) = config_toml {
3105 std::fs::write(mbr.join("config.toml"), contents).unwrap();
3106 }
3107 dir
3108 }
3109
3110 #[test]
3111 fn test_config_read_defaults_only() {
3112 let _guard = env_lock();
3113 let repo = repo_with_mbr_dir(None);
3114
3115 let config = Config::read(repo.path()).expect("defaults must load");
3116
3117 assert_eq!(config.theme, Config::default().theme);
3118 assert_eq!(config.port, DEFAULT_PORT);
3119 assert_eq!(config.root_dir, repo.path());
3120 }
3121
3122 #[test]
3123 fn test_config_read_toml_overrides_defaults() {
3124 let _guard = env_lock();
3125 let repo = repo_with_mbr_dir(Some("theme = \"amber\"\nport = 5321\n"));
3126
3127 let config = Config::read(repo.path()).expect("toml must load");
3128
3129 assert_eq!(config.theme, "amber");
3130 assert_eq!(config.port, 5321);
3131 }
3132
3133 #[test]
3134 fn test_config_read_env_overrides_defaults() {
3135 let _guard = env_lock();
3136 let repo = repo_with_mbr_dir(None);
3137 let _env = EnvVars::set(&[("MBR_THEME", "cyan"), ("MBR_PORT", "5322")]);
3138
3139 let config = Config::read(repo.path()).expect("env must load");
3140
3141 assert_eq!(config.theme, "cyan");
3142 assert_eq!(config.port, 5322);
3143 }
3144
3145 #[test]
3150 fn test_config_read_edit_token_hash_from_env() {
3151 let _guard = env_lock();
3152 let repo = repo_with_mbr_dir(Some("edit_enabled = true\nhost = \"0.0.0.0\"\n"));
3153 let _env = EnvVars::set(&[("MBR_EDIT_TOKEN_HASH", "$argon2id$from-env")]);
3154
3155 let config = Config::read(repo.path()).expect("env-supplied hash must satisfy validate");
3156
3157 assert_eq!(
3158 config.edit_token_hash.as_deref(),
3159 Some("$argon2id$from-env")
3160 );
3161 }
3162
3163 #[test]
3166 fn test_edit_enabled_defaults_off_for_read_and_on_for_gui() {
3167 let _guard = env_lock();
3168 let repo = repo_with_mbr_dir(None);
3169
3170 assert!(
3171 !Config::read(repo.path()).unwrap().edit_enabled,
3172 "server/build"
3173 );
3174 assert!(!Config::read_at_root(repo.path()).unwrap().edit_enabled);
3175 assert!(
3176 Config::read_for_gui(repo.path()).unwrap().edit_enabled,
3177 "GUI"
3178 );
3179 assert!(!Config::default().edit_enabled);
3180 assert!(Config::gui_defaults().edit_enabled);
3181 }
3182
3183 #[test]
3185 fn test_gui_edit_default_yields_to_config_false() {
3186 let _guard = env_lock();
3187 let repo = repo_with_mbr_dir(Some("edit_enabled = false\n"));
3188
3189 assert!(!Config::read_for_gui(repo.path()).unwrap().edit_enabled);
3190 }
3191
3192 #[test]
3194 fn test_gui_edit_default_yields_to_env_false() {
3195 let _guard = env_lock();
3196 let repo = repo_with_mbr_dir(None);
3197 let _env = EnvVars::set(&[("MBR_EDIT_ENABLED", "false")]);
3198 assert!(!Config::read_for_gui(repo.path()).unwrap().edit_enabled);
3199 drop(_env);
3200
3201 let repo = repo_with_mbr_dir(Some("edit_enabled = true\n"));
3202 let _env = EnvVars::set(&[("MBR_EDIT_ENABLED", "false")]);
3203 assert!(!Config::read_for_gui(repo.path()).unwrap().edit_enabled);
3204 }
3205
3206 #[test]
3208 fn test_server_edit_enabled_by_config_or_env() {
3209 let _guard = env_lock();
3210 let repo = repo_with_mbr_dir(Some("edit_enabled = true\n"));
3211 assert!(Config::read(repo.path()).unwrap().edit_enabled);
3212
3213 let repo = repo_with_mbr_dir(None);
3214 let _env = EnvVars::set(&[("MBR_EDIT_ENABLED", "true")]);
3215 assert!(Config::read(repo.path()).unwrap().edit_enabled);
3216 }
3217
3218 #[test]
3221 fn test_gui_edit_default_dropped_on_a_non_loopback_host_without_a_token() {
3222 let _guard = env_lock();
3223 let repo = repo_with_mbr_dir(Some("host = \"0.0.0.0\"\n"));
3224 let config = Config::read_for_gui(repo.path()).expect("default must not fail");
3225 assert!(!config.edit_enabled);
3226
3227 let repo = repo_with_mbr_dir(Some(
3228 "host = \"0.0.0.0\"\nedit_token_hash = \"$argon2id$x\"\n",
3229 ));
3230 assert!(
3231 Config::read_for_gui(repo.path()).unwrap().edit_enabled,
3232 "token"
3233 );
3234
3235 let repo = repo_with_mbr_dir(Some("host = \"0.0.0.0\"\nedit_enabled = true\n"));
3236 assert!(matches!(
3237 Config::read_for_gui(repo.path()),
3238 Err(crate::MbrError::Config(ConfigError::EditingRequiresToken))
3239 ));
3240 }
3241
3242 #[test]
3243 fn test_editing_needs_token() {
3244 let base = Config {
3245 edit_enabled: true,
3246 host: IpArray([0, 0, 0, 0]),
3247 ..Config::default()
3248 };
3249 assert!(base.editing_needs_token());
3250 assert!(
3251 !Config {
3252 edit_token_hash: Some("h".into()),
3253 ..base.clone()
3254 }
3255 .editing_needs_token()
3256 );
3257 assert!(
3258 !Config {
3259 host: IpArray([127, 0, 0, 1]),
3260 ..base.clone()
3261 }
3262 .editing_needs_token()
3263 );
3264 assert!(
3265 !Config {
3266 edit_enabled: false,
3267 ..base
3268 }
3269 .editing_needs_token()
3270 );
3271 }
3272
3273 #[test]
3278 fn test_config_read_env_beats_toml_beats_defaults() {
3279 let _guard = env_lock();
3280 let repo = repo_with_mbr_dir(Some(
3281 "theme = \"amber\"\nport = 5321\nindex_file = \"home.md\"\n",
3282 ));
3283 let _env = EnvVars::set(&[("MBR_THEME", "cyan")]);
3284
3285 let config = Config::read(repo.path()).expect("all three layers must load");
3286
3287 assert_eq!(config.theme, "cyan", "env must win over the config file");
3288 assert_eq!(
3289 config.index_file, "home.md",
3290 "config file must win over defaults where env is silent"
3291 );
3292 assert_eq!(
3293 config.port, 5321,
3294 "config file must win over defaults where env is silent"
3295 );
3296 assert_eq!(
3297 config.markdown_extensions,
3298 Config::default().markdown_extensions,
3299 "untouched keys keep the compiled-in default"
3300 );
3301 }
3302
3303 #[test]
3307 fn test_tasks_ignore_globs_defaults_to_empty() {
3308 assert!(Config::default().tasks_ignore_globs.is_empty());
3309 }
3310
3311 #[test]
3312 fn test_config_read_tasks_ignore_globs_from_toml() {
3313 let _guard = env_lock();
3314 let repo = repo_with_mbr_dir(Some(
3315 "tasks_ignore_globs = [\"templates/**\", \"**/archive/**\"]\n",
3316 ));
3317
3318 let config = Config::read(repo.path()).expect("toml must load");
3319
3320 assert_eq!(config.tasks_ignore_globs, ["templates/**", "**/archive/**"]);
3321 }
3322
3323 #[test]
3326 fn test_config_read_tasks_ignore_globs_env_overrides_toml() {
3327 let _guard = env_lock();
3328 let repo = repo_with_mbr_dir(Some("tasks_ignore_globs = [\"templates/**\"]\n"));
3329 let _env = EnvVars::set(&[("MBR_TASKS_IGNORE_GLOBS", r#"["**/archive/**"]"#)]);
3330
3331 let config = Config::read(repo.path()).expect("env must load");
3332
3333 assert_eq!(config.tasks_ignore_globs, ["**/archive/**"]);
3334 }
3335
3336 #[test]
3338 fn test_config_read_without_tasks_ignore_globs_key_still_loads() {
3339 let _guard = env_lock();
3340 let repo = repo_with_mbr_dir(Some("theme = \"amber\"\nport = 5323\n"));
3341
3342 let config = Config::read(repo.path()).expect("a config predating the option must load");
3343
3344 assert!(config.tasks_ignore_globs.is_empty());
3345 assert_eq!(config.theme, "amber");
3346 }
3347
3348 #[test]
3352 fn test_validate_rejects_a_malformed_tasks_ignore_glob() {
3353 let config = Config {
3355 tasks_ignore_globs: vec!["templates/**".to_string(), "templates/**bad".to_string()],
3356 ..Default::default()
3357 };
3358
3359 let err = config
3360 .validate()
3361 .expect_err("a malformed glob must fail validation");
3362
3363 assert!(
3364 matches!(err, ConfigError::InvalidTasksIgnoreGlob { ref pattern, .. } if pattern == "templates/**bad"),
3365 "expected InvalidTasksIgnoreGlob naming the offending pattern, got: {err:?}"
3366 );
3367 assert!(err.to_string().contains("templates/**"));
3368 }
3369
3370 #[test]
3371 fn test_validate_accepts_the_documented_glob_spellings() {
3372 let config = Config {
3373 tasks_ignore_globs: vec![
3374 "templates/**".to_string(),
3375 "**/templates/**".to_string(),
3376 "docs/*.md".to_string(),
3377 ],
3378 ..Default::default()
3379 };
3380
3381 assert!(config.validate().is_ok());
3382 }
3383
3384 #[test]
3386 fn test_config_read_rejects_a_malformed_tasks_ignore_glob() {
3387 let _guard = env_lock();
3388 let repo = repo_with_mbr_dir(Some("tasks_ignore_globs = [\"templates/**bad\"]\n"));
3389
3390 let err = Config::read(repo.path()).expect_err("a malformed glob must abort loading");
3391
3392 assert!(
3393 matches!(
3394 err,
3395 crate::MbrError::Config(ConfigError::InvalidTasksIgnoreGlob { .. })
3396 ),
3397 "expected ConfigError::InvalidTasksIgnoreGlob, got: {err:?}"
3398 );
3399 }
3400
3401 #[test]
3402 fn default_review_enabled_is_true() {
3403 assert!(Config::default().review_enabled);
3404 }
3405
3406 #[test]
3409 fn test_config_read_review_enabled_from_toml_and_env() {
3410 let _guard = env_lock();
3411 let repo = repo_with_mbr_dir(Some("review_enabled = false\n"));
3412
3413 let config = Config::read(repo.path()).expect("toml must load");
3414 assert!(!config.review_enabled);
3415
3416 let _env = EnvVars::set(&[("MBR_REVIEW_ENABLED", "true")]);
3417 let config = Config::read(repo.path()).expect("env must load");
3418 assert!(config.review_enabled, "env overrides .mbr/config.toml");
3419 }
3420
3421 #[test]
3423 fn test_config_read_without_review_enabled_key_still_loads() {
3424 let _guard = env_lock();
3425 let repo = repo_with_mbr_dir(Some("tasks_enabled = false\n"));
3426
3427 let config = Config::read(repo.path()).expect("toml must load");
3428 assert!(config.review_enabled, "serde default applies");
3429 }
3430
3431 #[test]
3435 fn test_default_tasks_default_include_is_tasks() {
3436 assert_eq!(
3437 Config::default().tasks_default_include,
3438 IncludeFilter::Tasks
3439 );
3440 }
3441
3442 #[test]
3443 fn test_config_read_tasks_default_include_round_trips_every_value() {
3444 let _guard = env_lock();
3445 for (written, expected) in [
3446 ("all", IncludeFilter::All),
3447 ("tasks", IncludeFilter::Tasks),
3448 ("markers", IncludeFilter::Markers),
3449 ] {
3450 let repo = repo_with_mbr_dir(Some(&format!("tasks_default_include = \"{written}\"\n")));
3451
3452 let config = Config::read(repo.path())
3453 .unwrap_or_else(|e| panic!("{written:?} must load, got: {e:?}"));
3454
3455 assert_eq!(config.tasks_default_include, expected, "for {written:?}");
3456 }
3457 }
3458
3459 #[test]
3462 fn test_config_read_rejects_an_unknown_tasks_default_include() {
3463 let _guard = env_lock();
3464 let repo = repo_with_mbr_dir(Some("tasks_default_include = \"todos\"\n"));
3465
3466 let err = Config::read(repo.path()).expect_err("an unknown value must abort loading");
3467
3468 assert!(
3469 matches!(err, crate::MbrError::Config(ConfigError::ParseFailed(_))),
3470 "expected ConfigError::ParseFailed, got: {err:?}"
3471 );
3472 }
3473
3474 #[test]
3477 fn test_config_read_tasks_default_include_env_overrides_toml() {
3478 let _guard = env_lock();
3479 let repo = repo_with_mbr_dir(Some("tasks_default_include = \"tasks\"\n"));
3480 let _env = EnvVars::set(&[("MBR_TASKS_DEFAULT_INCLUDE", "markers")]);
3481
3482 let config = Config::read(repo.path()).expect("env must load");
3483
3484 assert_eq!(config.tasks_default_include, IncludeFilter::Markers);
3485 }
3486
3487 #[test]
3489 fn test_config_read_without_tasks_default_include_key_still_loads() {
3490 let _guard = env_lock();
3491 let repo = repo_with_mbr_dir(Some("theme = \"amber\"\nport = 5324\n"));
3492
3493 let config = Config::read(repo.path()).expect("a config predating the option must load");
3494
3495 assert_eq!(config.tasks_default_include, IncludeFilter::Tasks);
3496 assert_eq!(config.theme, "amber");
3497 }
3498
3499 #[test]
3500 fn test_config_read_malformed_toml_returns_parse_failed() {
3501 let _guard = env_lock();
3502 let repo = repo_with_mbr_dir(Some("theme = \nport = [not valid toml\n"));
3503
3504 let err = Config::read(repo.path()).expect_err("malformed toml must fail");
3505
3506 assert!(
3507 matches!(err, crate::MbrError::Config(ConfigError::ParseFailed(_))),
3508 "expected ConfigError::ParseFailed, got: {err:?}"
3509 );
3510 }
3511
3512 #[test]
3517 fn test_config_toml_cannot_set_the_symlink_mount_switch() {
3518 let _guard = env_lock();
3519 let toml = "no_symlink_mounts = false\nsymlink_mounts = true\n\
3520 MBR_NO_SYMLINK_MOUNTS = false\n";
3521 let repo = repo_with_mbr_dir(Some(toml));
3522 {
3523 let _env = EnvVars::set(&[(NO_SYMLINK_MOUNTS_ENV, "1")]);
3524 assert!(
3525 Config::read(repo.path()).unwrap().no_symlink_mounts,
3526 "the operator's env var wins over anything in config.toml"
3527 );
3528 }
3529 let repo = repo_with_mbr_dir(Some("no_symlink_mounts = true\nsymlink_mounts = false\n"));
3530 let _env = EnvVars::set(&[(NO_SYMLINK_MOUNTS_ENV, "0")]);
3531 assert!(
3532 !Config::read(repo.path()).unwrap().no_symlink_mounts,
3533 "config.toml cannot disable mounts either: the key is simply not read"
3534 );
3535 }
3536
3537 #[test]
3538 fn test_no_symlink_mounts_env_values() {
3539 for on in ["1", "true", "TRUE", "yes", "on", "anything"] {
3540 assert!(env_flag_is_set(std::ffi::OsStr::new(on)), "{on}");
3541 }
3542 for off in ["", " ", "0", "false", "False", "no", "off"] {
3543 assert!(!env_flag_is_set(std::ffi::OsStr::new(off)), "{off:?}");
3544 }
3545 }
3546
3547 #[test]
3548 fn test_config_read_rejects_escaping_static_folder_from_toml() {
3549 let _guard = env_lock();
3550 let repo = repo_with_mbr_dir(Some("static_folder = \"../..\"\n"));
3551
3552 assert!(
3553 Config::read(repo.path()).is_err(),
3554 "a repo-supplied static_folder that escapes the root must be rejected"
3555 );
3556 }
3557
3558 #[test]
3562 fn test_config_read_rejects_absolute_static_folder_from_toml() {
3563 let _guard = env_lock();
3564 let absolute = if cfg!(windows) { r"C:\\etc" } else { "/etc" };
3565 let repo = repo_with_mbr_dir(Some(&format!("static_folder = \"{absolute}\"\n")));
3566
3567 let err = Config::read(repo.path())
3568 .expect_err("an absolute static_folder from the repo config must be rejected");
3569
3570 assert!(
3571 format!("{err:?}").contains("static_folder"),
3572 "error should name static_folder, got: {err:?}"
3573 );
3574 }
3575
3576 #[test]
3580 fn test_config_read_accepts_absolute_static_folder_from_env() {
3581 let _guard = env_lock();
3582 let repo = repo_with_mbr_dir(None);
3583 let assets = tempfile::tempdir().unwrap();
3584 let assets_str = assets.path().to_string_lossy().into_owned();
3585 let _env = EnvVars::set(&[("MBR_STATIC_FOLDER", &assets_str)]);
3586
3587 let config = Config::read(repo.path())
3588 .expect("an absolute static_folder from the environment must be accepted");
3589
3590 assert_eq!(config.static_folder, assets_str);
3591 }
3592
3593 #[test]
3596 fn test_config_read_env_absolute_static_folder_overrides_toml() {
3597 let _guard = env_lock();
3598 let repo = repo_with_mbr_dir(Some("static_folder = \"/etc\"\n"));
3599 let assets = tempfile::tempdir().unwrap();
3600 let assets_str = assets.path().to_string_lossy().into_owned();
3601 let _env = EnvVars::set(&[("MBR_STATIC_FOLDER", &assets_str)]);
3602
3603 let config = Config::read(repo.path())
3604 .expect("the operator's absolute static_folder must win over the repo's");
3605
3606 assert_eq!(config.static_folder, assets_str);
3607 }
3608
3609 #[test]
3613 fn test_config_read_accepts_peer_static_folder_from_toml() {
3614 let _guard = env_lock();
3615 let project = tempfile::tempdir().unwrap();
3616 let content = project.path().join("content");
3617 std::fs::create_dir_all(content.join(".mbr")).unwrap();
3618 std::fs::write(
3619 content.join(".mbr/config.toml"),
3620 "static_folder = \"../static\"\n",
3621 )
3622 .unwrap();
3623 std::fs::create_dir(project.path().join("static")).unwrap();
3624
3625 let config = Config::read(&content).expect("a peer static folder must load");
3626
3627 assert_eq!(config.static_folder, "../static");
3628 assert_eq!(config.root_dir, content);
3629 }
3630}