1use crate::config::AppConfig;
2use crate::support::Result;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6pub fn load_from_path(path: &Path) -> Result<AppConfig> {
7 let config_path = if path.is_absolute() {
8 path.to_path_buf()
9 } else {
10 std::env::current_dir()?.join(path)
11 };
12 let raw = fs::read_to_string(&config_path)?;
13 let mut config: AppConfig = toml::from_str(&raw)?;
14 let base_dir = config_path.parent().unwrap_or_else(|| Path::new("/"));
15 config.vault.root = resolve_path(&config.vault.root, base_dir);
16 config.developer.note_roots = config
17 .developer
18 .note_roots
19 .iter()
20 .map(|root| normalize_relative_note_path(root))
21 .collect::<Result<Vec<_>>>()?;
22 for project in &mut config.projects {
23 project.repo_paths = project
24 .repo_paths
25 .iter()
26 .map(|repo_path| resolve_path(repo_path, base_dir))
27 .collect();
28 project.note_roots = project
29 .note_roots
30 .iter()
31 .map(|root| normalize_relative_note_path(root))
32 .collect::<Result<Vec<_>>>()?;
33 }
34 for scene in &mut config.scenes {
35 scene.preferred_notes = scene
36 .preferred_notes
37 .iter()
38 .map(|note| normalize_relative_note_path(note))
39 .collect::<Result<Vec<_>>>()?;
40 }
41 Ok(config)
42}
43
44fn normalize_relative_note_path(path: &str) -> Result<String> {
45 let normalized = normalize_relative_path(Path::new(path))?;
46 Ok(normalized.to_string_lossy().replace('\\', "/"))
47}
48
49fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
50 if path.is_absolute() {
51 anyhow::bail!(
52 "relative note path must not be absolute: {}",
53 path.display()
54 );
55 }
56
57 let mut normalized = PathBuf::new();
58 for component in path.components() {
59 match component {
60 Component::CurDir => {}
61 Component::ParentDir => {
62 if !normalized.pop() {
63 anyhow::bail!(
64 "relative note path must not escape root: {}",
65 path.display()
66 );
67 }
68 }
69 Component::Normal(segment) => normalized.push(segment),
70 Component::RootDir | Component::Prefix(_) => {
71 anyhow::bail!(
72 "relative note path must be vault-relative: {}",
73 path.display()
74 );
75 }
76 }
77 }
78 Ok(normalized)
79}
80
81fn resolve_path(path: &Path, base_dir: &Path) -> PathBuf {
82 let resolved = if path.is_absolute() {
83 path.to_path_buf()
84 } else {
85 base_dir.join(path)
86 };
87 let normalized = normalize_absolute_path(&resolved);
88 if normalized.exists() {
89 normalized.canonicalize().unwrap_or(normalized)
90 } else {
91 normalized
92 }
93}
94
95fn normalize_absolute_path(path: &Path) -> PathBuf {
96 let mut normalized = PathBuf::new();
97
98 for component in path.components() {
99 match component {
100 Component::CurDir => {}
101 Component::ParentDir => {
102 normalized.pop();
103 }
104 other => normalized.push(other.as_os_str()),
105 }
106 }
107
108 normalized
109}
110
111#[cfg(test)]
112mod tests {
113 use super::{normalize_relative_note_path, resolve_path};
114 use std::path::Path;
115
116 #[test]
117 fn resolve_relative_path_against_config_dir() {
118 let base_dir = Path::new("/tmp/example/config");
119 let resolved = resolve_path(Path::new("../vault"), base_dir);
120 assert_eq!(resolved, Path::new("/tmp/example/vault"));
121 }
122
123 #[test]
124 fn normalize_preferred_note_path() {
125 assert_eq!(
126 normalize_relative_note_path("./20-Areas/../20-Areas/AI协作偏好.md").unwrap(),
127 "20-Areas/AI协作偏好.md"
128 );
129 }
130
131 #[test]
132 fn reject_escaping_preferred_note_path() {
133 let error = normalize_relative_note_path("../outside.md").unwrap_err();
134 assert!(error.to_string().contains("must not escape root"));
135 }
136}