use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub fn discover_instruction_files(working_dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
let mut dir = working_dir.to_path_buf();
loop {
let candidate = dir.join("HEARTBIT.md");
if candidate.is_file() {
paths.push(candidate);
}
if dir.join(".git").exists() {
break;
}
if !dir.pop() {
break;
}
}
if let Some(home) = home_dir() {
let global = home.join(".config").join("heartbit").join("HEARTBIT.md");
if global.is_file() {
paths.push(global);
}
}
paths
}
pub fn load_instructions(paths: &[PathBuf]) -> std::io::Result<String> {
let mut seen = HashSet::new();
let mut sections = Vec::new();
for path in paths {
let content = std::fs::read_to_string(path)?;
let content = content.trim().to_string();
if content.is_empty() {
continue;
}
if seen.contains(&content) {
continue;
}
seen.insert(content.clone());
sections.push(content);
}
if sections.is_empty() {
return Ok(String::new());
}
Ok(sections.join("\n\n---\n\n"))
}
pub fn prepend_instructions(system_prompt: &str, instructions: &str) -> String {
if instructions.is_empty() {
return system_prompt.to_string();
}
format!("# Project Instructions\n\n{instructions}\n\n---\n\n{system_prompt}")
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn discover_finds_heartbit_md_in_working_dir() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("HEARTBIT.md"), "instructions").unwrap();
let paths = discover_instruction_files(dir.path());
assert_eq!(paths.len(), 1);
assert_eq!(paths[0], dir.path().join("HEARTBIT.md"));
}
#[test]
fn discover_walks_up_to_git_root() {
let root = TempDir::new().unwrap();
std::fs::create_dir(root.path().join(".git")).unwrap();
std::fs::write(root.path().join("HEARTBIT.md"), "root instructions").unwrap();
let sub = root.path().join("src").join("module");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("HEARTBIT.md"), "module instructions").unwrap();
let paths = discover_instruction_files(&sub);
assert_eq!(paths.len(), 2);
assert_eq!(paths[0], sub.join("HEARTBIT.md"));
assert_eq!(paths[1], root.path().join("HEARTBIT.md"));
}
#[test]
fn discover_stops_at_git_root() {
let root = TempDir::new().unwrap();
let project = root.path().join("project");
std::fs::create_dir(&project).unwrap();
std::fs::create_dir(project.join(".git")).unwrap();
std::fs::write(root.path().join("HEARTBIT.md"), "above git root").unwrap();
std::fs::write(project.join("HEARTBIT.md"), "in project").unwrap();
let paths = discover_instruction_files(&project);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0], project.join("HEARTBIT.md"));
}
#[test]
fn discover_returns_empty_when_no_files() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let paths = discover_instruction_files(dir.path());
assert!(paths.is_empty());
}
#[test]
fn load_concatenates_with_separator() {
let dir = TempDir::new().unwrap();
let file1 = dir.path().join("a.md");
let file2 = dir.path().join("b.md");
std::fs::write(&file1, "First").unwrap();
std::fs::write(&file2, "Second").unwrap();
let result = load_instructions(&[file1, file2]).unwrap();
assert_eq!(result, "First\n\n---\n\nSecond");
}
#[test]
fn load_deduplicates_by_content() {
let dir = TempDir::new().unwrap();
let file1 = dir.path().join("a.md");
let file2 = dir.path().join("b.md");
std::fs::write(&file1, "Same content").unwrap();
std::fs::write(&file2, "Same content").unwrap();
let result = load_instructions(&[file1, file2]).unwrap();
assert_eq!(result, "Same content");
}
#[test]
fn load_skips_empty_files() {
let dir = TempDir::new().unwrap();
let file1 = dir.path().join("a.md");
let file2 = dir.path().join("b.md");
std::fs::write(&file1, "").unwrap();
std::fs::write(&file2, "Content").unwrap();
let result = load_instructions(&[file1, file2]).unwrap();
assert_eq!(result, "Content");
}
#[test]
fn load_trims_whitespace() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("a.md");
std::fs::write(&file, "\n Content with whitespace \n\n").unwrap();
let result = load_instructions(&[file]).unwrap();
assert_eq!(result, "Content with whitespace");
}
#[test]
fn load_empty_paths_returns_empty_string() {
let result = load_instructions(&[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn prepend_adds_header_and_separator() {
let result = prepend_instructions("You are an agent.", "Be safe.");
assert_eq!(
result,
"# Project Instructions\n\nBe safe.\n\n---\n\nYou are an agent."
);
}
#[test]
fn prepend_noop_when_empty() {
let result = prepend_instructions("You are an agent.", "");
assert_eq!(result, "You are an agent.");
}
}