use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use crate::util::paths::{grind_prompts_dir, home_grind_prompts_dir};
use super::prompt::{parse_prompt_str, PromptDoc, PromptParseError, PromptSource};
#[derive(Debug, Clone)]
pub struct DiscoveryOptions {
pub project_root: PathBuf,
pub home_dir: Option<PathBuf>,
pub override_dir: Option<PathBuf>,
}
#[derive(Debug)]
pub struct DiscoveryResult {
pub prompts: Vec<PromptDoc>,
pub errors: Vec<(PathBuf, PromptParseError)>,
}
pub fn resolve_home_prompts_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
if home.is_empty() {
return None;
}
Some(home_grind_prompts_dir(PathBuf::from(home)))
}
pub fn discover_prompts(opts: DiscoveryOptions) -> DiscoveryResult {
let mut by_name: BTreeMap<String, PromptDoc> = BTreeMap::new();
let mut errors: Vec<(PathBuf, PromptParseError)> = Vec::new();
if let Some(override_dir) = opts.override_dir.as_deref() {
load_dir(
override_dir,
PromptSource::Override,
&mut by_name,
&mut errors,
);
} else {
let project_dir = grind_prompts_dir(&opts.project_root);
load_dir(
&project_dir,
PromptSource::Project,
&mut by_name,
&mut errors,
);
if let Some(home) = opts.home_dir.as_deref() {
let global_dir = home_grind_prompts_dir(home);
load_dir(&global_dir, PromptSource::Global, &mut by_name, &mut errors);
}
}
DiscoveryResult {
prompts: by_name.into_values().collect(),
errors,
}
}
fn load_dir(
dir: &Path,
source: PromptSource,
by_name: &mut BTreeMap<String, PromptDoc>,
errors: &mut Vec<(PathBuf, PromptParseError)>,
) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
let mut paths: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(OsStr::to_str) != Some("md") {
continue;
}
paths.push(path);
}
paths.sort();
for path in paths {
match read_and_parse(&path, source) {
Ok(doc) => {
by_name.entry(doc.meta.name.clone()).or_insert(doc);
}
Err(e) => errors.push((path, e)),
}
}
}
fn read_and_parse(path: &Path, source: PromptSource) -> Result<PromptDoc, PromptParseError> {
let display = path.display().to_string();
let raw = fs::read_to_string(path).map_err(|e| PromptParseError::Io {
path: display,
source: e,
})?;
parse_prompt_str(&raw, path, source)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_prompt(dir: &Path, file_name: &str, name: &str, body: &str) -> PathBuf {
fs::create_dir_all(dir).unwrap();
let path = dir.join(file_name);
let content = format!("---\nname: {name}\ndescription: test prompt\n---\n{body}");
fs::write(&path, content).unwrap();
path
}
fn write_raw(dir: &Path, file_name: &str, content: &str) -> PathBuf {
fs::create_dir_all(dir).unwrap();
let path = dir.join(file_name);
fs::write(&path, content).unwrap();
path
}
fn project_dir(root: &Path) -> PathBuf {
grind_prompts_dir(root)
}
fn global_dir(home: &Path) -> PathBuf {
home_grind_prompts_dir(home)
}
#[test]
fn project_only_sources_are_loaded() {
let root = TempDir::new().unwrap();
write_prompt(&project_dir(root.path()), "alpha.md", "alpha", "hi");
write_prompt(&project_dir(root.path()), "bravo.md", "bravo", "hi");
let res = discover_prompts(DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: None,
override_dir: None,
});
assert!(res.errors.is_empty());
let names: Vec<&str> = res.prompts.iter().map(|p| p.meta.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "bravo"]);
assert!(res
.prompts
.iter()
.all(|p| p.source_kind == PromptSource::Project));
}
#[test]
fn global_only_sources_are_loaded_when_project_is_empty() {
let root = TempDir::new().unwrap();
let home = TempDir::new().unwrap();
write_prompt(&global_dir(home.path()), "triage.md", "triage", "hi");
let res = discover_prompts(DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: Some(home.path().to_path_buf()),
override_dir: None,
});
assert!(res.errors.is_empty());
assert_eq!(res.prompts.len(), 1);
assert_eq!(res.prompts[0].meta.name, "triage");
assert_eq!(res.prompts[0].source_kind, PromptSource::Global);
}
#[test]
fn project_shadows_global_for_same_name() {
let root = TempDir::new().unwrap();
let home = TempDir::new().unwrap();
let project_path = write_prompt(
&project_dir(root.path()),
"fp.md",
"fp-hunter",
"project body",
);
write_prompt(
&global_dir(home.path()),
"fp.md",
"fp-hunter",
"global body",
);
write_prompt(&global_dir(home.path()), "lint.md", "lint", "global body");
let res = discover_prompts(DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: Some(home.path().to_path_buf()),
override_dir: None,
});
assert!(res.errors.is_empty());
let names: Vec<&str> = res.prompts.iter().map(|p| p.meta.name.as_str()).collect();
assert_eq!(names, vec!["fp-hunter", "lint"]);
let fp = res
.prompts
.iter()
.find(|p| p.meta.name == "fp-hunter")
.unwrap();
assert_eq!(fp.source_kind, PromptSource::Project);
assert_eq!(fp.source_path, project_path);
assert!(fp.body.contains("project body"));
let lint = res.prompts.iter().find(|p| p.meta.name == "lint").unwrap();
assert_eq!(lint.source_kind, PromptSource::Global);
}
#[test]
fn override_dir_replaces_project_and_global_entirely() {
let root = TempDir::new().unwrap();
let home = TempDir::new().unwrap();
let override_root = TempDir::new().unwrap();
write_prompt(&project_dir(root.path()), "p.md", "from-project", "x");
write_prompt(&global_dir(home.path()), "g.md", "from-global", "x");
let override_path = write_prompt(override_root.path(), "o.md", "from-override", "x");
let res = discover_prompts(DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: Some(home.path().to_path_buf()),
override_dir: Some(override_root.path().to_path_buf()),
});
assert!(res.errors.is_empty());
assert_eq!(res.prompts.len(), 1);
let only = &res.prompts[0];
assert_eq!(only.meta.name, "from-override");
assert_eq!(only.source_kind, PromptSource::Override);
assert_eq!(only.source_path, override_path);
}
#[test]
fn invalid_files_are_collected_and_valid_files_still_returned() {
let root = TempDir::new().unwrap();
let dir = project_dir(root.path());
write_prompt(&dir, "good.md", "good", "ok");
let bad = write_raw(&dir, "bad.md", "no fence here\n");
let res = discover_prompts(DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: None,
override_dir: None,
});
assert_eq!(res.prompts.len(), 1);
assert_eq!(res.prompts[0].meta.name, "good");
assert_eq!(res.errors.len(), 1);
assert_eq!(res.errors[0].0, bad);
assert!(matches!(
res.errors[0].1,
PromptParseError::MissingFrontmatter { .. }
));
}
#[test]
fn missing_directories_yield_empty_result() {
let root = TempDir::new().unwrap();
let res = discover_prompts(DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: Some(root.path().join("nonexistent-home")),
override_dir: None,
});
assert!(res.prompts.is_empty());
assert!(res.errors.is_empty());
}
#[test]
fn missing_override_dir_yields_empty_result() {
let root = TempDir::new().unwrap();
write_prompt(&project_dir(root.path()), "p.md", "from-project", "x");
let res = discover_prompts(DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: None,
override_dir: Some(root.path().join("nope")),
});
assert!(res.prompts.is_empty());
assert!(res.errors.is_empty());
}
#[test]
fn non_md_files_are_ignored() {
let root = TempDir::new().unwrap();
let dir = project_dir(root.path());
write_prompt(&dir, "keep.md", "keep", "ok");
write_raw(&dir, "README.txt", "ignored");
write_raw(&dir, "notes", "ignored");
let nested = dir.join("nested");
write_prompt(&nested, "deep.md", "deep", "ok");
let res = discover_prompts(DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: None,
override_dir: None,
});
let names: Vec<&str> = res.prompts.iter().map(|p| p.meta.name.as_str()).collect();
assert_eq!(names, vec!["keep"]);
}
#[test]
fn discovery_is_deterministic_across_runs() {
let root = TempDir::new().unwrap();
let dir = project_dir(root.path());
write_prompt(&dir, "zeta.md", "zeta", "z");
write_prompt(&dir, "alpha.md", "alpha", "a");
write_prompt(&dir, "mike.md", "mike", "m");
write_prompt(&dir, "bravo.md", "bravo", "b");
let opts = || DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: None,
override_dir: None,
};
let first = discover_prompts(opts());
let second = discover_prompts(opts());
let names_a: Vec<&str> = first.prompts.iter().map(|p| p.meta.name.as_str()).collect();
let names_b: Vec<&str> = second
.prompts
.iter()
.map(|p| p.meta.name.as_str())
.collect();
assert_eq!(names_a, vec!["alpha", "bravo", "mike", "zeta"]);
assert_eq!(names_a, names_b);
}
#[test]
fn within_directory_first_filename_wins_on_duplicate_name() {
let root = TempDir::new().unwrap();
let dir = project_dir(root.path());
let first = write_prompt(&dir, "01-first.md", "dup", "first body");
write_prompt(&dir, "02-second.md", "dup", "second body");
let res = discover_prompts(DiscoveryOptions {
project_root: root.path().to_path_buf(),
home_dir: None,
override_dir: None,
});
assert!(res.errors.is_empty());
assert_eq!(res.prompts.len(), 1);
assert_eq!(res.prompts[0].source_path, first);
assert!(res.prompts[0].body.contains("first body"));
}
#[test]
fn resolve_home_prompts_dir_uses_home_env() {
if let Some(home) = std::env::var_os("HOME") {
let resolved = resolve_home_prompts_dir().expect("HOME was set");
assert_eq!(resolved, PathBuf::from(home).join(".pitboss/grind/prompts"));
}
}
}