use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::paths;
#[derive(Debug, Clone, Deserialize)]
pub struct TeamFile {
pub team: TeamConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TeamConfig {
pub id: String,
pub display_name: String,
#[serde(default)]
pub description: String,
#[serde(alias = "agents")]
pub preferred_agents: Vec<String>,
pub default_agent: Option<String>,
#[serde(default)]
pub overrides: HashMap<String, CapabilityOverrides>,
#[serde(default)]
pub rules: Vec<String>,
#[serde(default)]
pub toolbox: TeamToolbox,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct TeamToolbox {
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub auto_inject: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CapabilityOverrides {
#[serde(default)]
pub research: Option<i32>,
#[serde(default)]
pub simple_edit: Option<i32>,
#[serde(default)]
pub complex_impl: Option<i32>,
#[serde(default)]
pub frontend: Option<i32>,
#[serde(default)]
pub debugging: Option<i32>,
#[serde(default)]
pub testing: Option<i32>,
#[serde(default)]
pub refactoring: Option<i32>,
#[serde(default)]
pub documentation: Option<i32>,
}
pub fn teams_dir() -> PathBuf {
paths::aid_dir().join("teams")
}
fn load_from_dir(dir: &PathBuf) -> HashMap<String, TeamConfig> {
let mut teams = HashMap::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
continue;
}
match fs::read_to_string(&path) {
Ok(contents) => match parse_team(&contents) {
Ok(config) => {
let id = config.id.clone();
teams.insert(id, config);
}
Err(err) => {
aid_warn!("Failed to parse {}: {}", path.display(), err);
}
},
Err(err) => {
aid_warn!("Failed to read {}: {}", path.display(), err);
}
}
}
}
teams
}
pub fn parse_team(toml_content: &str) -> anyhow::Result<TeamConfig> {
let file: TeamFile = toml::from_str(toml_content)?;
Ok(file.team)
}
pub fn load_teams() -> HashMap<String, TeamConfig> {
load_from_dir(&teams_dir())
}
pub fn resolve_team(name: &str) -> Option<TeamConfig> {
load_teams().remove(name)
}
pub fn list_teams() -> Vec<TeamConfig> {
let registry = load_teams();
let mut teams: Vec<_> = registry.into_values().collect();
teams.sort_by(|a, b| a.id.cmp(&b.id));
teams
}
pub fn team_exists(name: &str) -> bool {
teams_dir().join(format!("{name}.toml")).is_file() || load_teams().contains_key(name)
}
pub fn knowledge_dir(team_id: &str) -> PathBuf {
teams_dir().join(team_id).join("knowledge")
}
pub fn knowledge_index(team_id: &str) -> PathBuf {
teams_dir().join(team_id).join("KNOWLEDGE.md")
}
pub struct KnowledgeEntry {
pub topic: String,
pub path: Option<String>,
pub description: String,
pub content: Option<String>,
}
pub fn read_knowledge_entries(team_id: &str) -> Vec<KnowledgeEntry> {
let index_path = knowledge_index(team_id);
let raw = match fs::read_to_string(&index_path) {
Ok(body) => body,
Err(_) => return Vec::new(),
};
if raw.trim().is_empty() {
return Vec::new();
}
let base = teams_dir().join(team_id);
raw.lines()
.filter_map(|line| parse_knowledge_line(line, &base))
.collect()
}
pub(crate) fn parse_knowledge_line(line: &str, base_dir: &Path) -> Option<KnowledgeEntry> {
let trimmed = line.trim();
if !trimmed.starts_with('-') {
return None;
}
let rest = trimmed[1..].trim_start();
if !rest.starts_with('[') {
return None;
}
let closing = rest.find(']')?;
if closing <= 1 {
return None;
}
let topic = rest[1..closing].trim().to_string();
let mut remainder = rest[closing + 1..].trim_start();
let mut path = None;
if remainder.starts_with('(') {
if let Some(end) = remainder.find(')') {
let segment = remainder[1..end].trim().to_string();
if !segment.is_empty() {
path = Some(segment);
}
remainder = remainder[end + 1..].trim_start();
} else {
return None;
}
}
let description = remainder.split_once('—')?.1.trim();
if description.is_empty() {
return None;
}
let content = path.as_ref().and_then(|relative| {
let target = base_dir.join(relative);
fs::read_to_string(&target)
.ok()
.map(|text| text.trim().to_string())
.filter(|t| !t.is_empty())
});
Some(KnowledgeEntry {
topic,
path,
description: description.to_string(),
content,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn knowledge_dir_for(team_id: &str) -> PathBuf {
teams_dir().join(team_id)
}
fn write_team(dir: &Path, file: &str, contents: &str) {
fs::write(dir.join(file), contents).unwrap();
}
fn sample_team_toml(id: &str) -> String {
format!(
r#"[team]
id = "{id}"
display_name = "{id} team"
preferred_agents = ["codex", "opencode"]
"#,
)
}
#[test]
fn empty_dir_returns_empty() {
let dir = TempDir::new().unwrap();
assert!(load_from_dir(&dir.path().to_path_buf()).is_empty());
}
#[test]
fn loads_valid_toml() {
let dir = TempDir::new().unwrap();
write_team(dir.path(), "dev.toml", &sample_team_toml("dev"));
let map = load_from_dir(&dir.path().to_path_buf());
assert!(map.contains_key("dev"));
assert_eq!(map["dev"].preferred_agents, vec!["codex", "opencode"]);
}
#[test]
fn skips_invalid_toml() {
let dir = TempDir::new().unwrap();
write_team(dir.path(), "bad.toml", "not = valid = toml");
assert!(load_from_dir(&dir.path().to_path_buf()).is_empty());
}
#[test]
fn parses_full_team_with_overrides() {
let toml_data = r#"
[team]
id = "dev"
display_name = "Development Team"
description = "Feature development"
preferred_agents = ["codex", "opencode", "kilo"]
default_agent = "codex"
[team.overrides.opencode]
simple_edit = 10
debugging = 6
[team.overrides.kilo]
simple_edit = 9
"#;
let config = parse_team(toml_data).unwrap();
assert_eq!(config.id, "dev");
assert_eq!(config.preferred_agents.len(), 3);
assert_eq!(config.default_agent, Some("codex".to_string()));
assert_eq!(config.overrides.len(), 2);
assert_eq!(config.overrides["opencode"].simple_edit, Some(10));
assert_eq!(config.overrides["kilo"].simple_edit, Some(9));
}
#[test]
fn parses_team_with_toolbox() {
let toml_data = r#"
[team]
id = "dev"
display_name = "Dev Team"
preferred_agents = ["codex"]
[team.toolbox]
tools = ["lint-check", "test-runner"]
auto_inject = ["lint-check"]
"#;
let config = parse_team(toml_data).unwrap();
assert_eq!(config.toolbox.tools, vec!["lint-check", "test-runner"]);
assert_eq!(config.toolbox.auto_inject, vec!["lint-check"]);
}
#[test]
fn toolbox_defaults_to_empty() {
let config = parse_team(&sample_team_toml("dev")).unwrap();
assert!(config.toolbox.tools.is_empty());
assert!(config.toolbox.auto_inject.is_empty());
}
#[test]
fn list_returns_sorted() {
let dir = TempDir::new().unwrap();
write_team(dir.path(), "b.toml", &sample_team_toml("b"));
write_team(dir.path(), "a.toml", &sample_team_toml("a"));
let map = load_from_dir(&dir.path().to_path_buf());
let mut teams: Vec<_> = map.into_values().collect();
teams.sort_by(|a, b| a.id.cmp(&b.id));
let ids: Vec<_> = teams.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b"]);
}
#[test]
fn read_knowledge_entries_parses_markdown() {
let dir = TempDir::new().unwrap();
let _guard = paths::AidHomeGuard::set(dir.path());
let team_id = "alpha";
let base = knowledge_dir_for(team_id);
fs::create_dir_all(base.join("knowledge")).unwrap();
fs::write(
base.join("KNOWLEDGE.md"),
"- [Topic A](knowledge/guide.md) — Useful guide\n- [Topic B] — General note\n",
)
.unwrap();
fs::write(base.join("knowledge/guide.md"), "Guide content\n").unwrap();
let entries = read_knowledge_entries(team_id);
assert_eq!(entries.len(), 2);
let guide = entries
.iter()
.find(|entry| entry.topic == "Topic A")
.unwrap();
assert_eq!(guide.path.as_deref(), Some("knowledge/guide.md"));
assert_eq!(guide.description, "Useful guide");
assert_eq!(guide.content.as_deref(), Some("Guide content"));
let note = entries
.iter()
.find(|entry| entry.topic == "Topic B")
.unwrap();
assert!(note.path.is_none());
assert!(note.content.is_none());
}
}