magi-code 0.61.0

Repository-aware CLI coding agent for terminal work
Documentation
use crate::agent::cancellation::AgentCancellation;
use ignore::WalkBuilder;
use std::{
    collections::HashSet,
    fs,
    path::{Path, PathBuf},
};

#[derive(Debug, Default)]
pub(super) struct WorkspaceWalker;

pub(super) struct WorkspaceWalkOptions<'a> {
    pub(super) root: &'a Path,
    pub(super) include_files: bool,
    pub(super) include_dirs: bool,
    pub(super) skip_dirs: &'a [&'a str],
    pub(super) cancel_interval: usize,
}

#[derive(Debug, Clone)]
pub(super) struct WorkspaceWalkEntry {
    pub(super) path: PathBuf,
    pub(super) file_type: fs::FileType,
}

#[derive(Debug, Clone, Default)]
pub(super) struct WorkspaceWalkResult {
    pub(super) entries: Vec<WorkspaceWalkEntry>,
    pub(super) agents_files: Vec<PathBuf>,
}

impl WorkspaceWalker {
    pub(super) fn walk(
        &self,
        options: WorkspaceWalkOptions<'_>,
        cancellation: Option<&AgentCancellation>,
    ) -> anyhow::Result<WorkspaceWalkResult> {
        let mut result = WorkspaceWalkResult::default();
        let cancel_interval = options.cancel_interval.max(1);
        let skip_dirs = options
            .skip_dirs
            .iter()
            .map(|name| (*name).to_string())
            .collect::<HashSet<_>>();

        let walker = WalkBuilder::new(options.root)
            .standard_filters(true)
            .filter_entry(move |entry| {
                let name = entry.file_name().to_string_lossy();
                !(entry
                    .file_type()
                    .is_some_and(|file_type| file_type.is_dir())
                    && skip_dirs.contains(name.as_ref()))
            })
            .build();

        for (entry_index, entry) in walker.enumerate() {
            if entry_index % cancel_interval == 0
                && let Some(cancellation) = cancellation
            {
                cancellation.check()?;
            }
            let Ok(entry) = entry else { continue };
            let Some(file_type) = entry.file_type() else {
                continue;
            };
            let path = entry.path();
            if path == options.root {
                continue;
            }
            if path.file_name().is_some_and(|name| name == "AGENTS.md") {
                result.agents_files.push(path.to_path_buf());
            }
            if (file_type.is_file() && options.include_files)
                || (file_type.is_dir() && options.include_dirs)
            {
                result.entries.push(WorkspaceWalkEntry {
                    path: path.to_path_buf(),
                    file_type,
                });
            }
        }

        Ok(result)
    }
}

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

    fn names(result: &WorkspaceWalkResult) -> Vec<String> {
        let mut names = result
            .entries
            .iter()
            .map(|entry| {
                entry
                    .path
                    .file_name()
                    .unwrap()
                    .to_string_lossy()
                    .into_owned()
            })
            .collect::<Vec<_>>();
        names.sort();
        names
    }

    #[test]
    fn walk_respects_gitignore() {
        let temp = tempfile::TempDir::new().unwrap();
        fs::create_dir(temp.path().join(".git")).unwrap();
        fs::write(temp.path().join(".gitignore"), "ignored.txt\n").unwrap();
        fs::write(temp.path().join("ignored.txt"), "hidden").unwrap();
        fs::write(temp.path().join("visible.txt"), "shown").unwrap();
        let result = WorkspaceWalker
            .walk(
                WorkspaceWalkOptions {
                    root: temp.path(),
                    include_files: true,
                    include_dirs: false,
                    skip_dirs: &[],
                    cancel_interval: 32,
                },
                None,
            )
            .unwrap();

        let names = names(&result);
        assert!(names.contains(&"visible.txt".to_string()));
        assert!(!names.contains(&"ignored.txt".to_string()));
    }

    #[test]
    fn walk_discovers_agents_files() {
        let temp = tempfile::TempDir::new().unwrap();
        fs::write(temp.path().join("AGENTS.md"), "root").unwrap();
        fs::create_dir(temp.path().join("nested")).unwrap();
        fs::write(temp.path().join("nested/AGENTS.md"), "nested").unwrap();
        let result = WorkspaceWalker
            .walk(
                WorkspaceWalkOptions {
                    root: temp.path(),
                    include_files: false,
                    include_dirs: false,
                    skip_dirs: &[],
                    cancel_interval: 32,
                },
                None,
            )
            .unwrap();

        assert_eq!(result.entries.len(), 0);
        assert_eq!(result.agents_files.len(), 2);
        assert!(
            result
                .agents_files
                .iter()
                .any(|path| path.ends_with("AGENTS.md"))
        );
        assert!(
            result
                .agents_files
                .iter()
                .any(|path| path.ends_with("nested/AGENTS.md"))
        );
    }

    #[test]
    fn configured_skip_dirs_are_excluded() {
        let temp = tempfile::TempDir::new().unwrap();
        fs::create_dir(temp.path().join("target")).unwrap();
        fs::write(temp.path().join("target/hidden.txt"), "hidden").unwrap();
        fs::create_dir(temp.path().join("node_modules")).unwrap();
        fs::write(temp.path().join("node_modules/visible.txt"), "shown").unwrap();
        let target_only = WorkspaceWalker
            .walk(
                WorkspaceWalkOptions {
                    root: temp.path(),
                    include_files: true,
                    include_dirs: true,
                    skip_dirs: &["target"],
                    cancel_interval: 32,
                },
                None,
            )
            .unwrap();
        let target_names = names(&target_only);
        assert!(!target_names.contains(&"hidden.txt".to_string()));
        assert!(target_names.contains(&"visible.txt".to_string()));

        let both = WorkspaceWalker
            .walk(
                WorkspaceWalkOptions {
                    root: temp.path(),
                    include_files: true,
                    include_dirs: true,
                    skip_dirs: &["target", "node_modules"],
                    cancel_interval: 32,
                },
                None,
            )
            .unwrap();
        let names = names(&both);
        assert!(!names.contains(&"hidden.txt".to_string()));
        assert!(!names.contains(&"visible.txt".to_string()));
    }
}