use std::path::{Path, PathBuf};
use super::instructions;
pub fn install(global: bool) -> anyhow::Result<()> {
if global {
let conventions_path = global_conventions_path()?;
write_conventions_file(&conventions_path)?;
patch_aider_conf(&conventions_path)?;
eprintln!(
"[tokf] Aider conventions installed to {}",
conventions_path.display()
);
eprintln!("[tokf] Updated ~/.aider.conf.yml to include the conventions file.");
eprintln!("[tokf] Tip: you can also alias aider to auto-prefix commands with tokf run.");
} else {
let conventions_path = PathBuf::from("CONVENTIONS.md");
install_to(&conventions_path)?;
}
Ok(())
}
pub(crate) fn install_to(conventions_path: &Path) -> anyhow::Result<()> {
append_to_conventions(conventions_path)?;
eprintln!(
"[tokf] Aider conventions installed to {}",
conventions_path.display()
);
eprintln!("[tokf] Aider will auto-discover CONVENTIONS.md in the project root.");
Ok(())
}
fn global_conventions_path() -> anyhow::Result<PathBuf> {
let user = crate::paths::user_dir()
.ok_or_else(|| anyhow::anyhow!("could not determine config directory"))?;
Ok(user.join("aider-conventions.md"))
}
fn write_conventions_file(path: &Path) -> anyhow::Result<()> {
let content = instructions::format_for_aider();
super::write_instruction_file(path, &content)
}
fn append_to_conventions(path: &Path) -> anyhow::Result<()> {
super::append_or_replace_section(path, instructions::format_for_aider)
}
fn patch_aider_conf(conventions_path: &Path) -> anyhow::Result<()> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
let conf_path = home.join(".aider.conf.yml");
patch_aider_conf_at(&conf_path, conventions_path)
}
pub(crate) fn patch_aider_conf_at(conf_path: &Path, conventions_path: &Path) -> anyhow::Result<()> {
let conventions_str = conventions_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("conventions path is not valid UTF-8"))?;
let existing = match std::fs::read_to_string(conf_path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
if existing.contains(conventions_str) {
return Ok(());
}
let entry = format!(" - {conventions_str}");
let updated = if existing.contains("\nread:") || existing.starts_with("read:") {
let mut lines: Vec<&str> = existing.lines().collect();
if let Some(pos) = lines.iter().position(|l| l.trim() == "read:") {
lines.insert(pos + 1, &entry);
}
let mut result = lines.join("\n");
if !result.ends_with('\n') {
result.push('\n');
}
result
} else {
let separator = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
format!("{existing}{separator}read:\n{entry}\n")
};
std::fs::write(conf_path, updated)?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn install_to_creates_conventions_file() {
let dir = TempDir::new().unwrap();
let conventions_path = dir.path().join("CONVENTIONS.md");
install_to(&conventions_path).unwrap();
assert!(conventions_path.exists());
let content = std::fs::read_to_string(&conventions_path).unwrap();
assert!(content.contains("tokf run"));
}
#[test]
fn install_to_is_idempotent() {
let dir = TempDir::new().unwrap();
let conventions_path = dir.path().join("CONVENTIONS.md");
install_to(&conventions_path).unwrap();
install_to(&conventions_path).unwrap();
let content = std::fs::read_to_string(&conventions_path).unwrap();
let count = content.matches("<!-- tokf:start -->").count();
assert_eq!(count, 1, "should have exactly one tokf section");
}
#[test]
fn append_preserves_existing_content() {
let dir = TempDir::new().unwrap();
let conventions_path = dir.path().join("CONVENTIONS.md");
std::fs::write(&conventions_path, "# Existing conventions\n").unwrap();
install_to(&conventions_path).unwrap();
let content = std::fs::read_to_string(&conventions_path).unwrap();
assert!(content.starts_with("# Existing conventions\n"));
assert!(content.contains("tokf run"));
}
#[test]
fn patch_aider_conf_creates_new_file() {
let dir = TempDir::new().unwrap();
let conf_path = dir.path().join(".aider.conf.yml");
let conventions_path = dir.path().join("tokf-conventions.md");
patch_aider_conf_at(&conf_path, &conventions_path).unwrap();
let content = std::fs::read_to_string(&conf_path).unwrap();
assert!(content.contains("read:"));
assert!(content.contains("tokf-conventions.md"));
}
#[test]
fn patch_aider_conf_is_idempotent() {
let dir = TempDir::new().unwrap();
let conf_path = dir.path().join(".aider.conf.yml");
let conventions_path = dir.path().join("tokf-conventions.md");
patch_aider_conf_at(&conf_path, &conventions_path).unwrap();
patch_aider_conf_at(&conf_path, &conventions_path).unwrap();
let content = std::fs::read_to_string(&conf_path).unwrap();
let count = content.matches("read:").count();
assert_eq!(count, 1, "should have exactly one read: entry");
}
#[test]
fn patch_aider_conf_preserves_existing() {
let dir = TempDir::new().unwrap();
let conf_path = dir.path().join(".aider.conf.yml");
std::fs::write(&conf_path, "model: gpt-4\n").unwrap();
let conventions_path = dir.path().join("tokf-conventions.md");
patch_aider_conf_at(&conf_path, &conventions_path).unwrap();
let content = std::fs::read_to_string(&conf_path).unwrap();
assert!(content.starts_with("model: gpt-4\n"));
assert!(content.contains("read:"));
}
#[test]
fn patch_aider_conf_appends_to_existing_read_key() {
let dir = TempDir::new().unwrap();
let conf_path = dir.path().join(".aider.conf.yml");
std::fs::write(&conf_path, "read:\n - /other/file.md\n").unwrap();
let conventions_path = dir.path().join("tokf-conventions.md");
patch_aider_conf_at(&conf_path, &conventions_path).unwrap();
let content = std::fs::read_to_string(&conf_path).unwrap();
let read_count = content.matches("read:").count();
assert_eq!(read_count, 1, "should not duplicate read: key");
assert!(content.contains("/other/file.md"));
assert!(content.contains("tokf-conventions.md"));
}
#[test]
fn conventions_content_has_markers() {
let content = instructions::format_for_aider();
assert!(content.contains("<!-- tokf:start -->"));
assert!(content.contains("<!-- tokf:end -->"));
}
}