2025-08-28 19:16:39 -07:00
|
|
|
use codex_protocol::custom_prompts::CustomPrompt;
|
|
|
|
|
use std::collections::HashSet;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use tokio::fs;
|
|
|
|
|
|
|
|
|
|
/// Return the default prompts directory: `$CODEX_HOME/prompts`.
|
|
|
|
|
/// If `CODEX_HOME` cannot be resolved, returns `None`.
|
|
|
|
|
pub fn default_prompts_dir() -> Option<PathBuf> {
|
|
|
|
|
crate::config::find_codex_home()
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|home| home.join("prompts"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Discover prompt files in the given directory, returning entries sorted by name.
|
|
|
|
|
/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty.
|
|
|
|
|
pub async fn discover_prompts_in(dir: &Path) -> Vec<CustomPrompt> {
|
|
|
|
|
discover_prompts_in_excluding(dir, &HashSet::new()).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Discover prompt files in the given directory, excluding any with names in `exclude`.
|
|
|
|
|
/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty.
|
|
|
|
|
pub async fn discover_prompts_in_excluding(
|
|
|
|
|
dir: &Path,
|
|
|
|
|
exclude: &HashSet<String>,
|
|
|
|
|
) -> Vec<CustomPrompt> {
|
|
|
|
|
let mut out: Vec<CustomPrompt> = Vec::new();
|
|
|
|
|
let mut entries = match fs::read_dir(dir).await {
|
|
|
|
|
Ok(entries) => entries,
|
|
|
|
|
Err(_) => return out,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
let is_file = entry
|
|
|
|
|
.file_type()
|
|
|
|
|
.await
|
|
|
|
|
.map(|ft| ft.is_file())
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
if !is_file {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// Only include Markdown files with a .md extension.
|
|
|
|
|
let is_md = path
|
|
|
|
|
.extension()
|
|
|
|
|
.and_then(|s| s.to_str())
|
|
|
|
|
.map(|ext| ext.eq_ignore_ascii_case("md"))
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
if !is_md {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Some(name) = path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|s| s.to_str())
|
2025-09-22 20:30:16 +01:00
|
|
|
.map(str::to_string)
|
2025-08-28 19:16:39 -07:00
|
|
|
else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if exclude.contains(&name) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let content = match fs::read_to_string(&path).await {
|
|
|
|
|
Ok(s) => s,
|
|
|
|
|
Err(_) => continue,
|
|
|
|
|
};
|
|
|
|
|
out.push(CustomPrompt {
|
|
|
|
|
name,
|
|
|
|
|
path,
|
|
|
|
|
content,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn empty_when_dir_missing() {
|
|
|
|
|
let tmp = tempdir().expect("create TempDir");
|
|
|
|
|
let missing = tmp.path().join("nope");
|
|
|
|
|
let found = discover_prompts_in(&missing).await;
|
|
|
|
|
assert!(found.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn discovers_and_sorts_files() {
|
|
|
|
|
let tmp = tempdir().expect("create TempDir");
|
|
|
|
|
let dir = tmp.path();
|
|
|
|
|
fs::write(dir.join("b.md"), b"b").unwrap();
|
|
|
|
|
fs::write(dir.join("a.md"), b"a").unwrap();
|
|
|
|
|
fs::create_dir(dir.join("subdir")).unwrap();
|
|
|
|
|
let found = discover_prompts_in(dir).await;
|
|
|
|
|
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
|
|
|
|
assert_eq!(names, vec!["a", "b"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn excludes_builtins() {
|
|
|
|
|
let tmp = tempdir().expect("create TempDir");
|
|
|
|
|
let dir = tmp.path();
|
|
|
|
|
fs::write(dir.join("init.md"), b"ignored").unwrap();
|
|
|
|
|
fs::write(dir.join("foo.md"), b"ok").unwrap();
|
|
|
|
|
let mut exclude = HashSet::new();
|
|
|
|
|
exclude.insert("init".to_string());
|
|
|
|
|
let found = discover_prompts_in_excluding(dir, &exclude).await;
|
|
|
|
|
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
|
|
|
|
assert_eq!(names, vec!["foo"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn skips_non_utf8_files() {
|
|
|
|
|
let tmp = tempdir().expect("create TempDir");
|
|
|
|
|
let dir = tmp.path();
|
|
|
|
|
// Valid UTF-8 file
|
|
|
|
|
fs::write(dir.join("good.md"), b"hello").unwrap();
|
|
|
|
|
// Invalid UTF-8 content in .md file (e.g., lone 0xFF byte)
|
|
|
|
|
fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap();
|
|
|
|
|
let found = discover_prompts_in(dir).await;
|
|
|
|
|
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
|
|
|
|
assert_eq!(names, vec!["good"]);
|
|
|
|
|
}
|
|
|
|
|
}
|