use crate::agent::mentions;
use crate::agent::prompt;
use crate::config::AgentDef;
use crate::repo_map::RepoMap;
use super::App;
pub(super) fn popup_max_scroll(popup: &crate::tui::state::PopupState, term_height: u16) -> u16 {
use crate::tui::state::PopupKind;
let content_lines = match &popup.kind {
PopupKind::Info => popup.content.lines().count(),
PopupKind::Select { items, .. } => items.len() + 2,
PopupKind::TableSelect { items, .. } => items.len(),
PopupKind::Config { items, .. } => items.len(),
PopupKind::SessionResume { items, .. } => items.len(),
PopupKind::QueueConfirm { pending, .. } => pending.len() + 4,
PopupKind::ContinuationConfirm { .. } => 6,
PopupKind::ThemeSelect { .. } => crate::tui::theme::Theme::families().len(),
PopupKind::ModeApproval { .. } => 8,
PopupKind::McpToggle { items, .. } => items.len(),
PopupKind::PiiWarning { findings, .. } => findings.len() + 5,
PopupKind::OptimizeSuggestion { items, .. } => items.len() * 4 + 6,
PopupKind::LspInstall { .. } => 8,
PopupKind::ToolApproval { .. } => 9,
PopupKind::InitConfirm { .. } => 6,
} as u16;
let popup_h = (term_height as f32 * 0.75).max(12.0) as u16;
let inner_h = popup_h.saturating_sub(2); content_lines.saturating_sub(inner_h)
}
pub(super) fn apply_config_changes(app: &mut App, items: &[crate::tui::state::ConfigItem]) {
use crate::tui::state::ConfigValue;
for item in items {
match item.key.as_str() {
"model" => {
if let ConfigValue::Choice { ref value, .. } = item.value {
app.config.model = value.clone();
app.client.model = value.clone();
app.state.model_name = value.clone();
}
}
"theme" => {
if let ConfigValue::Choice { ref value, .. } = item.value {
app.state.set_theme(value);
app.config.theme = value.clone();
let _ = crate::config::save_ui_theme(value);
}
}
"auto_commit" => {
if let ConfigValue::Bool(b) = item.value {
app.config.auto_commit = b;
}
}
"max_iterations" => {
if let ConfigValue::Choice { ref value, .. } = item.value
&& let Ok(n) = value.parse::<u32>()
{
app.config.max_iterations = n;
}
}
"tool_timeout_secs" => {
if let ConfigValue::Choice { ref value, .. } = item.value
&& let Ok(n) = value.parse::<u64>()
{
app.config.tool_timeout_secs = n;
}
}
"compaction_threshold" => {
if let ConfigValue::Choice { ref value, .. } = item.value
&& let Ok(f) = value.parse::<f32>()
{
app.config.compaction_threshold = f;
}
}
"context_max_tokens" => {
if let ConfigValue::Choice { ref value, .. } = item.value
&& let Ok(n) = value.parse::<usize>()
{
app.config.context_max_tokens = n;
app.state.context_max_tokens = n;
}
}
"debug_mode" => {
if let ConfigValue::Bool(b) = item.value {
app.config.debug_mode = b;
app.state.debug_mode = b;
if b && app.metrics_collector.is_none() {
app.metrics_collector =
Some(crate::util::process_metrics::MetricsCollector::new());
} else if !b {
app.metrics_collector = None;
}
}
}
"collab_mode" => {
if let ConfigValue::Choice { ref value, .. } = item.value {
use crate::agent::swarm::config::CollaborationMode;
app.config.collaboration.mode = match value.as_str() {
"fork" => CollaborationMode::Fork,
"hive" => CollaborationMode::Hive,
"flock" => CollaborationMode::Flock,
_ => CollaborationMode::None,
};
}
}
"collab_worktree" => {
if let ConfigValue::Bool(b) = item.value {
app.config.collaboration.worktree = b;
}
}
"collab_max_agents" => {
if let ConfigValue::Choice { ref value, .. } = item.value
&& let Ok(n) = value.parse::<usize>()
{
app.config.collaboration.max_agents = n;
}
}
"collab_strategy" => {
if let ConfigValue::Choice { ref value, .. } = item.value {
use crate::agent::swarm::config::SwarmStrategy;
app.config.collaboration.strategy = match value.as_str() {
"role_based" => SwarmStrategy::RoleBased,
"plan_review_execute" => SwarmStrategy::PlanReviewExecute,
_ => SwarmStrategy::AutoSplit,
};
}
}
"collab_conflict_resolution" => {
if let ConfigValue::Choice { ref value, .. } = item.value {
use crate::agent::swarm::config::ConflictResolution;
app.config.collaboration.conflict_resolution = match value.as_str() {
"last_writer_wins" => ConflictResolution::LastWriterWins,
"user_resolves" => ConflictResolution::UserResolves,
_ => ConflictResolution::CoordinatorResolves,
};
}
}
_ => {}
}
}
if let Err(e) = persist_config(&app.config) {
app.state.status_msg = format!("Config save error: {e}");
} else {
app.state.status_msg = "Configuration saved".to_string();
}
}
pub(super) fn persist_config(config: &crate::config::Config) -> anyhow::Result<()> {
use crate::config::{ConfigFile, config_file_path, ensure_config_dir};
ensure_config_dir()?;
let path = config_file_path();
let mut file_cfg: ConfigFile = if path.exists() {
let raw = std::fs::read_to_string(&path)?;
toml::from_str(&raw)?
} else {
ConfigFile::default()
};
file_cfg.api.model = Some(config.model.clone());
file_cfg.ui.theme = Some(config.theme.clone());
file_cfg.hooks.auto_commit = Some(config.auto_commit);
file_cfg.agent.max_iterations = Some(config.max_iterations);
file_cfg.agent.tool_timeout_secs = Some(config.tool_timeout_secs);
file_cfg.agent.stream_max_retries = Some(config.stream_max_retries);
file_cfg.agent.iteration_delay_ms = Some(config.iteration_delay_ms);
file_cfg.context.compaction_threshold = Some(config.compaction_threshold);
file_cfg.context.max_tokens = Some(config.context_max_tokens);
file_cfg.ui.debug_mode = Some(config.debug_mode);
file_cfg.collaboration.mode = Some(config.collaboration.mode.clone());
file_cfg.collaboration.max_agents = Some(config.collaboration.max_agents);
file_cfg.collaboration.worktree = Some(config.collaboration.worktree);
file_cfg.collaboration.strategy = Some(config.collaboration.strategy.clone());
file_cfg.collaboration.conflict_resolution =
Some(config.collaboration.conflict_resolution.clone());
let default_home = crate::config::collet_home(None);
if config.collet_home != default_home {
file_cfg.paths.home = Some(config.collet_home.to_string_lossy().into_owned());
}
crate::config::write_config_commented(&path, &file_cfg)?;
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub(super) enum InitMode {
Merge,
Replace,
}
pub(super) fn build_init_prompt(working_dir: &str, mode: InitMode) -> String {
let agents_md_path = format!("{working_dir}/AGENTS.md");
let mode_instruction = match mode {
InitMode::Merge => format!(
"Read the existing `{agents_md_path}` first. \
Preserve any user-customized content (custom agent roles, hand-written Boundaries, etc.) \
and update only sections that have changed based on the current project state."
),
InitMode::Replace => format!(
"Discard the existing `{agents_md_path}` and regenerate it entirely from scratch."
),
};
format!(
r#"Generate `{agents_md_path}` from thorough project analysis.
{mode_instruction}
## Steps
1. Read: README, Cargo.toml/package.json/go.mod/pubspec.yaml, entry points, CI config, tests, lint config
2. If CONTRIBUTING.md or STYLE_GUIDE.md exists → treat as authoritative for Boundaries/Code Style
3. Extract: languages+versions, commands (build/test/lint/format/codegen with flags), directory structure, conventions (naming, error handling, state management, async), git workflow, boundaries
4. Assign agent roles: `code` (always), `architect` (complex projects), `ask` (read-only), plus project-specific if needed
5. Write AGENTS.md using template below — fill ALL sections from analysis
## Template
```markdown
# AGENTS.md
## Commands
- Build: `<exact command>` | Test: `<with flags>` | Test single: `<single file>`
- Lint: `<command>` | Format: `<command>` | Run: `<dev server>`
- Gen/Sync: `<codegen commands or "N/A">`
## Project Structure
- `<dir>/` — <purpose> (list all key directories)
## Code Style
- Language/version, naming, error handling, imports, state/async patterns
### Golden Path
```<lang>
// 5-10 line ideal code snippet from this project
```
## Testing
- Framework | Run all: `<cmd>` | Coverage: `<cmd or "N/A">`
- File naming: `<pattern>` | Mocking: `<strategy or "N/A">`
## Git Workflow
- Branch strategy | Commit format | PR requirements
## Boundaries
- Always: <mandatory actions>
- Ask first: <needs approval>
- Never: <forbidden actions — be specific>
- Never: suppress linter warnings inline — fix the root cause instead
<LINT_SUPPRESS_RULES: replace with language-specific rule if language detected, otherwise keep this line as-is>
```
## Rules
- Fill from analysis; if unknown → "Not observed". Prioritize existing docs (CONTRIBUTING.md etc.)
- Concise: snippets > prose. Commands: include exact flags. Boundaries: specific, not vague
- For <LINT_SUPPRESS_RULES>: detect project languages from Cargo.toml/package.json/go.mod/pyproject.toml/pubspec.yaml,
then replace the placeholder with the matching never-rules:
- Rust → `Never: Use \`#[allow(...)]\` to suppress warnings — fix the root cause; use \`#[expect(...)]\` only with a tracking issue comment`
- TypeScript/JavaScript → `Never: Use \`// eslint-disable\` or \`// @ts-ignore\` — fix the type/lint error directly`
- Python → `Never: Use \`# noqa\` or \`# type: ignore\` — fix the lint/type error at the source`
- Go → `Never: Use \`//nolint:\` directives — address the linter finding instead`
- Dart/Flutter → `Never: Use \`// ignore:\` or \`// ignore_for_file:\` — resolve the analyzer warning`
- If a language is not listed, omit the placeholder entirely.
- Report what you created when done."#
)
}
pub(super) fn build_system_prompt(repo_map: &RepoMap, reasoning: Option<&str>) -> String {
build_system_prompt_with_agent(repo_map, reasoning, None, None)
}
pub(super) fn build_system_prompt_with_agent(
repo_map: &RepoMap,
reasoning: Option<&str>,
agent_behavior: Option<&str>,
soul_content: Option<&str>,
) -> String {
prompt::build_prompt_with_agent(
&repo_map.to_map_string(),
repo_map.file_count(),
repo_map.symbol_count(),
reasoning,
agent_behavior,
None, soul_content,
)
}
pub(super) fn detect_available_lsp(working_dir: &str) -> Vec<String> {
use crate::lsp::client::{extension_to_language_id, find_server_for_language, known_servers};
use std::collections::HashSet;
let project_langs: HashSet<String> = walkdir(working_dir)
.filter_map(|ext| extension_to_language_id(ext.as_str()).map(|id| id.to_string()))
.collect();
let mut langs = project_langs.clone();
if langs.contains("cpp") {
langs.insert("c".to_string());
}
if langs.contains("javascript") {
langs.insert("typescript".to_string());
}
let candidate_commands: Vec<String> = langs
.iter()
.filter_map(|lang| find_server_for_language(lang))
.map(|s| s.command)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
use rayon::prelude::*;
let all_servers: Vec<_> = known_servers()
.into_iter()
.filter(|s| candidate_commands.contains(&s.command))
.collect();
let mut available: Vec<String> = all_servers
.into_par_iter()
.filter(|server| {
std::process::Command::new("which")
.arg(&server.command)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
.map(|server| server.command)
.collect();
available.dedup();
tracing::info!(count = available.len(), servers = ?available, "LSP servers detected");
available
}
pub(super) fn detect_missing_lsp(working_dir: &str) -> Vec<(String, String, String)> {
use crate::lsp::client::{extension_to_language_id, find_missing_server_for_language};
use std::collections::HashSet;
let project_langs: HashSet<String> = walkdir(working_dir)
.filter_map(|ext| extension_to_language_id(ext.as_str()).map(|id| id.to_string()))
.collect();
let mut langs = project_langs.clone();
if langs.contains("cpp") {
langs.insert("c".to_string());
}
if langs.contains("javascript") {
langs.insert("typescript".to_string());
}
let mut missing = Vec::new();
let mut seen_langs = HashSet::new();
for lang in &langs {
if seen_langs.contains(lang) {
continue;
}
if let Some(cfg) = find_missing_server_for_language(lang)
&& let Some(hint) = cfg.install_hint
{
missing.push((lang.clone(), cfg.command.clone(), hint.to_string()));
seen_langs.insert(lang.clone());
}
}
if !missing.is_empty() {
tracing::info!(count = missing.len(), langs = ?missing.iter().map(|(l,_,_)| l).collect::<Vec<_>>(), "Missing LSP servers detected");
}
missing
}
pub(super) fn walkdir(dir: &str) -> impl Iterator<Item = String> {
let mut exts = std::collections::HashSet::new();
let root = std::path::Path::new(dir);
fn collect(path: &std::path::Path, exts: &mut std::collections::HashSet<String>, depth: u8) {
if depth > 8 {
return;
}
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.')
|| matches!(
name_str.as_ref(),
"target" | "node_modules" | "vendor" | "dist" | "build"
)
{
continue;
}
if p.is_dir() {
collect(&p, exts, depth + 1);
} else if let Some(ext) = p.extension().and_then(|e| e.to_str()) {
exts.insert(ext.to_lowercase());
}
}
}
collect(root, &mut exts, 0);
exts.into_iter()
}
pub(super) fn at_prefix_at_cursor(input: &str, cursor: usize) -> Option<String> {
let before = &input[..cursor.min(input.len())];
let mut chars: Vec<(usize, char)> = before.char_indices().collect();
chars.reverse();
let mut token = String::new();
for (byte_pos, ch) in chars {
if ch == '@' {
let prev_ok = byte_pos == 0
|| input[..byte_pos]
.chars()
.last()
.map(|c| c.is_whitespace())
.unwrap_or(true);
if prev_ok {
let collected: String = token.chars().rev().collect();
return Some(collected);
}
return None;
}
if ch.is_whitespace() {
return None;
}
token.push(ch);
}
None
}
pub(super) fn at_start_pos(input: &str, cursor: usize) -> Option<usize> {
let before = &input[..cursor.min(input.len())];
let chars: Vec<(usize, char)> = before.char_indices().collect();
for i in (0..chars.len()).rev() {
let (byte_pos, ch) = chars[i];
if ch == '@' {
let prev_ok = byte_pos == 0
|| input[..byte_pos]
.chars()
.last()
.map(|c| c.is_whitespace())
.unwrap_or(true);
if prev_ok {
return Some(byte_pos);
}
return None;
}
if ch.is_whitespace() {
return None;
}
}
None
}
pub(super) fn extract_agent_mention(
input: &str,
agents: &[AgentDef],
working_dir: &str,
) -> (String, Vec<AgentDef>) {
let mut result_input = input.to_string();
let mut matched: Vec<AgentDef> = Vec::new();
let words: Vec<&str> = input.split_whitespace().collect();
for word in words {
if !word.starts_with('@') || word.len() < 2 {
continue;
}
let raw = &word[1..];
if raw.contains('/') || raw.contains('.') {
continue;
}
if mentions::exists_on_disk(raw, working_dir) {
continue;
}
let q = raw.to_lowercase();
let resolve = |agents: &[AgentDef]| -> Option<AgentDef> {
if let Some(a) = agents.iter().find(|a| a.name.to_lowercase() == q) {
return Some(a.clone());
}
if let Some(a) = agents.iter().find(|a| a.model.to_lowercase() == q) {
return Some(a.clone());
}
let name_hits: Vec<_> = agents
.iter()
.filter(|a| a.name.to_lowercase().contains(&q))
.collect();
if name_hits.len() == 1 {
return Some(name_hits[0].clone());
}
let model_hits: Vec<_> = agents
.iter()
.filter(|a| a.model.to_lowercase().contains(&q))
.collect();
if model_hits.len() == 1 {
return Some(model_hits[0].clone());
}
None
};
if let Some(agent) = resolve(agents) {
if !matched.iter().any(|a| a.name == agent.name) {
matched.push(agent);
}
result_input = result_input.replacen(word, "", 1);
result_input = result_input
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
}
}
(result_input, matched)
}
pub(super) fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut end_byte = 0;
for (count, (idx, ch)) in s.char_indices().enumerate() {
if count == max {
break;
}
end_byte = idx + ch.len_utf8();
}
format!("{}...", &s[..end_byte])
}
pub(super) fn should_suggest_mode(msg: &str, config: &crate::config::Config) -> bool {
if !config.collaboration.auto_suggest {
return false;
}
let lower = msg.to_lowercase();
let long_enough = msg.len() > 300;
let has_keyword = [
"implement",
"refactor",
"build",
"migrate",
"integrate",
"add feature",
"create",
"redesign",
"rewrite",
"구현",
"리팩토링",
"마이그레이션",
"통합",
"개발",
"만들어",
"작성해",
"전체",
"모두",
"모든",
"일괄",
"일관",
]
.iter()
.any(|kw| lower.contains(kw));
long_enough || has_keyword
}
pub(super) struct ModeRecommendation {
pub(super) index: usize,
pub(super) reason: String,
}
pub(super) fn recommend_collab_mode(msg: &str) -> ModeRecommendation {
let lower = msg.to_lowercase();
let file_exts = [
".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".vue", ".svelte",
];
let file_count: usize = file_exts.iter().map(|ext| lower.matches(ext).count()).sum();
let dir_seps = msg.matches('/').count();
let consensus_keywords = [
"review",
"합의",
"검토",
"리뷰",
"validate",
"verify",
"확인",
"plan",
"설계",
"design",
"architecture",
"아키텍처",
"pros and cons",
"장단점",
"trade-off",
"트레이드오프",
];
let needs_consensus = consensus_keywords.iter().any(|kw| lower.contains(kw));
let parallel_keywords = [
"동시에",
"병렬",
"parallel",
"simultaneously",
"independently",
"각각",
"separately",
"both",
"모두 동시",
"and also",
"그리고 또",
];
let wants_parallel = parallel_keywords.iter().any(|kw| lower.contains(kw));
let multi_concern_keywords = [
"frontend and backend",
"프론트엔드와 백엔드",
"client and server",
"클라이언트와 서버",
"api and ui",
"test and implement",
"테스트와 구현",
];
let multi_concern = multi_concern_keywords.iter().any(|kw| lower.contains(kw));
if needs_consensus {
ModeRecommendation {
index: 1, reason: "A task requiring design/review/consensus was detected.\nHive mode is recommended (agents reach consensus before execution).".to_string(),
}
} else if multi_concern && (file_count > 2 || msg.len() > 500) {
ModeRecommendation {
index: 2, reason: "A complex task spanning multiple domains was detected.\nFlock mode is recommended (real-time coordination between agents).".to_string(),
}
} else if file_count > 1 || dir_seps > 2 || wants_parallel {
ModeRecommendation {
index: 0, reason: "Independent tasks spanning multiple files/modules were detected.\nFork mode is recommended (parallel split → execute → merge).".to_string(),
}
} else if msg.len() > 500 {
ModeRecommendation {
index: 0, reason: "A detailed multi-step task was detected.\nFork mode is recommended (parallel split → execute → merge).".to_string(),
}
} else {
ModeRecommendation {
index: 3, reason: "A complex task was detected.\nPlease select an execution mode.".to_string(),
}
}
}
pub(super) fn resolve_theme_id(family_idx: usize, dark_mode: bool) -> &'static str {
let families = crate::tui::theme::Theme::families();
let family = match families.get(family_idx) {
Some(f) => f,
None => return "default",
};
if dark_mode {
family.dark_id
} else {
family.light_id.unwrap_or(family.dark_id)
}
}
pub(super) fn mcp_json_path_for_toggle(working_dir: &str) -> std::path::PathBuf {
let project = std::path::Path::new(working_dir)
.join(".collet")
.join("mcp.json");
if project.exists() {
project
} else {
crate::config::collet_home(None).join("mcp.json")
}
}
pub(super) fn build_mcp_toggle_items(
state: &crate::tui::state::UiState,
working_dir: &str,
) -> Vec<crate::tui::state::McpToggleItem> {
use crate::mcp::config::McpStatus;
use crate::tui::state::McpToggleItem;
let config_path = mcp_json_path_for_toggle(working_dir);
let raw = std::fs::read_to_string(&config_path).unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_default();
let servers_obj = parsed
.get("mcpServers")
.or_else(|| parsed.get("servers"))
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let mut items = Vec::new();
for (name, cfg) in &servers_obj {
let disabled = cfg
.get("disabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let sidebar_status = state
.mcp_servers
.iter()
.find(|s| &s.name == name)
.map(|s| match s.status {
McpStatus::Available => "available",
McpStatus::Unavailable => "unavailable",
})
.unwrap_or("unknown");
let tool_count = state
.mcp_servers
.iter()
.find(|s| &s.name == name)
.map(|_| 0usize) .unwrap_or(0);
items.push(McpToggleItem {
name: name.clone(),
enabled: !disabled,
tool_count,
status: sidebar_status.to_string(),
description: String::new(),
});
}
items
}
#[cfg(test)]
mod tests {
use super::*;
fn agents() -> Vec<AgentDef> {
vec![
AgentDef {
name: "code".into(),
model: "glm-4.5".into(),
..AgentDef::default()
},
AgentDef {
name: "flash".into(),
model: "glm-4.5-air".into(),
..AgentDef::default()
},
AgentDef {
name: "pro".into(),
model: "glm-4.7".into(),
..AgentDef::default()
},
AgentDef {
name: "architect".into(),
model: "glm-4-plus".into(),
..AgentDef::default()
},
AgentDef {
name: "ultra".into(),
model: "glm-5".into(),
..AgentDef::default()
},
]
}
#[test]
fn test_agent_mention_exact_name() {
let (text, agents_found) =
extract_agent_mention("@architect fix the bug", &agents(), "/nonexistent");
assert_eq!(agents_found.len(), 1);
assert_eq!(agents_found[0].name, "architect");
assert_eq!(agents_found[0].model, "glm-4-plus");
assert_eq!(text, "fix the bug");
}
#[test]
fn test_agent_mention_exact_model() {
let (text, agents_found) =
extract_agent_mention("@glm-5 fix the bug", &agents(), "/nonexistent");
assert_eq!(agents_found.len(), 1);
assert_eq!(agents_found[0].name, "ultra");
assert_eq!(text, "fix the bug");
}
#[test]
fn test_agent_mention_name_prefix_unique() {
let (text, agents_found) =
extract_agent_mention("@arch plan this", &agents(), "/nonexistent");
assert_eq!(agents_found.len(), 1);
assert_eq!(agents_found[0].name, "architect");
assert_eq!(text, "plan this");
}
#[test]
fn test_agent_mention_ambiguous_no_match() {
let (_, agents_found) = extract_agent_mention("@zzz fix it", &agents(), "/nonexistent");
assert!(agents_found.is_empty());
}
#[test]
fn test_agent_mention_file_not_matched() {
let (text, agents_found) =
extract_agent_mention("fix @src/main.rs please", &agents(), "/nonexistent");
assert!(agents_found.is_empty());
assert!(text.contains("@src/main.rs"));
}
#[test]
fn test_agent_mention_no_mention() {
let (text, agents_found) =
extract_agent_mention("just a normal message", &agents(), "/nonexistent");
assert!(agents_found.is_empty());
assert_eq!(text, "just a normal message");
}
#[test]
fn test_agent_mention_at_end() {
let (text, agents_found) =
extract_agent_mention("explain this @ultra", &agents(), "/nonexistent");
assert_eq!(agents_found.len(), 1);
assert_eq!(agents_found[0].model, "glm-5");
assert_eq!(text, "explain this");
}
#[test]
fn test_agent_mention_case_insensitive() {
let (_, agents_found) =
extract_agent_mention("@ARCHITECT do it", &agents(), "/nonexistent");
assert_eq!(agents_found[0].name, "architect");
}
#[test]
fn test_agent_mention_skipped_when_dir_exists() {
let tmp = std::env::temp_dir().join("collet_agent_dir_test");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("code")).unwrap();
let wd = tmp.to_str().unwrap();
let (text, agents_found) = extract_agent_mention("@code fix it", &agents(), wd);
assert!(
agents_found.is_empty(),
"Should skip @code because it's a directory on disk"
);
assert!(text.contains("@code"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_agent_mention_multiple() {
let (text, agents_found) = extract_agent_mention(
"@architect and @ultra review this",
&agents(),
"/nonexistent",
);
assert_eq!(agents_found.len(), 2);
assert_eq!(agents_found[0].name, "architect");
assert_eq!(agents_found[1].name, "ultra");
assert_eq!(text, "and review this");
}
#[test]
fn test_agent_mention_dedup() {
let (_, agents_found) =
extract_agent_mention("@architect and @architect", &agents(), "/nonexistent");
assert_eq!(agents_found.len(), 1);
}
}