collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! @file / @folder mention parsing and context injection.
//!
//! Users can reference files or directories with `@path` in their messages.
//! This module extracts those mentions, reads the contents, and injects
//! them into the user message as context.
//!
//! Three kinds of `@mention`:
//! - `@src/main.rs` — file with dot/slash → always file mention
//! - `@src`         — no dot/slash, but exists as file/dir on disk → file mention
//! - `@flash`       — no dot/slash, not on disk → delegate to agent mention

use std::fmt::Write;
use std::path::{Path, PathBuf};

/// A parsed @file mention.
#[derive(Debug, Clone)]
pub struct FileMention {
    /// The original text (e.g., "@src/main.rs").
    pub raw: String,
    /// The resolved file path.
    pub path: String,
    /// Whether this mention resolved to a directory.
    pub is_dir: bool,
}

/// Extract @file mentions from user input.
///
/// First pass: syntactic detection (contains `.` or `/`).
/// Use `extract_mentions_with_cwd` for the full cwd-aware detection.
/// This syntactic-only variant is used in headless and non-interactive contexts
/// where disk stats for bare `@name` tokens are unnecessary.
pub fn extract_mentions(input: &str) -> Vec<FileMention> {
    extract_mentions_inner(input, None)
}

/// Extract @file mentions with cwd-aware resolution.
///
/// When `working_dir` is provided, any `@word` that doesn't contain `.` or `/`
/// but maps to an existing file or directory under `working_dir` is treated as
/// a file/folder mention.
pub fn extract_mentions_with_cwd(input: &str, working_dir: &str) -> Vec<FileMention> {
    extract_mentions_inner(input, Some(working_dir))
}

fn extract_mentions_inner(input: &str, working_dir: Option<&str>) -> Vec<FileMention> {
    let mut mentions = Vec::new();

    for word in input.split_whitespace() {
        if !word.starts_with('@') || word.len() < 2 {
            continue;
        }

        let path_str = &word[1..];

        // Skip things that look like emails
        if path_str.contains('@') {
            continue;
        }

        // Strip trailing punctuation
        let path_str = path_str.trim_end_matches([',', '.', ';', ':', ')', ']']);
        if path_str.is_empty() {
            continue;
        }

        let looks_like_path = path_str.contains('.') || path_str.contains('/');

        if looks_like_path {
            // Classic file mention — syntactically obvious
            let is_dir = working_dir
                .map(|wd| {
                    let full = if path_str.starts_with('/') {
                        PathBuf::from(path_str)
                    } else {
                        Path::new(wd).join(path_str)
                    };
                    full.is_dir()
                })
                .unwrap_or(false);

            mentions.push(FileMention {
                raw: format!("@{path_str}"),
                path: path_str.to_string(),
                is_dir,
            });
        } else if let Some(wd) = working_dir {
            // No dot/slash — check if it exists on disk under cwd
            let full = Path::new(wd).join(path_str);
            if full.exists() {
                mentions.push(FileMention {
                    raw: format!("@{path_str}"),
                    path: path_str.to_string(),
                    is_dir: full.is_dir(),
                });
            }
            // If not on disk, leave it for agent mention parsing
        }
    }

    mentions
}

/// Check if a bare `@word` (no dot/slash) exists as a file or directory under
/// the given working directory. Used by agent mention logic to decide whether
/// to treat it as a file mention instead of a model switch.
pub fn exists_on_disk(name: &str, working_dir: &str) -> bool {
    Path::new(working_dir).join(name).exists()
}

