mempal-runtime 0.9.0

Reusable mempal runtime workflows built on agent memory storage.
Documentation
use std::path::{Path, PathBuf};

use ignore::WalkBuilder;
use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectPathFilterOptions {
    pub respect_gitignore: bool,
    pub respect_mempalignore: bool,
    pub custom_ignore_files: Vec<PathBuf>,
}

impl Default for ProjectPathFilterOptions {
    fn default() -> Self {
        Self {
            respect_gitignore: true,
            respect_mempalignore: true,
            custom_ignore_files: Vec::new(),
        }
    }
}

#[derive(Debug, Error)]
pub enum ProjectPathFilterError {
    #[error("custom ignore file does not exist: {path}")]
    MissingCustomIgnoreFile { path: PathBuf },
    #[error("project traversal failed for {path}")]
    Walk {
        path: PathBuf,
        #[source]
        source: ignore::Error,
    },
}

pub fn project_walk(
    root: &Path,
    options: &ProjectPathFilterOptions,
) -> Result<ignore::Walk, ProjectPathFilterError> {
    for path in &options.custom_ignore_files {
        if !path.is_file() {
            return Err(ProjectPathFilterError::MissingCustomIgnoreFile { path: path.clone() });
        }
    }

    let mut builder = WalkBuilder::new(root);
    builder
        .hidden(false)
        .git_ignore(options.respect_gitignore)
        .git_exclude(options.respect_gitignore)
        .git_global(options.respect_gitignore)
        .require_git(false)
        .parents(true);

    let mempalignore = root.join(".mempalignore");
    if options.respect_mempalignore && mempalignore.is_file() {
        builder.add_ignore(mempalignore);
    }

    for path in &options.custom_ignore_files {
        builder.add_ignore(path);
    }

    builder.filter_entry(|entry| !is_hard_skipped_dir(entry.path()));
    Ok(builder.build())
}

pub fn is_hard_skipped_dir(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .map(|name| matches!(name, ".git" | "target" | "node_modules"))
        .unwrap_or(false)
}

pub fn collect_relative_paths(
    root: &Path,
    options: &ProjectPathFilterOptions,
) -> Result<Vec<String>, ProjectPathFilterError> {
    let mut paths = Vec::new();
    for entry in project_walk(root, options)? {
        let entry = entry.map_err(|source| ProjectPathFilterError::Walk {
            path: root.to_path_buf(),
            source,
        })?;
        if entry.path() == root {
            continue;
        }
        if let Ok(relative) = entry.path().strip_prefix(root) {
            paths.push(relative.to_string_lossy().replace('\\', "/"));
        }
    }
    paths.sort();
    Ok(paths)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn write(path: &std::path::Path, content: &str) {
        fs::write(path, content).expect("write fixture");
    }

    #[test]
    fn default_walk_respects_gitignore_and_mempalignore() {
        let tmp = TempDir::new().expect("tempdir");
        let root = tmp.path();
        fs::create_dir_all(root.join("src/auth")).expect("src");
        fs::create_dir_all(root.join("dist/build-room")).expect("dist");
        fs::create_dir_all(root.join("generated/gen-room")).expect("generated");
        fs::create_dir_all(root.join("target/release-room")).expect("target");
        write(&root.join(".gitignore"), "dist/\n");
        write(&root.join(".mempalignore"), "generated/\n");

        let paths = collect_relative_paths(root, &ProjectPathFilterOptions::default())
            .expect("walk succeeds");

        assert!(paths.iter().any(|path| path == "src/auth"));
        assert!(!paths.iter().any(|path| path.starts_with("dist/")));
        assert!(!paths.iter().any(|path| path.starts_with("generated/")));
        assert!(!paths.iter().any(|path| path.starts_with("target/")));
    }

    #[test]
    fn no_gitignore_keeps_mempalignore() {
        let tmp = TempDir::new().expect("tempdir");
        let root = tmp.path();
        fs::create_dir_all(root.join("dist/build-room")).expect("dist");
        fs::create_dir_all(root.join("generated/gen-room")).expect("generated");
        write(&root.join(".gitignore"), "dist/\n");
        write(&root.join(".mempalignore"), "generated/\n");

        let paths = collect_relative_paths(
            root,
            &ProjectPathFilterOptions {
                respect_gitignore: false,
                ..ProjectPathFilterOptions::default()
            },
        )
        .expect("walk succeeds");

        assert!(paths.iter().any(|path| path.starts_with("dist/")));
        assert!(!paths.iter().any(|path| path.starts_with("generated/")));
    }

    #[test]
    fn custom_ignore_file_is_validated_and_applied() {
        let tmp = TempDir::new().expect("tempdir");
        let root = tmp.path();
        let custom = root.join("custom.ignore");
        fs::create_dir_all(root.join("notes/api")).expect("notes");
        write(&custom, "notes/\n");

        let paths = collect_relative_paths(
            root,
            &ProjectPathFilterOptions {
                custom_ignore_files: vec![custom],
                ..ProjectPathFilterOptions::default()
            },
        )
        .expect("walk succeeds");

        assert!(!paths.iter().any(|path| path.starts_with("notes/")));
    }

    #[test]
    fn missing_custom_ignore_file_is_an_error() {
        let tmp = TempDir::new().expect("tempdir");
        let missing = tmp.path().join("missing.ignore");
        let err = collect_relative_paths(
            tmp.path(),
            &ProjectPathFilterOptions {
                custom_ignore_files: vec![missing.clone()],
                ..ProjectPathFilterOptions::default()
            },
        )
        .expect_err("missing ignore file should fail");

        assert!(err.to_string().contains(&missing.display().to_string()));
    }
}