spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use crate::config::AppConfig;
use crate::support::Result;
use std::fs;
use std::path::{Component, Path, PathBuf};

pub fn load_from_path(path: &Path) -> Result<AppConfig> {
    let config_path = if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir()?.join(path)
    };
    let raw = fs::read_to_string(&config_path)?;
    let mut config: AppConfig = toml::from_str(&raw)?;
    let base_dir = config_path.parent().unwrap_or_else(|| Path::new("/"));
    config.vault.root = resolve_path(&config.vault.root, base_dir);
    config.developer.note_roots = config
        .developer
        .note_roots
        .iter()
        .map(|root| normalize_relative_note_path(root))
        .collect::<Result<Vec<_>>>()?;
    for project in &mut config.projects {
        project.repo_paths = project
            .repo_paths
            .iter()
            .map(|repo_path| resolve_path(repo_path, base_dir))
            .collect();
        project.note_roots = project
            .note_roots
            .iter()
            .map(|root| normalize_relative_note_path(root))
            .collect::<Result<Vec<_>>>()?;
    }
    for scene in &mut config.scenes {
        scene.preferred_notes = scene
            .preferred_notes
            .iter()
            .map(|note| normalize_relative_note_path(note))
            .collect::<Result<Vec<_>>>()?;
    }
    Ok(config)
}

fn normalize_relative_note_path(path: &str) -> Result<String> {
    let normalized = normalize_relative_path(Path::new(path))?;
    Ok(normalized.to_string_lossy().replace('\\', "/"))
}

fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
    if path.is_absolute() {
        anyhow::bail!(
            "relative note path must not be absolute: {}",
            path.display()
        );
    }

    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                if !normalized.pop() {
                    anyhow::bail!(
                        "relative note path must not escape root: {}",
                        path.display()
                    );
                }
            }
            Component::Normal(segment) => normalized.push(segment),
            Component::RootDir | Component::Prefix(_) => {
                anyhow::bail!(
                    "relative note path must be vault-relative: {}",
                    path.display()
                );
            }
        }
    }
    Ok(normalized)
}

fn resolve_path(path: &Path, base_dir: &Path) -> PathBuf {
    let resolved = if path.is_absolute() {
        path.to_path_buf()
    } else {
        base_dir.join(path)
    };
    let normalized = normalize_absolute_path(&resolved);
    if normalized.exists() {
        normalized.canonicalize().unwrap_or(normalized)
    } else {
        normalized
    }
}

fn normalize_absolute_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();

    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                normalized.pop();
            }
            other => normalized.push(other.as_os_str()),
        }
    }

    normalized
}

#[cfg(test)]
mod tests {
    use super::{normalize_relative_note_path, resolve_path};
    use std::path::Path;

    #[test]
    fn resolve_relative_path_against_config_dir() {
        let base_dir = Path::new("/tmp/example/config");
        let resolved = resolve_path(Path::new("../vault"), base_dir);
        assert_eq!(resolved, Path::new("/tmp/example/vault"));
    }

    #[test]
    fn normalize_preferred_note_path() {
        assert_eq!(
            normalize_relative_note_path("./20-Areas/../20-Areas/AI协作偏好.md").unwrap(),
            "20-Areas/AI协作偏好.md"
        );
    }

    #[test]
    fn reject_escaping_preferred_note_path() {
        let error = normalize_relative_note_path("../outside.md").unwrap_err();
        assert!(error.to_string().contains("must not escape root"));
    }
}