use gray_matter::engine::YAML;
use gray_matter::{Matter, Pod};
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use rmcp::model::Tool;
use scryer_db::ArchitecturalDecision;
use scryer_engine::EngineService;
use super::admin::{destructive, make_tool, mutating, read_only};
use super::adr_rank::{
AdrCorpus, AdrSource, RankedAdr, invalidate_adr_corpus, load_corpus, rank_query,
};
use crate::context::ProjectContextResolver;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ParsedAdr {
pub file_path: String,
pub adr_number: Option<u32>,
pub title: String,
pub status: String,
pub context: String,
pub decision: String,
pub consequences: String,
#[serde(default)]
pub body: String,
pub affected_paths: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct QueryAdrsParams {
pub query: String,
pub status: Option<String>,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub compact: Option<bool>,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
pub project: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AdrMatch {
pub title: String,
pub status: String,
pub file_path: String,
pub decision_summary: String,
pub affected_paths: Vec<String>,
pub matched_section: String,
pub relevance_score: usize,
pub score: f64,
pub match_reasons: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct QueryAdrsResult {
pub query: String,
pub total_matches: usize,
pub returned_matches: usize,
pub offset: usize,
pub limit: Option<usize>,
pub has_more: bool,
pub matches: Vec<AdrMatch>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct RecordAdrParams {
pub title: String,
pub context: String,
pub decision: String,
pub consequences: Option<String>,
pub affected_paths: Option<Vec<String>>,
pub status: Option<super::enums::AdrStatus>,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
pub project: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RecordAdrResult {
pub adr_number: u32,
pub title: String,
pub status: String,
pub file_path: String,
pub affected_paths: Vec<String>,
pub message: String,
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct DeleteAdrParams {
pub adr_number: Option<u32>,
pub file_path: Option<String>,
pub title: Option<String>,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
pub project: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DeleteAdrResult {
pub success: bool,
pub removed_file: Option<String>,
pub removed_db_records: usize,
pub message: String,
}
pub fn parse_adr_markdown(file_path: &Path, content: &str) -> ParsedAdr {
let matter = Matter::<YAML>::new();
let (matter_data, markdown_content) = match matter.parse::<Pod>(content) {
Ok(entity) => (entity.data, entity.content),
Err(_) => (None, content.to_string()),
};
let mut title = file_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled")
.to_string();
let mut status = "accepted".to_string();
let mut affected_paths = Vec::new();
let mut adr_number = None;
if let Some(data) = matter_data
&& let Ok(val) = data.as_hashmap()
{
if let Some(Ok(t)) = val.get("title").map(|v| v.as_string()) {
title = t;
}
if let Some(Ok(s)) = val.get("status").map(|v| v.as_string()) {
status = s;
}
if let Some(Ok(num)) = val.get("number").map(|v| v.as_i64()) {
adr_number = u32::try_from(num).ok();
}
if let Some(Ok(paths)) = val.get("affected_paths").map(|v| v.as_vec()) {
for p in paths {
if let Ok(s) = p.as_string() {
affected_paths.push(s);
}
}
}
}
let parser = Parser::new(&markdown_content);
let mut current_heading = String::new();
let mut in_heading = false;
let mut context_text = String::new();
let mut decision_text = String::new();
let mut consequences_text = String::new();
let mut general_text = String::new();
for event in parser {
match event {
Event::Start(Tag::Heading { .. }) => {
in_heading = true;
current_heading.clear();
}
Event::End(TagEnd::Heading(_)) => {
in_heading = false;
let h_lower = current_heading.trim().to_lowercase();
if title == "Untitled" || title.is_empty() {
title = current_heading.trim().to_string();
}
current_heading = h_lower;
}
Event::Text(text) | Event::Code(text) => {
if in_heading {
current_heading.push_str(&text);
} else if current_heading.contains("context") {
context_text.push_str(&text);
} else if current_heading.contains("decision") {
decision_text.push_str(&text);
} else if current_heading.contains("consequence") {
consequences_text.push_str(&text);
} else {
general_text.push_str(&text);
}
}
_ => {}
}
}
if context_text.is_empty() && decision_text.is_empty() {
decision_text = std::mem::take(&mut general_text);
}
ParsedAdr {
file_path: file_path.to_string_lossy().to_string(),
adr_number,
title,
status,
context: context_text.trim().to_string(),
decision: decision_text.trim().to_string(),
consequences: consequences_text.trim().to_string(),
body: general_text.trim().to_string(),
affected_paths,
}
}
pub async fn handle_query_adrs(
context: &ProjectContextResolver,
engine: &EngineService,
params: QueryAdrsParams,
) -> anyhow::Result<QueryAdrsResult> {
let (project, _) = context
.resolve_project(None, params.project.as_deref())
.await?;
let root = PathBuf::from(&project.root_path);
let corpus = load_corpus(engine, project.id, &root).await?;
let compact = params.compact.unwrap_or(false);
if let Some(wanted) = params.status.as_deref() {
let mut known: Vec<String> = ["proposed", "accepted", "deprecated", "superseded"]
.iter()
.map(|s| s.to_string())
.collect();
known.extend(corpus.entries.iter().map(|e| e.adr.status.to_lowercase()));
known.sort();
known.dedup();
anyhow::ensure!(
known.iter().any(|k| k.eq_ignore_ascii_case(wanted)),
"Unknown status '{wanted}'; valid values: {}",
known.join(", ")
);
}
let ranked = rank_query(&corpus, ¶ms.query, &root);
let ranked_total = ranked.len();
let matches: Vec<AdrMatch> = ranked
.into_iter()
.filter(|r| status_matches(&corpus, r, params.status.as_deref()))
.map(|r| to_adr_match(&corpus, r, compact))
.collect();
let mut notes = Vec::new();
if matches.is_empty()
&& ranked_total > 0
&& let Some(status) = ¶ms.status
{
notes.push(format!(
"{ranked_total} document(s) match the query but none has status '{status}'; drop `status` to see them"
));
}
let (paginated_matches, total_matches, offset, has_more) =
paginate(matches, params.offset, params.limit);
Ok(QueryAdrsResult {
query: params.query,
total_matches,
returned_matches: paginated_matches.len(),
offset,
limit: params.limit,
has_more,
matches: paginated_matches,
notes,
})
}
fn status_matches(corpus: &AdrCorpus, ranked: &RankedAdr, status: Option<&str>) -> bool {
status.is_none_or(|s| {
corpus.entries[ranked.idx]
.adr
.status
.eq_ignore_ascii_case(s)
})
}
fn to_adr_match(corpus: &AdrCorpus, ranked: RankedAdr, compact: bool) -> AdrMatch {
let entry = &corpus.entries[ranked.idx];
let adr = &entry.adr;
let matched_section = if compact {
String::new()
} else if entry.source == AdrSource::Db {
"ArchitecturalDecision (Turso)".to_string()
} else if !adr.context.is_empty() {
"Context & Decision".to_string()
} else {
"Body".to_string()
};
AdrMatch {
title: adr.title.clone(),
status: adr.status.clone(),
file_path: adr.file_path.clone(),
decision_summary: truncate_string(&adr.decision, 200),
affected_paths: adr.affected_paths.clone(),
matched_section,
relevance_score: (ranked.score.round() as usize).max(1),
score: (ranked.score * 100.0).round() / 100.0,
match_reasons: ranked.match_reasons,
}
}
fn paginate<T>(
items: Vec<T>,
offset: Option<usize>,
limit: Option<usize>,
) -> (Vec<T>, usize, usize, bool) {
let total = items.len();
let offset = offset.unwrap_or(0);
let page: Vec<T> = items
.into_iter()
.skip(offset)
.take(limit.unwrap_or(usize::MAX))
.collect();
let has_more = offset + page.len() < total;
(page, total, offset, has_more)
}
fn truncate_string(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
let mut truncated: String = s.chars().take(max_len).collect();
truncated.push_str("...");
truncated
}
}
pub async fn handle_record_adr(
context: &ProjectContextResolver,
engine: &EngineService,
params: RecordAdrParams,
) -> anyhow::Result<RecordAdrResult> {
let (project, _) = context
.resolve_project(None, params.project.as_deref())
.await?;
let root = PathBuf::from(&project.root_path);
let adr_dir = root.join("docs").join("adr");
fs::create_dir_all(&adr_dir)?;
let mut params = params;
params.title = params
.title
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if params.title.is_empty() {
anyhow::bail!("ADR title must not be empty");
}
let mut highest_num = 0u32;
if let Ok(entries) = fs::read_dir(&adr_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path.extension().and_then(|s| s.to_str()) == Some("md")
&& let Ok(content) = fs::read_to_string(&path)
{
let parsed = parse_adr_markdown(&path, &content);
if let Some(num) = parsed.adr_number {
highest_num = highest_num.max(num);
}
}
}
}
let mut guard = engine.db().lock().await;
let db_adrs =
ArchitecturalDecision::filter(ArchitecturalDecision::fields().project_id().eq(project.id))
.exec(&mut *guard)
.await?;
for adr in &db_adrs {
highest_num = highest_num.max(adr.adr_number);
}
let adr_number = highest_num + 1;
let slug = slugify(¶ms.title);
let filename = format!("{:04}-{}.md", adr_number, slug);
let file_path = adr_dir.join(&filename);
let status = params
.status
.unwrap_or(super::enums::AdrStatus::Accepted)
.as_str()
.to_string();
let consequences = params.consequences.unwrap_or_default();
let affected_paths = params.affected_paths.unwrap_or_default();
let quote = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string());
let mut yaml_frontmatter = format!(
"---\nnumber: {}\ntitle: {}\nstatus: {}\n",
adr_number,
quote(¶ms.title),
quote(&status)
);
if !affected_paths.is_empty() {
yaml_frontmatter.push_str("affected_paths:\n");
for p in &affected_paths {
yaml_frontmatter.push_str(&format!(" - {}\n", quote(p)));
}
}
yaml_frontmatter.push_str("---\n\n");
let body = format!(
"# {}. {}\n\n## Context\n{}\n\n## Decision\n{}\n\n## Consequences\n{}\n",
adr_number, params.title, params.context, params.decision, consequences
);
let full_content = format!("{}{}", yaml_frontmatter, body);
let affected_paths_json = serde_json::to_string(&affected_paths)?;
let created = ArchitecturalDecision::create()
.project_id(project.id)
.adr_number(adr_number)
.title(params.title.clone())
.status(status.clone())
.context(params.context.clone())
.decision(params.decision.clone())
.consequences(consequences)
.affected_paths(affected_paths_json)
.exec(&mut *guard)
.await?;
let write_result = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&file_path)
.and_then(|mut f| std::io::Write::write_all(&mut f, full_content.as_bytes()));
if let Err(e) = write_result {
let del_stmt = format!(
"DELETE FROM architectural_decision WHERE project_id = {} AND id = {};",
project.id, created.id
);
if let Err(del_err) = toasty::sql::statement(&del_stmt).exec(&mut *guard).await {
tracing::warn!("Could not roll back ADR {adr_number} after write failure: {del_err}");
}
drop(guard);
invalidate_adr_corpus(project.id);
anyhow::bail!("Failed to write {}: {e}", file_path.display());
}
drop(guard);
invalidate_adr_corpus(project.id);
Ok(RecordAdrResult {
adr_number,
title: params.title,
status,
file_path: file_path.to_string_lossy().to_string(),
affected_paths,
message: format!(
"ADR recorded successfully as #{:04} at {}",
adr_number,
file_path.display()
),
})
}
pub async fn handle_delete_adr(
context: &ProjectContextResolver,
engine: &EngineService,
params: DeleteAdrParams,
) -> anyhow::Result<DeleteAdrResult> {
if params.adr_number.is_none() && params.file_path.is_none() && params.title.is_none() {
anyhow::bail!(
"At least one of 'adr_number', 'file_path', or 'title' must be specified to delete an ADR."
);
}
let (project, _) = context
.resolve_project(None, params.project.as_deref())
.await?;
let root = PathBuf::from(&project.root_path);
let adr_dir = root.join("docs").join("adr");
let mut removed_file = None;
let mut target_number = params.adr_number;
if let Some(fp) = ¶ms.file_path {
let path = if Path::new(fp).is_absolute() {
PathBuf::from(fp)
} else {
root.join(fp)
};
if path.exists() {
let canonical = path.canonicalize()?;
let canonical_adr_dir = adr_dir.canonicalize().map_err(|_| {
anyhow::anyhow!("ADR directory {} does not exist", adr_dir.display())
})?;
if !canonical.starts_with(&canonical_adr_dir)
|| canonical.extension().and_then(|s| s.to_str()) != Some("md")
{
anyhow::bail!(
"Refusing to delete '{}': file_path must be a .md file inside {}",
fp,
adr_dir.display()
);
}
if let Ok(content) = fs::read_to_string(&path) {
let parsed = parse_adr_markdown(&path, &content);
if target_number.is_none() {
target_number = parsed.adr_number;
}
}
fs::remove_file(&path)?;
removed_file = Some(path.to_string_lossy().to_string());
}
}
if removed_file.is_none()
&& adr_dir.exists()
&& let Ok(entries) = fs::read_dir(&adr_dir)
{
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
let mut is_match = false;
if let Some(num) = target_number {
let prefix = format!("{:04}-", num);
let alt_prefix = format!("{}-", num);
let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if fname.starts_with(&prefix) || fname.starts_with(&alt_prefix) {
is_match = true;
}
}
if !is_match
&& (target_number.is_some() || params.title.is_some())
&& let Ok(content) = fs::read_to_string(&path)
{
let parsed = parse_adr_markdown(&path, &content);
if let Some(num) = target_number
&& parsed.adr_number == Some(num)
{
is_match = true;
}
if let Some(t) = ¶ms.title
&& parsed.title.eq_ignore_ascii_case(t)
{
is_match = true;
if target_number.is_none() {
target_number = parsed.adr_number;
}
}
}
if is_match {
fs::remove_file(&path)
.map_err(|e| anyhow::anyhow!("Failed to delete {}: {e}", path.display()))?;
removed_file = Some(path.to_string_lossy().to_string());
break;
}
}
}
}
let mut guard = engine.db().lock().await;
let db_adrs =
ArchitecturalDecision::filter(ArchitecturalDecision::fields().project_id().eq(project.id))
.exec(&mut *guard)
.await?;
let matches_target =
|adr: &ArchitecturalDecision| match (target_number, params.title.as_deref()) {
(Some(n), _) => adr.adr_number == n,
(None, Some(t)) => adr.title.eq_ignore_ascii_case(t),
(None, None) => false,
};
if target_number.is_none() {
let mut numbers: Vec<u32> = db_adrs
.iter()
.filter(|a| matches_target(a))
.map(|a| a.adr_number)
.collect();
numbers.sort_unstable();
numbers.dedup();
if numbers.len() > 1 {
anyhow::bail!("Title matches several ADRs ({numbers:?}); pass adr_number to pick one");
}
}
let mut removed_db_records = 0;
for adr in db_adrs {
if matches_target(&adr) {
let del_stmt = format!(
"DELETE FROM architectural_decision WHERE project_id = {} AND id = {};",
project.id, adr.id
);
toasty::sql::statement(&del_stmt).exec(&mut *guard).await?;
removed_db_records += 1;
}
}
drop(guard);
invalidate_adr_corpus(project.id);
let success = removed_file.is_some() || removed_db_records > 0;
let message = if success {
format!(
"Successfully deleted ADR (file: {}, db records: {})",
removed_file.as_deref().unwrap_or("none"),
removed_db_records
)
} else {
"No matching ADR found on disk or in database".to_string()
};
Ok(DeleteAdrResult {
success,
removed_file,
removed_db_records,
message,
})
}
fn slugify(title: &str) -> String {
let slug: String = title
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
let trimmed = slug.trim_matches('-');
let collapsed: String = trimmed
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if collapsed.is_empty() {
"adr".to_string()
} else {
collapsed
}
}
pub fn tool_definitions() -> Vec<Tool> {
vec![
make_tool::<QueryAdrsParams>(
"query_adrs",
"Use before architectural changes to find prior decisions, invariants and lessons on a topic or path. Searches ADRs, learnings and plans by keywords or a path and explains each ranking in match_reasons.",
read_only(),
),
make_tool::<RecordAdrParams>(
"record_adr",
"Use after an architectural decision has been made and should be preserved for future contributors. Creates a numbered ADR Markdown file in docs/adr and indexes it so query_adrs and inspect_symbol can find it; status defaults to accepted.",
mutating(false),
),
make_tool::<DeleteAdrParams>(
"delete_adr",
"Use only when deletion of a specific ADR is explicitly intended. Removes the ADR file and its index record; identify it with exactly one of adr_number, file_path (inside docs/adr) or title.",
destructive(),
),
]
}