/// Resolve mentions and build context injection text.
///
/// Returns the augmented user message with file/folder contents appended,
/// and a list of resolved paths.
pub fn resolve_mentions(
    input: &str,
    working_dir: &str,
    mentions: &[FileMention],
) -> (String, Vec<String>) {
    if mentions.is_empty() {
        return (input.to_string(), Vec::new());
    }

    tracing::debug!(
        mentions = ?mentions.iter().map(|m| m.raw.as_str()).collect::<Vec<_>>(),
        "Resolving @file mentions"
    );

    let mut file_contexts = Vec::new();
    let mut resolved_paths = Vec::new();

    for mention in mentions {
        let full_path = if mention.path.starts_with('/') {
            PathBuf::from(&mention.path)
        } else {
            Path::new(working_dir).join(&mention.path)
        };

        if mention.is_dir || full_path.is_dir() {
            // Directory: build a tree listing
            match build_dir_context(&full_path, &mention.path) {
                Ok(tree) => {
                    file_contexts.push(tree);
                    resolved_paths.push(mention.path.clone());
                }
                Err(e) => {
                    file_contexts.push(format!(
                        "<directory path=\"{}\" error=\"{e}\"/>",
                        mention.path,
                    ));
                }
            }
        } else {
            // File: read contents
            match std::fs::read_to_string(&full_path) {
                Ok(content) => {
                    let content = if content.len() > 50_000 {
                        format!(
                            "{}...\n\n(truncated, {} total bytes)",
                            crate::util::truncate_bytes(&content, 50_000),
                            content.len(),
                        )
                    } else {
                        content
                    };

                    let rel_path = &mention.path;
                    file_contexts.push(format!("<file path=\"{rel_path}\">\n{content}\n</file>"));
                    resolved_paths.push(mention.path.clone());
                }
                Err(e) => {
                    file_contexts.push(format!("<file path=\"{}\" error=\"{e}\"/>", mention.path,));
                }
            }
        }
    }

    let augmented = format!(
        "{input}\n\n---\n**Referenced files:**\n\n{}",
        file_contexts.join("\n\n"),
    );

    (augmented, resolved_paths)
}

/// Build directory context: a tree listing with file sizes.
///
/// Limits: max 200 entries, max 3 levels deep.
fn build_dir_context(dir_path: &Path, rel_prefix: &str) -> Result<String, std::io::Error> {
    let mut output = String::new();
    let _ = writeln!(output, "<directory path=\"{rel_prefix}\">");

    let mut entries = Vec::new();
    collect_dir_entries(dir_path, rel_prefix, 0, 3, &mut entries)?;

    // Sort: directories first, then alphabetical
    entries.sort_by(|a, b| {
        let a_dir = a.ends_with('/');
        let b_dir = b.ends_with('/');
        b_dir.cmp(&a_dir).then(a.cmp(b))
    });

    let total = entries.len();
    for entry in entries.iter().take(200) {
        let _ = writeln!(output, "  {entry}");
    }
    if total > 200 {
        let _ = writeln!(output, "  ... and {} more entries", total - 200);
    }

    let _ = writeln!(output, "</directory>");
    Ok(output)
}

