use std::collections::HashMap;
use std::path::{Path, PathBuf};
use include_dir::{Dir, include_dir};
static EMBEDDED: Dir = include_dir!("$CARGO_MANIFEST_DIR/prompts");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PromptSource {
#[default]
Embedded,
Global,
Project,
}
impl PromptSource {
pub fn label(self) -> &'static str {
match self {
PromptSource::Embedded => "embedded",
PromptSource::Global => "global",
PromptSource::Project => "project",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Prompt {
pub body: String,
pub deny_tools: Vec<String>,
pub description: Option<String>,
pub critic: Option<bool>,
pub critic_preamble: Option<String>,
pub source: PromptSource,
}
fn parse_frontmatter(raw: &str) -> Prompt {
let Some(after_open) = raw
.strip_prefix("---\n")
.or_else(|| raw.strip_prefix("---\r\n"))
else {
return Prompt {
body: raw.to_string(),
..Prompt::default()
};
};
let close_marker = "\n---\n";
let close_marker_crlf = "\r\n---\r\n";
let (front, body) = if let Some(pos) = after_open.find(close_marker) {
(&after_open[..pos], &after_open[pos + close_marker.len()..])
} else if let Some(pos) = after_open.find(close_marker_crlf) {
(
&after_open[..pos],
&after_open[pos + close_marker_crlf.len()..],
)
} else {
return Prompt {
body: raw.to_string(),
..Prompt::default()
};
};
let mut deny_tools: Vec<String> = Vec::new();
let mut description: Option<String> = None;
let mut critic: Option<bool> = None;
let mut critic_preamble: Option<String> = None;
let lines: Vec<&str> = front.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
i += 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim();
match key {
"deny_tools" => {
if value.starts_with('[') {
let stripped = value.trim_start_matches('[').trim_end_matches(']');
deny_tools = stripped
.split(',')
.map(|s| s.trim().trim_matches(|c| c == '"' || c == '\''))
.filter(|s| !s.is_empty())
.map(|s| {
let mut owned = s.to_string();
owned.make_ascii_lowercase();
owned
})
.collect();
} else if value.is_empty() && i < lines.len() && lines[i].trim().starts_with('-') {
let mut items: Vec<String> = Vec::new();
while i < lines.len() {
let next = lines[i].trim();
if let Some(rest) = next.strip_prefix('-') {
let name = rest.trim().trim_matches(|c| c == '"' || c == '\'');
if !name.is_empty() {
let mut owned = name.to_string();
owned.make_ascii_lowercase();
items.push(owned);
}
i += 1;
} else if next.is_empty() || next.starts_with('#') {
i += 1; } else {
break;
}
}
deny_tools = items;
} else {
}
}
"description" => {
let v = value.trim_matches(|c| c == '"' || c == '\'');
if !v.is_empty() {
description = Some(v.to_string());
}
}
"critic" => match value {
"false" => critic = Some(false),
"true" => critic = Some(true),
_ => {}
},
"critic_preamble" => {
if (value == "|" || value == "|-") && i < lines.len() {
let mut block: Vec<String> = Vec::new();
while i < lines.len() {
let raw = lines[i];
if raw.is_empty() {
block.push(String::new());
i += 1;
} else if raw.starts_with(char::is_whitespace) {
block.push(raw.trim_start().to_string());
i += 1;
} else {
break;
}
}
while block.last().is_some_and(String::is_empty) {
block.pop();
}
let joined = block.join("\n");
if !joined.trim().is_empty() {
critic_preamble = Some(joined);
}
} else {
let v = value.trim_matches(|c| c == '"' || c == '\'');
if !v.is_empty() {
critic_preamble = Some(v.to_string());
}
}
}
_ => {}
}
}
Prompt {
body: body.to_string(),
deny_tools,
description,
critic,
critic_preamble,
..Default::default()
}
}
pub fn global_prompts_dir() -> PathBuf {
crate::session::storage::config_path().join("prompts")
}
pub fn local_prompts_dir() -> PathBuf {
PathBuf::from(".dirge").join("prompts")
}
fn load_dir_hard(dir: &Path, source: PromptSource, prompts: &mut HashMap<String, Prompt>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "md")
&& let Some(name) = path.file_stem().and_then(|s| s.to_str())
&& let Ok(content) = std::fs::read_to_string(&path)
{
let mut prompt = parse_frontmatter(&content);
prompt.source = source;
prompts.insert(name.to_string(), prompt);
}
}
}
fn warn_unknown_deny_tools(prompt_name: &str, deny: &[String]) {
let known: &[&str] = crate::agent::tools::BUILTIN_TOOL_NAMES;
for t in deny {
if !known.iter().any(|k| k.eq_ignore_ascii_case(t)) {
eprintln!(
"warning: prompt '{}' deny_tools entry {:?} doesn't match any known built-in. \
If this is an MCP tool name, ignore this warning; if it's a typo, fix the .md. \
Known tools: {}",
prompt_name,
t,
known.join(", "),
);
}
}
}
pub fn load() -> HashMap<String, Prompt> {
let mut prompts: HashMap<String, Prompt> = HashMap::new();
for file in EMBEDDED.files() {
if file.path().extension().is_some_and(|e| e == "md")
&& let Some(name) = file.path().file_stem().and_then(|s| s.to_str())
&& let Some(content) = file.contents_utf8()
{
prompts
.entry(name.to_string())
.or_insert_with(|| parse_frontmatter(content));
}
}
load_file_tiers(&global_prompts_dir(), &local_prompts_dir(), &mut prompts);
for (name, p) in &prompts {
if !p.deny_tools.is_empty() {
warn_unknown_deny_tools(name, &p.deny_tools);
}
}
prompts
}
fn load_file_tiers(global: &Path, local: &Path, prompts: &mut HashMap<String, Prompt>) {
load_dir_hard(global, PromptSource::Global, prompts);
load_dir_hard(local, PromptSource::Project, prompts);
}
pub fn ensure_global() -> anyhow::Result<()> {
let dir = global_prompts_dir();
if !dir.exists() {
std::fs::create_dir_all(&dir)?;
copy_embedded(&dir)?;
}
Ok(())
}
pub fn regen() -> anyhow::Result<()> {
let dir = global_prompts_dir();
std::fs::create_dir_all(&dir)?;
copy_embedded(&dir)
}
fn copy_embedded(dest: &Path) -> anyhow::Result<()> {
for file in EMBEDDED.files() {
if let Some(name) = file.path().file_name().and_then(|s| s.to_str()) {
let dest_path = dest.join(name);
if let Some(content) = file.contents_utf8() {
std::fs::write(&dest_path, content)?;
}
}
}
Ok(())
}
pub fn next_prompt<'a>(current: Option<&str>, sorted: &'a [&'a String]) -> Option<Option<&'a str>> {
if sorted.is_empty() {
return None;
}
match current.and_then(|c| sorted.iter().position(|n| n.as_str() == c)) {
Some(i) if i + 1 < sorted.len() => Some(Some(sorted[i + 1].as_str())),
Some(_) => Some(None),
None => Some(Some(sorted[0].as_str())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_frontmatter_loads_whole_file_as_body() {
let raw = "You are dirge.\n\nDo the thing.\n";
let p = parse_frontmatter(raw);
assert_eq!(p.body, raw);
assert!(p.deny_tools.is_empty());
assert!(p.description.is_none());
}
#[test]
fn frontmatter_extracts_deny_tools_and_description() {
let raw = "---\ndeny_tools: [edit, write, apply_patch, bash]\ndescription: Read-only plan mode\n---\nYou are dirge.\n";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit", "write", "apply_patch", "bash"]);
assert_eq!(p.description.as_deref(), Some("Read-only plan mode"));
assert_eq!(p.body, "You are dirge.\n");
}
#[test]
fn frontmatter_parses_critic_disable_and_inline_preamble() {
let raw = "---\ncritic: false\ncritic_preamble: Be strict about tests.\n---\nbody\n";
let p = parse_frontmatter(raw);
assert_eq!(p.critic, Some(false));
assert_eq!(p.critic_preamble.as_deref(), Some("Be strict about tests."));
}
#[test]
fn frontmatter_parses_critic_preamble_block_scalar() {
let raw = "---\ncritic_preamble: |\n You are a security-focused reviewer.\n Block on concrete, in-scope gaps.\n---\nbody\n";
let p = parse_frontmatter(raw);
assert_eq!(
p.critic_preamble.as_deref(),
Some("You are a security-focused reviewer.\nBlock on concrete, in-scope gaps."),
);
}
#[test]
fn frontmatter_empty_critic_preamble_is_unset() {
let raw = "---\ncritic_preamble:\n---\nbody\n";
let p = parse_frontmatter(raw);
assert!(p.critic_preamble.is_none());
assert!(p.critic.is_none(), "critic omitted → inherit");
}
#[test]
fn frontmatter_critic_true_parses() {
let raw = "---\ncritic: true\n---\nbody\n";
let p = parse_frontmatter(raw);
assert_eq!(p.critic, Some(true));
}
#[test]
fn plan_prompt_locks_down_all_mutation_and_exec_tools() {
let raw = EMBEDDED
.get_file("plan.md")
.and_then(|f| f.contents_utf8())
.expect("embedded plan.md present");
let p = parse_frontmatter(raw);
for required in [
"edit",
"write",
"apply_patch",
"edit_lines",
"edit_minified",
"bash",
"webfetch",
"task",
"mcp_tool",
"plugin_tool",
"debug",
"spec",
] {
assert!(
p.deny_tools
.iter()
.any(|d| d.eq_ignore_ascii_case(required)),
"plan mode must deny {required:?}; deny_tools = {:?}",
p.deny_tools,
);
}
for d in &p.deny_tools {
assert!(
crate::agent::tools::BUILTIN_TOOL_NAMES
.iter()
.any(|k| k.eq_ignore_ascii_case(d)),
"plan deny_tools entry {d:?} isn't a known built-in (would warn at load)",
);
}
}
#[test]
fn frontmatter_tolerates_quoted_tool_names_and_whitespace() {
let raw = "---\ndeny_tools: [ \"edit\" , 'write' , bash ]\n---\nbody\n";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit", "write", "bash"]);
}
#[test]
fn malformed_frontmatter_falls_back_to_whole_body() {
let raw = "---\ndeny_tools: [edit]\n\nbody without close\n";
let p = parse_frontmatter(raw);
assert_eq!(p.body, raw);
assert!(p.deny_tools.is_empty());
}
#[test]
fn unknown_keys_are_ignored() {
let raw = "---\ndeny_tools: [edit]\nunknown_key: whatever\nfuture_thing: 42\n---\nbody\n";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit"]);
assert_eq!(p.body, "body\n");
}
#[test]
fn block_form_deny_tools_is_parsed() {
let raw = "\
---
deny_tools:
- edit
- write
- apply_patch
- bash
description: Plan mode
---
body
";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit", "write", "apply_patch", "bash"]);
assert_eq!(p.description.as_deref(), Some("Plan mode"));
}
#[test]
fn block_form_deny_tools_with_quotes() {
let raw = "\
---
deny_tools:
- edit
- \"write\"
- 'bash'
description: Mixed quotes
---
body
";
let p = parse_frontmatter(raw);
assert_eq!(p.deny_tools, vec!["edit", "write", "bash"]);
}
#[test]
fn next_prompt_starts_at_head_from_base() {
let names = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let names: Vec<&String> = names.iter().collect();
assert_eq!(next_prompt(None, &names), Some(Some("a")));
}
#[test]
fn next_prompt_advances_then_returns_to_base() {
let names = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let names: Vec<&String> = names.iter().collect();
assert_eq!(next_prompt(Some("a"), &names), Some(Some("b")));
assert_eq!(next_prompt(Some("b"), &names), Some(Some("c")));
assert_eq!(next_prompt(Some("c"), &names), Some(None));
}
#[test]
fn next_prompt_single_prompt_alternates_with_base() {
let names = vec!["only".to_string()];
let names: Vec<&String> = names.iter().collect();
assert_eq!(next_prompt(None, &names), Some(Some("only")));
assert_eq!(next_prompt(Some("only"), &names), Some(None));
}
#[test]
fn next_prompt_unknown_current_starts_at_head() {
let names = vec!["a".to_string(), "b".to_string()];
let names: Vec<&String> = names.iter().collect();
assert_eq!(next_prompt(Some("zzz"), &names), Some(Some("a")));
}
#[test]
fn next_prompt_empty_is_none() {
assert_eq!(next_prompt(None, &[]), None);
}
#[test]
fn project_local_overrides_global_by_name() {
use std::fs;
let root = std::env::temp_dir().join(format!("dirge-prompt-tier-{}", std::process::id()));
let global = root.join("global").join("prompts");
let local = root.join("project").join(".dirge").join("prompts");
fs::create_dir_all(&global).unwrap();
fs::create_dir_all(&local).unwrap();
fs::write(global.join("shared.md"), "GLOBAL BODY\n").unwrap();
fs::write(local.join("shared.md"), "PROJECT BODY\n").unwrap();
fs::write(global.join("only-global.md"), "G\n").unwrap();
fs::write(local.join("only-local.md"), "L\n").unwrap();
let mut prompts = HashMap::new();
load_file_tiers(&global, &local, &mut prompts);
assert_eq!(prompts.get("shared").unwrap().body, "PROJECT BODY\n");
assert_eq!(prompts.get("only-global").unwrap().body, "G\n");
assert_eq!(prompts.get("only-local").unwrap().body, "L\n");
assert_eq!(prompts.get("shared").unwrap().source, PromptSource::Project);
assert_eq!(
prompts.get("only-global").unwrap().source,
PromptSource::Global
);
assert_eq!(
prompts.get("only-local").unwrap().source,
PromptSource::Project
);
let _ = fs::remove_dir_all(&root);
}
}