use std::collections::HashMap;
use std::fmt::Write;
use std::path::Path;
use regex::Regex;
use std::sync::LazyLock;
#[derive(rust_embed::RustEmbed)]
#[folder = "src/prompt"]
struct PromptAssets;
pub(crate) static TEMPLATE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?-u)\{\{(\w+)\}\}").expect("TEMPLATE_RE must compile"));
#[must_use]
pub(crate) fn load_prompt(asset_key: &str) -> String {
let file = PromptAssets::get(asset_key).unwrap_or_else(|| {
panic!(
"Embedded prompt '{asset_key}' not found. \
Create the file at src/prompt/{asset_key} and rebuild."
)
});
String::from_utf8_lossy(file.data.as_ref()).into_owned()
}
#[must_use]
pub(crate) fn load_prompt_sections(asset_key: &str) -> Vec<String> {
load_prompt(asset_key)
.split("\n---\n")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
fn append_file_section(out: &mut String, name: &str, content: &str) {
let trimmed = content.trim();
if trimmed.is_empty() {
return;
}
let _ = writeln!(out, "--- {name} ---\n");
push_truncated(out, trimmed);
}
pub(crate) async fn build_workspace_context(workspace: &Path) -> String {
const WORKSPACE_FILES: &[&str] = &[
"README.md",
"BOOTSTRAP.md",
"MEMORY.md",
"CLAUDE.md",
"AGENTS.md",
"AGENTS.local.md",
"CLAUDE.local.md",
".cursorrules",
"copilot-instructions.md",
".github/copilot-instructions.md",
];
let mut out = String::new();
for &filename in WORKSPACE_FILES {
let path = workspace.join(filename);
if let Ok(raw) = tokio::fs::read_to_string(&path).await {
append_file_section(&mut out, filename, &raw);
}
}
for (rel_path, content) in discover_claude_rules(workspace).await {
append_file_section(&mut out, &rel_path, &content);
}
out
}
pub(crate) fn wrap_workspace_context(content: &str) -> String {
let trimmed = content.trim();
if trimmed.is_empty() {
return String::new();
}
format!("\n<workspace-context>\n{trimmed}\n</workspace-context>\n")
}
pub(crate) async fn build_general_workspace_context(ws: &crate::Workspace) -> String {
let stored = match crate::workspace::WORKSPACES.get() {
Some(store) => store.get_general_context(&ws.name).await.ok().flatten(),
None => None,
};
let content = match stored {
Some(ctx) => ctx,
None => build_workspace_context(ws.as_path()).await,
};
wrap_workspace_context(&content)
}
pub(crate) async fn prepend_general_context(
messages: &mut Vec<crate::ChatMessage>,
ws: &crate::Workspace,
) {
let context = build_general_workspace_context(ws).await;
if !context.is_empty() {
messages.insert(0, crate::ChatMessage::system(&context));
}
}
pub(crate) fn format_ticket_block(ticket: &crate::board::Ticket) -> String {
let mut comments = String::new();
if !ticket.comments.is_empty() {
let _ = writeln!(comments);
let _ = writeln!(comments, "### Comments ({})", ticket.comments.len());
let _ = writeln!(comments);
for comment in &ticket.comments {
let ts = format_local_timestamp(&comment.created_at);
let _ = writeln!(comments, "**{}** ({}):", comment.role, ts);
let _ = writeln!(comments, "{}", comment.content);
let _ = writeln!(comments);
let _ = writeln!(comments, "---");
let _ = writeln!(comments);
}
}
substitute(
&load_prompt("context/ticket.md"),
&[
("{{ticket_id}}", &ticket.id),
("{{ticket_title}}", &ticket.title),
("{{ticket_reporter}}", &ticket.reporter),
("{{ticket_priority}}", &format!("P{}", ticket.priority)),
("{{ticket_description}}", &ticket.description),
("{{ticket_comments}}", &comments),
],
)
}
fn format_local_timestamp(iso_str: &str) -> String {
crate::turso::parse_utc_timestamp(iso_str).map_or_else(
|e| {
tracing::warn!(iso_str = %iso_str, error = %e, "Failed to parse timestamp, falling back to raw string");
iso_str.to_string()
},
|dt| {
dt.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
},
)
}
fn push_truncated(out: &mut String, text: &str) {
const MAX_WORKSPACE_FILE_CHARS: usize = 10_000;
if let Some((idx, _)) = text.char_indices().nth(MAX_WORKSPACE_FILE_CHARS) {
out.push_str(&text[..idx]);
let _ = writeln!(
out,
"\n\n{}\n",
substitute(
&load_prompt("context/truncation_notice.md"),
&[("{{max_chars}}", &MAX_WORKSPACE_FILE_CHARS.to_string())],
),
);
} else {
out.push_str(text);
out.push_str("\n\n");
}
}
async fn discover_claude_rules(workspace: &Path) -> Vec<(String, String)> {
let rules_dir = workspace.join(".claude").join("rules");
let Ok(mut entries) = tokio::fs::read_dir(&rules_dir).await else {
return Vec::new();
};
let mut files = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().is_some_and(|e| e == "md") {
files.push(path);
}
}
files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
let mut rules = Vec::new();
for path in files {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
let rel_path = path
.strip_prefix(workspace)
.unwrap_or(&path)
.display()
.to_string();
rules.push((rel_path, content));
}
}
rules
}
pub(crate) fn substitute(template: &str, replacements: &[(&str, &str)]) -> String {
let map: HashMap<&str, &str> = replacements.iter().copied().collect();
TEMPLATE_RE
.replace_all(template, |caps: ®ex::Captures| {
let whole = caps
.get(0)
.expect("capture group 0 always matches")
.as_str();
if let Some(val) = map.get(whole) {
(*val).to_owned()
} else {
tracing::warn!(
template_var = %whole,
"prompt substitution: no replacement provided for '{whole}' — \
literal text will appear in the prompt output. \
Check that the variable name in the template matches a \
replacement key at the call site."
);
whole.to_owned()
}
})
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substitute_basic_replacement() {
let result = substitute(
"Hello {{name}}, your {{item}} is ready.",
&[("{{name}}", "Alice"), ("{{item}}", "order")],
);
assert_eq!(result, "Hello Alice, your order is ready.");
}
#[test]
fn substitute_preserves_unknown_keys() {
let result = substitute(
"Hello {{name}}, here is {{missing}} key.",
&[("{{name}}", "Alice")],
);
assert_eq!(result, "Hello Alice, here is {{missing}} key.");
}
#[test]
fn substitute_no_cascade() {
let result = substitute(
"First: {{a}}, Second: {{b}}",
&[("{{a}}", "value-{{b}}"), ("{{b}}", "actual-b")],
);
assert_eq!(result, "First: value-{{b}}, Second: actual-b");
}
#[test]
fn substitute_empty_template() {
let result = substitute("", &[("{{key}}", "value")]);
assert_eq!(result, "");
}
#[test]
fn substitute_no_replacements() {
let result = substitute("Hello {{name}}!", &[]);
assert_eq!(result, "Hello {{name}}!");
}
#[tokio::test]
async fn discover_claude_rules_finds_md_files() {
let dir = tempfile::tempdir().unwrap();
let rules_dir = dir.path().join(".claude").join("rules");
std::fs::create_dir_all(&rules_dir).unwrap();
std::fs::write(rules_dir.join("testing.md"), "Test content").unwrap();
std::fs::write(rules_dir.join("style.md"), "Style rules").unwrap();
std::fs::write(rules_dir.join("notes.txt"), "irrelevant").unwrap();
let rules = discover_claude_rules(dir.path()).await;
assert_eq!(rules.len(), 2);
let paths: Vec<&str> = rules.iter().map(|(p, _)| p.as_str()).collect();
assert!(paths.contains(&".claude/rules/testing.md"));
assert!(paths.contains(&".claude/rules/style.md"));
}
#[tokio::test]
async fn discover_claude_rules_missing_dir() {
let dir = tempfile::tempdir().unwrap();
let rules = discover_claude_rules(dir.path()).await;
assert!(rules.is_empty());
}
#[tokio::test]
async fn discover_claude_rules_skips_unreadable() {
let dir = tempfile::tempdir().unwrap();
let rules_dir = dir.path().join(".claude").join("rules");
std::fs::create_dir_all(&rules_dir).unwrap();
let bad = rules_dir.join("broken.md");
std::fs::write(&bad, "fine").unwrap();
std::fs::write(rules_dir.join("good.md"), "good content").unwrap();
let rules = discover_claude_rules(dir.path()).await;
assert_eq!(rules.len(), 2);
}
#[tokio::test]
async fn discover_claude_rules_returns_full_content() {
let dir = tempfile::tempdir().unwrap();
let rules_dir = dir.path().join(".claude").join("rules");
std::fs::create_dir_all(&rules_dir).unwrap();
let long = "x".repeat(100_000);
std::fs::write(rules_dir.join("long.md"), &long).unwrap();
let rules = discover_claude_rules(dir.path()).await;
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].1.len(), 100_000);
}
#[tokio::test]
async fn discover_claude_rules_returns_sorted_order() {
let dir = tempfile::tempdir().unwrap();
let rules_dir = dir.path().join(".claude").join("rules");
std::fs::create_dir_all(&rules_dir).unwrap();
for name in ["zebra", "alpha", "middle"] {
std::fs::write(
rules_dir.join(format!("{name}.md")),
format!("{name} rules"),
)
.unwrap();
}
let rules = discover_claude_rules(dir.path()).await;
let paths: Vec<&str> = rules.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
[
".claude/rules/alpha.md",
".claude/rules/middle.md",
".claude/rules/zebra.md",
]
);
}
#[test]
fn all_template_variables_are_word_chars() {
let broad_re =
regex::Regex::new(r"\{\{([^}]+)\}\}").expect("broad placeholder regex must compile");
for asset_key in PromptAssets::iter() {
let asset = PromptAssets::get(&asset_key)
.unwrap_or_else(|| panic!("asset {asset_key} disappeared between iter and get"));
let content = String::from_utf8_lossy(asset.data.as_ref());
for cap in broad_re.captures_iter(&content) {
let var_name = cap.get(1).unwrap().as_str();
assert!(
var_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_'),
"Template variable '{{{{{var_name}}}}}' in '{asset_key}' contains non-\\w \
characters (only ASCII alphanumeric and underscore are allowed).\n\
Template keys must match \\w+ so that TEMPLATE_RE can substitute them. \
Use underscores instead of hyphens, dots, or spaces.",
);
}
}
}
#[test]
fn all_prompt_assets_load_non_empty() {
for asset_key in PromptAssets::iter() {
let content = load_prompt(&asset_key);
assert!(
!content.trim().is_empty(),
"Prompt asset '{asset_key}' is empty or whitespace-only.\n\
Each embedded prompt file must contain meaningful content.",
);
}
}
}