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;
static TEMPLATE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{(\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"));
String::from_utf8_lossy(file.data.as_ref()).into_owned()
}
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 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("ticket.md"),
&[
("{{ticket_id}}", &ticket.id),
("{{ticket_title}}", &ticket.title),
("{{ticket_reporter}}", &ticket.reporter),
("{{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[... truncated at {MAX_WORKSPACE_FILE_CHARS} chars — use `read` for full file]\n"
);
} 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 rules = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().is_some_and(|e| e == "md")
&& 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();
map.get(whole).copied().unwrap_or(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);
}
}