Skip to main content

spool/config/
loader.rs

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}