magi-code 0.63.1

Repository-aware CLI coding agent for terminal work
Documentation
use crate::{commands::CommandRegistry, skills::SkillDiscovery, tui::state};
use std::path::{Component, Path};

pub(crate) fn tui_autocomplete_candidates(
    commands: &CommandRegistry,
) -> Vec<state::AutocompleteCandidate> {
    let mut candidates = commands
        .list()
        .into_iter()
        .map(|command| {
            state::AutocompleteCandidate::slash_command(
                command.name.clone(),
                command.description.clone(),
            )
        })
        .collect::<Vec<_>>();
    candidates.extend(tui_context_injection_autocomplete_candidates());
    candidates
}

fn tui_context_injection_autocomplete_candidates() -> Vec<state::AutocompleteCandidate> {
    crate::agent::prompt_injections::definitions()
        .iter()
        .map(|definition| {
            state::AutocompleteCandidate::context_injection(definition.name, definition.description)
        })
        .collect()
}

pub(crate) fn tui_skill_autocomplete_candidates(
    skills: &SkillDiscovery,
) -> Vec<state::AutocompleteCandidate> {
    skills
        .skills
        .keys()
        .map(|name| state::AutocompleteCandidate::skill_tag(name.clone()))
        .collect()
}

pub(crate) fn refresh_skill_autocomplete_candidates(
    candidates: &mut Vec<state::AutocompleteCandidate>,
    skills: &SkillDiscovery,
) {
    candidates.retain(|candidate| candidate.kind != state::AutocompleteKind::SkillTag);
    candidates.extend(tui_skill_autocomplete_candidates(skills));
}

const MAX_FILE_AUTOCOMPLETE_CANDIDATES: usize = 2000;
const MAX_FILE_AUTOCOMPLETE_VISITED_ENTRIES: usize = 10_000;

pub(crate) fn tui_file_autocomplete_candidates(
    cwd: &Path,
    respect_gitignore: bool,
) -> Vec<state::AutocompleteCandidate> {
    tui_file_autocomplete_candidates_with_limits(
        cwd,
        respect_gitignore,
        MAX_FILE_AUTOCOMPLETE_CANDIDATES,
        MAX_FILE_AUTOCOMPLETE_VISITED_ENTRIES,
    )
}

fn tui_file_autocomplete_candidates_with_limits(
    cwd: &Path,
    respect_gitignore: bool,
    max_candidates: usize,
    max_visited_entries: usize,
) -> Vec<state::AutocompleteCandidate> {
    if max_candidates == 0 || max_visited_entries == 0 {
        return Vec::new();
    }
    let Ok(root) = cwd.canonicalize() else {
        return Vec::new();
    };
    let mut paths = if respect_gitignore {
        gitignore_respecting_file_autocomplete_paths(&root, max_candidates, max_visited_entries)
    } else {
        unfiltered_file_autocomplete_paths(&root, max_candidates, max_visited_entries)
    };
    paths.sort();
    paths.dedup();
    paths
        .into_iter()
        .map(state::AutocompleteCandidate::file_tag)
        .collect()
}

fn unfiltered_file_autocomplete_paths(
    root: &Path,
    max_candidates: usize,
    max_visited_entries: usize,
) -> Vec<String> {
    let mut paths = Vec::new();
    for entry in walkdir::WalkDir::new(root)
        .follow_links(false)
        .sort_by_file_name()
        .into_iter()
        .take(max_visited_entries)
        .filter_map(Result::ok)
    {
        collect_file_autocomplete_path(root, entry.path(), entry.file_type().is_file(), &mut paths);
        if paths.len() >= max_candidates {
            break;
        }
    }
    paths
}

fn gitignore_respecting_file_autocomplete_paths(
    root: &Path,
    max_candidates: usize,
    max_visited_entries: usize,
) -> Vec<String> {
    let mut builder = ignore::WalkBuilder::new(root);
    builder
        .follow_links(false)
        .hidden(false)
        .ignore(false)
        .git_ignore(true)
        .require_git(false)
        .git_global(false)
        .git_exclude(false)
        .sort_by_file_name(|left, right| left.cmp(right));

    let mut paths = Vec::new();
    for entry in builder
        .build()
        .take(max_visited_entries)
        .filter_map(Result::ok)
    {
        let is_file = entry
            .file_type()
            .is_some_and(|file_type| file_type.is_file());
        collect_file_autocomplete_path(root, entry.path(), is_file, &mut paths);
        if paths.len() >= max_candidates {
            break;
        }
    }
    paths
}