fn collect_dir_entries(
    dir: &Path,
    prefix: &str,
    depth: usize,
    max_depth: usize,
    entries: &mut Vec<String>,
) -> Result<(), std::io::Error> {
    if depth > max_depth {
        return Ok(());
    }

    let mut dir_entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
    dir_entries.sort_by_key(|e| e.file_name());

    for entry in dir_entries {
        let name = entry.file_name();
        let name_str = name.to_string_lossy();

        // Skip hidden files and common noise
        if name_str.starts_with('.') || name_str == "node_modules" || name_str == "target" {
            continue;
        }

        let rel = format!("{prefix}/{name_str}");
        let ft = entry.file_type()?;

        if ft.is_dir() {
            entries.push(format!("{rel}/"));
            collect_dir_entries(&entry.path(), &rel, depth + 1, max_depth, entries)?;
        } else if ft.is_file() {
            let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
            let size_display = if size > 1_000_000 {
                format!("{:.1}MB", size as f64 / 1_000_000.0)
            } else if size > 1_000 {
                format!("{:.1}KB", size as f64 / 1_000.0)
            } else {
                format!("{size}B")
            };
            entries.push(format!("{rel} ({size_display})"));
        }
    }

    Ok(())
}

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

    #[test]
    fn test_extract_mentions() {
        let input = "Look at @src/main.rs and @Cargo.toml for details";
        let mentions = extract_mentions(input);
        assert_eq!(mentions.len(), 2);
        assert_eq!(mentions[0].path, "src/main.rs");
        assert_eq!(mentions[1].path, "Cargo.toml");
    }

    #[test]
    fn test_skip_non_file_mentions() {
        let input = "Talk to @username about the code";
        let mentions = extract_mentions(input);
        assert!(mentions.is_empty());
    }

    #[test]
    fn test_mention_with_trailing_punctuation() {
        let input = "Check @src/lib.rs, then @Cargo.toml.";
        let mentions = extract_mentions(input);
        assert_eq!(mentions.len(), 2);
        assert_eq!(mentions[0].path, "src/lib.rs");
        assert_eq!(mentions[1].path, "Cargo.toml");
    }

    #[test]
    fn test_extract_mentions_with_cwd_bare_dir() {
        let tmp = std::env::temp_dir().join("collet_mention_test_dir");
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(tmp.join("mydir")).unwrap();
        fs::write(tmp.join("myfile.txt"), "hello").unwrap();

        let wd = tmp.to_str().unwrap();

        // @mydir should be detected (exists as dir under cwd)
        let mentions = extract_mentions_with_cwd("look at @mydir", wd);
        assert_eq!(mentions.len(), 1);
        assert_eq!(mentions[0].path, "mydir");
        assert!(mentions[0].is_dir);

        // @nonexistent should NOT be detected
        let mentions = extract_mentions_with_cwd("look at @nonexistent", wd);
        assert!(mentions.is_empty());

        let _ = fs::remove_dir_all(&tmp);
    }

    #[test]
    fn test_extract_mentions_with_cwd_bare_file() {
        let tmp = std::env::temp_dir().join("collet_mention_test_file");
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        fs::write(tmp.join("Makefile"), "all: build").unwrap();

        let wd = tmp.to_str().unwrap();

        // @Makefile has no dot or slash but exists on disk
        let mentions = extract_mentions_with_cwd("check @Makefile", wd);
        assert_eq!(mentions.len(), 1);
        assert_eq!(mentions[0].path, "Makefile");
        assert!(!mentions[0].is_dir);

        let _ = fs::remove_dir_all(&tmp);
    }

    #[test]
    fn test_resolve_dir_mention() {
        let tmp = std::env::temp_dir().join("collet_mention_resolve_dir");
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(tmp.join("src")).unwrap();
        fs::write(tmp.join("src/main.rs"), "fn main() {}").unwrap();
        fs::write(tmp.join("src/lib.rs"), "pub mod foo;").unwrap();

        let wd = tmp.to_str().unwrap();
        let mentions = vec![FileMention {
            raw: "@src".to_string(),
            path: "src".to_string(),
            is_dir: true,
        }];

        let (augmented, resolved) = resolve_mentions("look at @src", wd, &mentions);
        assert_eq!(resolved.len(), 1);
        assert!(augmented.contains("<directory"));
        assert!(augmented.contains("src/lib.rs"));
        assert!(augmented.contains("src/main.rs"));

        let _ = fs::remove_dir_all(&tmp);
    }

    #[test]
    fn test_exists_on_disk() {
        let tmp = std::env::temp_dir().join("collet_mention_exists");
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(tmp.join("subdir")).unwrap();
        fs::write(tmp.join("afile"), "content").unwrap();

        let wd = tmp.to_str().unwrap();
        assert!(exists_on_disk("subdir", wd));
        assert!(exists_on_disk("afile", wd));
        assert!(!exists_on_disk("nope", wd));

        let _ = fs::remove_dir_all(&tmp);
    }
}