use anyhow::{Context, Result};
use serde::Deserialize;
use serde::de::{Deserializer, IntoDeserializer};
use std::path::{Path, PathBuf};
use std::{env, fs};
use crate::team::{self, KnowledgeEntry};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProjectFile {
#[serde(rename = "project")]
pub project: ProjectConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Default)]
pub struct ProjectConfig {
pub id: String,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub max_task_cost: Option<f64>,
#[serde(default)]
pub team: Option<String>,
#[serde(default)]
pub verify: Option<String>,
#[serde(default)]
pub container: Option<String>,
#[serde(default)]
pub gitbutler: Option<String>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub rules: Vec<String>,
#[serde(default, deserialize_with = "deserialize_budget")]
pub budget: ProjectBudget,
#[serde(default)]
pub agents: ProjectAgents,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct ProjectBudget {
#[serde(default)]
pub window: Option<String>,
#[serde(default)]
pub cost_limit_usd: Option<f64>,
#[serde(default)]
pub token_limit: Option<u64>,
#[serde(default)]
pub prefer_budget: bool,
}
impl ProjectBudget {
pub fn budget_shorthand(&self) -> Option<String> {
let cost = self.cost_limit_usd?;
let mut shorthand = format!("${}", cost);
if let Some(window) = self.window.as_deref() {
match window.to_lowercase().as_str() {
"day" | "daily" => shorthand.push_str("/day"),
"month" | "monthly" => shorthand.push_str("/month"),
other => {
shorthand.push('/');
shorthand.push_str(other);
}
}
}
Some(shorthand)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct ProjectAgents {
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub research: Option<String>,
#[serde(default)]
pub simple_edit: Option<String>,
}
impl ProjectConfig {
pub fn gitbutler_mode(&self) -> crate::gitbutler::Mode {
let Some(value) = self.gitbutler.as_deref() else {
return crate::gitbutler::Mode::Off;
};
match crate::gitbutler::Mode::from_str(value) {
Ok(mode) => mode,
Err(err) => {
aid_warn!(
"[aid] Warning: invalid project.gitbutler mode '{value}': {err}. Falling back to off."
);
crate::gitbutler::Mode::Off
}
}
}
}
fn deserialize_budget<'de, D>(deserializer: D) -> Result<ProjectBudget, D::Error>
where
D: Deserializer<'de>,
{
let value = toml::Value::deserialize(deserializer)?;
match value {
toml::Value::String(raw) => parse_budget_shorthand(&raw).map_err(serde::de::Error::custom),
toml::Value::Integer(amount) => Ok(ProjectBudget {
cost_limit_usd: Some(amount as f64),
..Default::default()
}),
toml::Value::Float(amount) => Ok(ProjectBudget {
cost_limit_usd: Some(amount),
..Default::default()
}),
toml::Value::Table(table) => ProjectBudget::deserialize(
toml::Value::Table(table).into_deserializer(),
)
.map_err(serde::de::Error::custom),
other => Err(serde::de::Error::custom(format!(
"invalid budget value: {other:?}"
))),
}
}
fn parse_budget_shorthand(value: &str) -> Result<ProjectBudget, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("budget shorthand is empty".to_string());
}
let trimmed = trimmed.strip_prefix('$').unwrap_or(trimmed).trim();
if trimmed.is_empty() {
return Err("budget amount is missing".to_string());
}
let (amount_part, window_part) = match trimmed.split_once('/') {
Some((left, right)) => (left.trim(), Some(right.trim())),
None => (trimmed, None),
};
if amount_part.is_empty() {
return Err("budget amount is missing".to_string());
}
let cost_limit_usd = amount_part
.parse::<f64>()
.map_err(|_| format!("invalid budget amount '{}'", amount_part))?;
let window = match window_part {
Some(w) if !w.is_empty() => match w.to_lowercase().as_str() {
"day" | "daily" => Some("daily".to_string()),
"month" | "monthly" => Some("monthly".to_string()),
other => {
return Err(format!("unsupported budget window '{}'", other));
}
},
Some(_) => return Err("budget window is empty".to_string()),
None => None,
};
Ok(ProjectBudget {
cost_limit_usd: Some(cost_limit_usd),
window,
..Default::default()
})
}
pub fn detect_project() -> Option<ProjectConfig> {
let cwd = env::current_dir().ok()?;
detect_project_in(&cwd)
}
pub fn detect_project_in(start_dir: &Path) -> Option<ProjectConfig> {
let git_root = find_git_root_from(start_dir)?;
let project_path = git_root.join(".aid").join("project.toml");
if !project_path.is_file() {
return None;
}
load_project(&project_path).ok()
}
pub fn load_project(path: &Path) -> Result<ProjectConfig> {
let contents =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let file: ProjectFile =
toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))?;
let mut config = file.project;
apply_profile(&mut config);
Ok(config)
}
pub fn project_knowledge_dir(git_root: &Path) -> PathBuf {
git_root.join(".aid").join("knowledge")
}
pub fn read_project_knowledge(git_root: &Path) -> Vec<KnowledgeEntry> {
let knowledge_dir = project_knowledge_dir(git_root);
let index_path = knowledge_dir.join("KNOWLEDGE.md");
let raw = match fs::read_to_string(&index_path) {
Ok(body) => body,
Err(_) => return Vec::new(),
};
raw.lines()
.filter_map(|line| team::parse_knowledge_line(line, &knowledge_dir))
.collect()
}
fn find_git_root_from(start_dir: &Path) -> Option<PathBuf> {
let mut dir = start_dir.to_path_buf();
loop {
if dir.join(".git").exists() {
return Some(dir);
}
if !dir.pop() {
break;
}
}
None
}
fn apply_profile(config: &mut ProjectConfig) {
let profile = config.profile.as_deref().map(str::to_lowercase);
let profile = match profile {
Some(ref value) => value.as_str(),
None => return,
};
match profile {
"hobby" => apply_hobby_profile(config),
"standard" => apply_standard_profile(config),
"production" => apply_production_profile(config),
_ => {}
}
}
fn apply_hobby_profile(config: &mut ProjectConfig) {
if config.max_task_cost.is_none() {
config.max_task_cost = Some(2.0);
}
if config.budget.cost_limit_usd.is_none() {
config.budget.cost_limit_usd = Some(5.0);
}
config.budget.prefer_budget = true;
}
fn apply_standard_profile(config: &mut ProjectConfig) {
if config.max_task_cost.is_none() {
config.max_task_cost = Some(10.0);
}
if config.verify.is_none() {
config.verify = Some("auto".to_string());
}
if config.budget.cost_limit_usd.is_none() {
config.budget.cost_limit_usd = Some(20.0);
}
append_rule(
&mut config.rules,
"All new functions must have at least one test",
);
config.budget.prefer_budget = false;
}
fn apply_production_profile(config: &mut ProjectConfig) {
if config.max_task_cost.is_none() {
config.max_task_cost = Some(25.0);
}
if config.verify.is_none() {
config.verify = Some(default_production_verify(config));
}
if config.budget.cost_limit_usd.is_none() {
config.budget.cost_limit_usd = Some(50.0);
}
append_rule(&mut config.rules, "All changes must have tests");
append_rule(&mut config.rules, "No unwrap() in production code");
append_rule(&mut config.rules, "Changes require cross-review");
config.budget.prefer_budget = false;
}
fn default_production_verify(config: &ProjectConfig) -> String {
let language = config.language.as_deref().unwrap_or("").to_lowercase();
if language == "typescript" || language == "javascript" || language == "node" {
"npm test".to_string()
} else {
"cargo test".to_string()
}
}
fn append_rule(rules: &mut Vec<String>, rule: &str) {
if !rules.iter().any(|existing| existing == rule) {
rules.push(rule.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn write_project(dir: &Path, contents: &str) -> PathBuf {
let path = dir.join("project.toml");
fs::write(&path, contents).unwrap();
path
}
struct TempCwd {
previous: PathBuf,
}
impl TempCwd {
fn enter(target: &Path) -> Self {
let previous = env::current_dir().unwrap();
env::set_current_dir(target).unwrap();
Self { previous }
}
}
impl Drop for TempCwd {
fn drop(&mut self) {
env::set_current_dir(&self.previous).unwrap();
}
}
#[test]
fn parses_minimal_toml() {
let dir = TempDir::new().unwrap();
let contents = r#"[project]
id = "alpha"
"#;
let config = load_project(&write_project(dir.path(), contents)).unwrap();
assert_eq!(config.id, "alpha");
assert!(config.rules.is_empty());
}
#[test]
fn profile_expands_standard_defaults() {
let dir = TempDir::new().unwrap();
let contents = r#"[project]
id = "beta"
profile = "standard"
"#;
let config = load_project(&write_project(dir.path(), contents)).unwrap();
assert_eq!(config.max_task_cost, Some(10.0));
assert_eq!(config.verify.as_deref(), Some("auto"));
assert_eq!(config.budget.cost_limit_usd, Some(20.0));
assert!(config
.rules
.iter()
.any(|rule| rule.contains("new functions")));
}
#[test]
fn profile_defaults_respect_explicit_values() {
let dir = TempDir::new().unwrap();
let contents = r#"[project]
id = "gamma"
profile = "standard"
max_task_cost = 3.5
verify = "custom verify"
rules = ["explicit rule"]
budget.window = "4h"
budget.cost_limit_usd = 99.5
"#;
let config = load_project(&write_project(dir.path(), contents)).unwrap();
assert_eq!(config.max_task_cost, Some(3.5));
assert_eq!(config.verify.as_deref(), Some("custom verify"));
assert!(config.rules.iter().any(|rule| rule == "explicit rule"));
assert!(config
.rules
.iter()
.any(|rule| rule.contains("new functions")));
assert_eq!(config.budget.cost_limit_usd, Some(99.5));
}
#[test]
fn strict_toml_rejects_unknown_top_level() {
let dir = TempDir::new().unwrap();
let contents = r#"
[project]
id = "test"
[budget]
daily_limit = "$50"
"#;
assert!(load_project(&write_project(dir.path(), contents)).is_err());
}
#[test]
fn budget_shorthand_day() {
let dir = TempDir::new().unwrap();
let contents = r#"[project]
id = "test"
budget = "$1000/day"
"#;
let config = load_project(&write_project(dir.path(), contents)).unwrap();
assert_eq!(config.budget.cost_limit_usd, Some(1000.0));
assert_eq!(config.budget.window.as_deref(), Some("daily"));
assert_eq!(config.budget.budget_shorthand(), Some("$1000/day".to_string()));
}
#[test]
fn budget_shorthand_plain_number() {
let dir = TempDir::new().unwrap();
let contents = r#"[project]
id = "test"
budget = "$500"
"#;
let config = load_project(&write_project(dir.path(), contents)).unwrap();
assert_eq!(config.budget.cost_limit_usd, Some(500.0));
assert!(config.budget.window.is_none());
}
#[test]
fn budget_shorthand_month() {
let dir = TempDir::new().unwrap();
let contents = r#"[project]
id = "test"
budget = "$2000/month"
"#;
let config = load_project(&write_project(dir.path(), contents)).unwrap();
assert_eq!(config.budget.cost_limit_usd, Some(2000.0));
assert_eq!(config.budget.window.as_deref(), Some("monthly"));
}
#[test]
fn parses_container_image() {
let dir = TempDir::new().unwrap();
let contents = r#"[project]
id = "test"
container = "dev:latest"
"#;
let config = load_project(&write_project(dir.path(), contents)).unwrap();
assert_eq!(config.container.as_deref(), Some("dev:latest"));
}
#[test]
fn gitbutler_mode_round_trips_from_toml() {
let dir = TempDir::new().unwrap();
let contents = r#"[project]
id = "test"
gitbutler = "auto"
"#;
let config = load_project(&write_project(dir.path(), contents)).unwrap();
assert_eq!(config.gitbutler.as_deref(), Some("auto"));
assert_eq!(config.gitbutler_mode(), crate::gitbutler::Mode::Auto);
}
#[test]
fn gitbutler_mode_falls_back_to_off_for_invalid_values() {
let config = ProjectConfig {
id: "test".to_string(),
gitbutler: Some("broken".to_string()),
..Default::default()
};
assert_eq!(config.gitbutler_mode(), crate::gitbutler::Mode::Off);
}
#[test]
fn parses_max_task_cost_from_toml() {
let dir = TempDir::new().unwrap();
let contents = r#"[project]
id = "delta"
max_task_cost = 7.25
"#;
let config = load_project(&write_project(dir.path(), contents)).unwrap();
assert_eq!(config.max_task_cost, Some(7.25));
}
#[test]
fn profile_sets_default_max_task_costs() {
let dir = TempDir::new().unwrap();
let hobby = load_project(&write_project(
dir.path(),
r#"[project]
id = "hobby"
profile = "hobby"
"#,
))
.unwrap();
assert_eq!(hobby.max_task_cost, Some(2.0));
let standard = load_project(&write_project(
dir.path(),
r#"[project]
id = "standard"
profile = "standard"
"#,
))
.unwrap();
assert_eq!(standard.max_task_cost, Some(10.0));
let production = load_project(&write_project(
dir.path(),
r#"[project]
id = "production"
profile = "production"
"#,
))
.unwrap();
assert_eq!(production.max_task_cost, Some(25.0));
}
#[test]
fn detect_project_returns_none_outside_git() {
let dir = TempDir::new().unwrap();
let _guard = TempCwd::enter(dir.path());
assert!(detect_project().is_none());
}
#[test]
fn test_read_project_knowledge() {
let dir = TempDir::new().unwrap();
let git_root = dir.path();
fs::create_dir_all(git_root.join(".git")).unwrap();
let knowledge_dir = project_knowledge_dir(git_root);
fs::create_dir_all(&knowledge_dir).unwrap();
fs::write(
knowledge_dir.join("KNOWLEDGE.md"),
"- [Guide](guide.md) — Useful knowledge\n- [Note] — Standalone note\n",
)
.unwrap();
fs::write(knowledge_dir.join("guide.md"), "Details\n").unwrap();
let entries = read_project_knowledge(git_root);
assert_eq!(entries.len(), 2);
let guide = entries
.into_iter()
.find(|entry| entry.topic == "Guide")
.unwrap();
assert_eq!(guide.path.as_deref(), Some("guide.md"));
assert_eq!(guide.description, "Useful knowledge");
assert_eq!(guide.content.as_deref(), Some("Details"));
}
}