mod state_command;
use crate::{config as aid_config, project};
use anyhow::{anyhow, bail, Context, Result};
use serde_json::Value;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
pub enum ProjectAction {
Init,
Show,
State,
Sync,
}
pub fn run_project_command(action: ProjectAction) -> Result<()> {
match action {
ProjectAction::Init => init(),
ProjectAction::Show => show(),
ProjectAction::State => state(),
ProjectAction::Sync => sync(),
}
}
fn init() -> Result<()> {
let git_root = current_git_root()?;
let project_id = prompt_project_id(&git_root)?;
let profile = prompt_profile("Profile (hobby/standard/production)", "standard")?;
let language = prompt_language(detect_language(&git_root).as_deref())?;
let (budget_shorthand, budget_cost, budget_window) =
prompt_daily_budget(default_budget_for_profile(&profile))?;
let team_input = prompt_line("Team (optional)", Some(""), true)?;
let team = if team_input.is_empty() {
None
} else {
Some(team_input)
};
let verify_default = default_verify_for_language(language.as_deref());
let verify_command = prompt_line("Verify command", Some(verify_default), false)?;
let (project_path, knowledge_index) = write_project_config(
&git_root,
&project_id,
&profile,
language.as_deref(),
Some(&budget_shorthand),
team.as_deref(),
Some(verify_command.as_str()),
)?;
aid_config::upsert_budget(&project_id, budget_cost, budget_window.as_deref())?;
println!(" Budget synced to ~/.aid/config.toml");
let config = project::load_project(&project_path)?;
crate::claudemd::sync_claude_md(&git_root, &config)?;
println!(" CLAUDE.md updated with aid section");
println!("Project: {}", config.id);
println!(" Profile: {}", config.profile.as_deref().unwrap_or("-"));
println!(" Language: {}", config.language.as_deref().unwrap_or("-"));
println!(" File: {}", project_path.display());
println!(" Knowledge: {}", knowledge_index.display());
Ok(())
}
fn sync() -> Result<()> {
let git_root = current_git_root()?;
let config = project::detect_project()
.ok_or_else(|| anyhow!("No project configuration found. Run `aid project init` first."))?;
if let Some(cost) = config.budget.cost_limit_usd {
let window = config.budget.window.as_deref();
aid_config::upsert_budget(&config.id, cost, window)?;
println!("Budget synced to ~/.aid/config.toml");
}
crate::claudemd::sync_claude_md(&git_root, &config)?;
println!("CLAUDE.md updated with aid section");
Ok(())
}
fn show() -> Result<()> {
let config = project::detect_project().ok_or_else(|| {
anyhow!("No project configuration found. Run `aid project init` in a git repository.")
})?;
let git_root = current_git_root()?;
println!("Project: {}", config.id);
println!(" Profile: {}", config.profile.as_deref().unwrap_or("-"));
println!(" Team: {}", config.team.as_deref().unwrap_or("-"));
println!(" Language: {}", config.language.as_deref().unwrap_or("-"));
println!(" Verify: {}", config.verify.as_deref().unwrap_or("-"));
println!(" Container: {}", config.container.as_deref().unwrap_or("-"));
let budget_display = if let Some(shorthand) = config.budget.budget_shorthand() {
format!("{shorthand} (shorthand)")
} else if let Some(cost) = config.budget.cost_limit_usd {
let window = config.budget.window.as_deref().unwrap_or("unlimited");
format!("${cost:.2}/{window}")
} else {
"-".to_string()
};
println!(" Budget: {}", budget_display);
match aid_config::effective_budget(&config.id) {
Ok(Some((cost, window))) => {
let window_str = window.as_deref().unwrap_or("unlimited");
println!(
" Effective: ${cost:.2}/{window_str} (synced to ~/.aid/config.toml)"
);
}
Ok(None) => {
println!(" Effective: (not configured in ~/.aid/config.toml)");
}
Err(_) => {}
}
if config.rules.is_empty() {
println!(" Rules: (none)");
} else {
println!(" Rules: {} rule(s)", config.rules.len());
for rule in &config.rules {
println!(" - {rule}");
}
}
let knowledge_entries = project::read_project_knowledge(&git_root);
let knowledge_index = project::project_knowledge_dir(&git_root).join("KNOWLEDGE.md");
println!(" Knowledge: {} entries", knowledge_entries.len());
println!(" Index: {}", knowledge_index.display());
Ok(())
}
fn state() -> Result<()> {
state_command::run()
}
fn write_project_config(
git_root: &Path,
project_id: &str,
profile: &str,
language: Option<&str>,
budget: Option<&str>,
team: Option<&str>,
verify: Option<&str>,
) -> Result<(PathBuf, PathBuf)> {
let aid_dir = git_root.join(".aid");
let project_path = aid_dir.join("project.toml");
fs::create_dir_all(&aid_dir)
.with_context(|| format!("Failed to create {}", aid_dir.display()))?;
let batches_dir = aid_dir.join("batches");
if !batches_dir.exists() {
std::fs::create_dir_all(&batches_dir)?;
aid_info!("[aid] Created .aid/batches/ for batch TOML files");
}
if project_path.exists() {
bail!("Project config already exists at {}", project_path.display());
}
let mut lines = vec![
"[project]".to_string(),
format!("id = \"{}\"", project_id),
format!("profile = \"{}\"", profile),
];
if let Some(lang) = language && !lang.trim().is_empty() {
lines.push(format!("language = \"{}\"", lang.trim()));
}
if let Some(value) = budget && !value.trim().is_empty() {
lines.push(format!("budget = \"{}\"", value.trim()));
}
if let Some(value) = team && !value.trim().is_empty() {
lines.push(format!("team = \"{}\"", value.trim()));
}
if let Some(value) = verify && !value.trim().is_empty() {
lines.push(format!("verify = \"{}\"", value.trim()));
}
lines.push(String::new());
fs::write(&project_path, lines.join("\n"))?;
let knowledge_dir = project::project_knowledge_dir(git_root);
fs::create_dir_all(&knowledge_dir)
.with_context(|| format!("Failed to create {}", knowledge_dir.display()))?;
let knowledge_index = knowledge_dir.join("KNOWLEDGE.md");
if !knowledge_index.exists() {
fs::write(
&knowledge_index,
format!(
"# {project_id} — Project Knowledge\n\n<!-- Add knowledge entries as: - [topic](knowledge/file.md) — description -->\n",
),
)?;
}
Ok((project_path, knowledge_index))
}
fn prompt_project_id(git_root: &Path) -> Result<String> {
let default = git_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("project");
prompt_line("Project ID", Some(default), false)
}
fn prompt_profile(label: &str, default: &str) -> Result<String> {
loop {
let value = prompt_line(label, Some(default), false)?;
let normalized = value.to_lowercase();
match normalized.as_str() {
"hobby" | "standard" | "production" => return Ok(normalized),
_ => aid_error!("Allowed profiles: hobby, standard, production."),
}
}
}
fn prompt_language(default: Option<&str>) -> Result<Option<String>> {
let entry = prompt_line("Language", default, true)?;
if entry.trim().is_empty() {
Ok(None)
} else {
Ok(Some(entry.trim().to_string()))
}
}
fn prompt_daily_budget(default_cost: f64) -> Result<(String, f64, Option<String>)> {
let default_label = format_budget_default_label(default_cost);
loop {
let entry = prompt_line("Daily budget", Some(&default_label), false)?;
match normalize_budget_input(&entry, "day") {
Ok(parsed) => return Ok(parsed),
Err(err) => aid_error!("Invalid budget: {err}"),
}
}
}
fn default_budget_for_profile(profile: &str) -> f64 {
match profile {
"hobby" => 5.0,
"standard" => 20.0,
"production" => 50.0,
_ => 20.0,
}
}
fn format_budget_default_label(cost: f64) -> String {
let amount = if (cost - cost.trunc()).abs() < f64::EPSILON {
format!("{:.0}", cost)
} else {
format!("{cost}")
};
format!("${amount}")
}
fn normalize_budget_input(value: &str, default_window: &str) -> Result<(String, f64, Option<String>)> {
let mut sanitized = value.trim().to_string();
if sanitized.is_empty() {
bail!("Budget cannot be empty");
}
if !sanitized.starts_with('$') {
sanitized.insert(0, '$');
}
if !sanitized.contains('/') {
sanitized.push('/');
sanitized.push_str(default_window);
}
let (cost, window) = parse_budget_value(&sanitized)?;
Ok((sanitized, cost, window))
}
fn parse_budget_value(value: &str) -> Result<(f64, Option<String>)> {
let trimmed = value.trim();
if trimmed.is_empty() {
bail!("budget shorthand is empty");
}
let amount_window = trimmed.strip_prefix('$').unwrap_or(trimmed).trim();
if amount_window.is_empty() {
bail!("budget amount is missing");
}
let (amount_part, window_part) = match amount_window.split_once('/') {
Some((left, right)) => (left.trim(), Some(right.trim())),
None => (amount_window, None),
};
if amount_part.is_empty() {
bail!("budget amount is missing");
}
let cost_limit = amount_part
.parse::<f64>()
.map_err(|_| anyhow!("invalid budget amount '{amount_part}'"))?;
let window = match window_part {
Some(part) if !part.is_empty() => {
match part.to_lowercase().as_str() {
"day" | "daily" => Some("daily".to_string()),
"month" | "monthly" => Some("monthly".to_string()),
other => bail!("unsupported budget window '{other}'"),
}
}
Some(_) => bail!("budget window is empty"),
None => None,
};
Ok((cost_limit, window))
}
fn default_verify_for_language(language: Option<&str>) -> &'static str {
match language {
Some(lang) => {
let lower = lang.to_ascii_lowercase();
match lower.as_str() {
"typescript" | "javascript" | "node" => "npm test",
_ => "cargo test",
}
}
None => "cargo test",
}
}
fn prompt_line(label: &str, default: Option<&str>, allow_empty: bool) -> Result<String> {
loop {
match default {
Some(value) => print!("{label} [{value}]: "),
None => print!("{label}: "),
}
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_string();
if !trimmed.is_empty() {
return Ok(trimmed);
}
if let Some(value) = default {
return Ok(value.to_string());
}
if allow_empty {
return Ok(String::new());
}
aid_error!("{} cannot be empty.", label);
}
}
fn detect_language(git_root: &Path) -> Option<String> {
let cargo = git_root.join("Cargo.toml");
if cargo.is_file() {
return Some("rust".to_string());
}
let package = git_root.join("package.json");
if package.is_file() {
if package_json_has_typescript(&package) {
return Some("typescript".to_string());
}
return Some("javascript".to_string());
}
None
}
fn package_json_has_typescript(path: &Path) -> bool {
let raw = match fs::read_to_string(path) {
Ok(text) => text,
Err(_) => return false,
};
let parsed: Value = match serde_json::from_str(&raw) {
Ok(value) => value,
Err(_) => return false,
};
["dependencies", "devDependencies", "peerDependencies"].iter().any(|key| {
parsed
.get(*key)
.and_then(|deps| deps.as_object())
.is_some_and(|deps| deps.contains_key("typescript"))
})
}
fn current_git_root() -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to run `git rev-parse --show-toplevel`")?;
if !output.status.success() {
bail!("Not inside a git repository");
}
let root = String::from_utf8(output.stdout)
.context("Failed to read git root from git output")?
.trim()
.to_string();
if root.is_empty() {
bail!("Git root path is empty");
}
Ok(PathBuf::from(root))
}