fn collect_file_autocomplete_path(
    root: &Path,
    path: &Path,
    is_file: bool,
    paths: &mut Vec<String>,
) {
    if !is_file || path.is_absolute() != root.is_absolute() {
        return;
    }
    let Ok(relative) = path.strip_prefix(root) else {
        return;
    };
    let normalized = slash_normalized_relative_path(relative);
    if !normalized.is_empty() {
        paths.push(normalized);
    }
}

fn slash_normalized_relative_path(path: &Path) -> String {
    path.components()
        .filter_map(|component| match component {
            Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("/")
}

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

    #[test]
    fn skill_autocomplete_candidates_use_enabled_skill_discovery_and_refresh_in_place() {
        let mut enabled = SkillDiscovery::default();
        enabled.skills.insert(
            "rust-dev".to_string(),
            crate::skills::Skill {
                name: "rust-dev".to_string(),
                path: PathBuf::from("/tmp/rust-dev/SKILL.md"),
                frontmatter: Default::default(),
                body: String::new(),
            },
        );
        let mut candidates = vec![
            state::AutocompleteCandidate::slash_command("skills", "manage skills"),
            state::AutocompleteCandidate::file_tag("src/lib.rs"),
            state::AutocompleteCandidate::skill_tag("disabled-skill"),
        ];

        refresh_skill_autocomplete_candidates(&mut candidates, &enabled);

        assert!(candidates.iter().any(|candidate| {
            candidate.kind == state::AutocompleteKind::SlashCommand && candidate.name == "skills"
        }));
        assert!(candidates.iter().any(|candidate| {
            candidate.kind == state::AutocompleteKind::FileTag && candidate.name == "src/lib.rs"
        }));
        assert!(
            !candidates
                .iter()
                .any(|candidate| candidate.name == "disabled-skill")
        );
        assert!(candidates.iter().any(|candidate| {
            candidate.kind == state::AutocompleteKind::SkillTag && candidate.name == "rust-dev"
        }));
    }

    #[test]
    fn tui_autocomplete_includes_supported_commands() {
        let registry = CommandRegistry::mvp();

        let candidates = tui_autocomplete_candidates(&registry);
        let slash_names = candidates
            .iter()
            .filter(|candidate| candidate.kind == state::AutocompleteKind::SlashCommand)
            .map(|candidate| candidate.name.as_str())
            .collect::<Vec<_>>();
        let registry_names = registry
            .list()
            .into_iter()
            .map(|command| command.name.as_str())
            .collect::<Vec<_>>();
        let names = candidates
            .iter()
            .map(|candidate| candidate.name.as_str())
            .collect::<Vec<_>>();

        assert_eq!(slash_names, registry_names);
        assert!(slash_names.contains(&"prune-sessions"));
        assert_eq!(
            names,
            vec![
                "changes",
                "compact",
                "help",
                "login",
                "logout",
                "mcp",
                "models",
                "new",
                "prune-sessions",
                "quit",
                "rewind",
                "sessions",
                "setmodel",
                "skills",
                "subagents",
                "system-prompt",
                "tools",
                "usage",
                "tree",
                "git-status"
            ]
        );
        assert!(names.contains(&"changes"));
        assert!(names.contains(&"rewind"));
        assert!(names.contains(&"setmodel"));
        assert!(names.contains(&"mcp"));
        assert!(names.contains(&"login"));
        assert!(names.contains(&"sessions"));
        assert!(names.contains(&"system-prompt"));
        assert!(candidates.iter().any(|candidate| {
            candidate.kind == state::AutocompleteKind::ContextInjection
                && candidate.name == "git-status"
                && candidate.description == "current Git status"
        }));
        assert!(candidates.iter().any(|candidate| {
            candidate.kind == state::AutocompleteKind::ContextInjection
                && candidate.name == "tree"
                && candidate.description == "current directory tree"
        }));
        assert!(candidates.iter().all(|candidate| matches!(
            candidate.kind,
            state::AutocompleteKind::SlashCommand | state::AutocompleteKind::ContextInjection
        )));
    }

    #[test]
    fn tui_file_autocomplete_candidates_are_relative_slash_normalized_regular_files() {
        let temp = tempfile::TempDir::new().unwrap();
        fs::create_dir_all(temp.path().join("src/nested")).unwrap();
        fs::write(temp.path().join("README.md"), "readme").unwrap();
        fs::write(temp.path().join("src/nested/main.rs"), "fn main() {}").unwrap();
        fs::create_dir(temp.path().join("empty-dir")).unwrap();

        let candidates = tui_file_autocomplete_candidates(temp.path(), true);
        let names = candidates
            .iter()
            .map(|candidate| candidate.name.as_str())
            .collect::<Vec<_>>();

        assert_eq!(names, vec!["README.md", "src/nested/main.rs"]);
        assert!(
            candidates
                .iter()
                .all(|candidate| candidate.kind == state::AutocompleteKind::FileTag)
        );
    }

    #[test]
    fn tui_file_autocomplete_candidates_stop_at_visited_entry_limit() {
        let temp = tempfile::TempDir::new().unwrap();
        fs::create_dir_all(temp.path().join("00-first")).unwrap();
        fs::create_dir_all(temp.path().join("01-second")).unwrap();
        fs::create_dir_all(temp.path().join("zz-late")).unwrap();
        fs::write(temp.path().join("zz-late/target.rs"), "late").unwrap();

        let candidates = tui_file_autocomplete_candidates_with_limits(temp.path(), true, 10, 3);

        assert!(
            candidates.is_empty(),
            "late file should not be reached after the root plus first two sorted entries: {candidates:?}"
        );
    }

    #[test]
    fn tui_file_autocomplete_candidates_do_not_follow_symlinked_files() {
        let temp = tempfile::TempDir::new().unwrap();
        fs::write(temp.path().join("real.txt"), "real").unwrap();
        #[cfg(unix)]
        std::os::unix::fs::symlink(temp.path().join("real.txt"), temp.path().join("link.txt"))
            .unwrap();

        let candidates = tui_file_autocomplete_candidates(temp.path(), true);
        let names = candidates
            .iter()
            .map(|candidate| candidate.name.as_str())
            .collect::<Vec<_>>();

        assert!(names.contains(&"real.txt"));
        #[cfg(unix)]
        assert!(!names.contains(&"link.txt"));
    }

    #[test]
    fn tui_file_autocomplete_candidates_respect_gitignore_by_default_policy() {
        let temp = tempfile::TempDir::new().unwrap();
        fs::create_dir_all(temp.path().join("target")).unwrap();
        fs::write(
            temp.path().join(".gitignore"),
            "target/\n*.log\n!important.log\n",
        )
        .unwrap();
        fs::write(temp.path().join("target/generated.rs"), "generated").unwrap();
        fs::write(temp.path().join("debug.log"), "debug").unwrap();
        fs::write(temp.path().join("important.log"), "important").unwrap();
        fs::write(temp.path().join("keep.rs"), "keep").unwrap();

        let candidates = tui_file_autocomplete_candidates(temp.path(), true);
        let names = candidates
            .iter()
            .map(|candidate| candidate.name.as_str())
            .collect::<Vec<_>>();

        assert!(names.contains(&".gitignore"));
        assert!(names.contains(&"keep.rs"));
        assert!(names.contains(&"important.log"));
        assert!(!names.contains(&"debug.log"));
        assert!(!names.contains(&"target/generated.rs"));
    }

    #[test]
    fn tui_file_autocomplete_candidates_can_include_gitignored_files() {
        let temp = tempfile::TempDir::new().unwrap();
        fs::create_dir_all(temp.path().join("target")).unwrap();
        fs::write(temp.path().join(".gitignore"), "target/\n*.log\n").unwrap();
        fs::write(temp.path().join("target/generated.rs"), "generated").unwrap();
        fs::write(temp.path().join("debug.log"), "debug").unwrap();

        let candidates = tui_file_autocomplete_candidates(temp.path(), false);
        let names = candidates
            .iter()
            .map(|candidate| candidate.name.as_str())
            .collect::<Vec<_>>();

        assert!(names.contains(&"debug.log"));
        assert!(names.contains(&"target/generated.rs"));
    }

    #[test]
    fn tui_file_autocomplete_candidates_keep_dotfiles_when_not_ignored() {
        let temp = tempfile::TempDir::new().unwrap();
        fs::create_dir_all(temp.path().join(".github/workflows")).unwrap();
        fs::write(temp.path().join(".github/workflows/ci.yml"), "ci").unwrap();

        let candidates = tui_file_autocomplete_candidates(temp.path(), true);
        let names = candidates
            .iter()
            .map(|candidate| candidate.name.as_str())
            .collect::<Vec<_>>();

        assert!(names.contains(&".github/workflows/ci.yml"));
    }
}