ontoref-code/crates/ontoref-daemon/src/search.rs

675 lines
22 KiB
Rust
Raw Normal View History

2026-03-13 00:18:14 +00:00
use std::path::Path;
use std::sync::Arc;
use serde::Serialize;
use tracing::warn;
use crate::cache::NclCache;
#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ResultKind {
Node,
Adr,
Mode,
}
#[derive(Debug, Serialize)]
pub struct SearchResult {
pub kind: ResultKind,
pub id: String,
pub title: String,
pub description: String,
pub detail_html: String,
pub path: String,
pub pole: Option<String>,
pub level: Option<String>,
}
/// Case-insensitive full-text search across ontology nodes, ADRs, and
/// reflection modes.
pub async fn search_project(
root: &Path,
cache: &Arc<NclCache>,
import_path: Option<&str>,
query: &str,
) -> Vec<SearchResult> {
let q = query.to_lowercase();
let mut results = Vec::new();
// Ensure root is always in NICKEL_IMPORT_PATH so that cross-directory imports
// like `import "reflection/defaults.ncl"` resolve from the project root,
// regardless of whether the project has an explicit nickel_import_paths config.
let root_str = root.display().to_string();
let effective_ip = match import_path {
Some(ip) if !ip.is_empty() => format!("{root_str}:{ip}"),
_ => root_str,
};
let ip = Some(effective_ip.as_str());
search_nodes(root, cache, ip, &q, &mut results).await;
search_adrs(root, cache, ip, &q, &mut results).await;
search_modes(root, cache, ip, &q, &mut results).await;
results
}
async fn search_nodes(
root: &Path,
cache: &Arc<NclCache>,
import_path: Option<&str>,
q: &str,
out: &mut Vec<SearchResult>,
) {
let core_path = root.join(".ontology").join("core.ncl");
if !core_path.exists() {
return;
}
let Ok((json, _)) = cache.export(&core_path, import_path).await else {
return;
};
let Some(nodes) = json.get("nodes").and_then(|n| n.as_array()) else {
return;
};
for node in nodes {
let id = str_field(node, "id");
let name = str_field(node, "name");
let desc = str_field(node, "description");
if matches(q, &[id, name, desc]) {
out.push(SearchResult {
kind: ResultKind::Node,
id: str_field(node, "id").to_string(),
title: str_field(node, "name").to_string(),
description: truncate(str_field(node, "description"), 180),
detail_html: node_html(node),
path: ".ontology/core.ncl".to_string(),
pole: node.get("pole").and_then(|v| v.as_str()).map(String::from),
level: node.get("level").and_then(|v| v.as_str()).map(String::from),
});
}
}
}
async fn search_adrs(
root: &Path,
cache: &Arc<NclCache>,
import_path: Option<&str>,
q: &str,
out: &mut Vec<SearchResult>,
) {
let adrs_dir = root.join("adrs");
let Ok(entries) = std::fs::read_dir(&adrs_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
let fname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// Only process actual ADR records: adr-NNN-*.ncl
if !is_adr_record(fname) {
continue;
}
match cache.export(&path, import_path).await {
Ok((json, _)) => {
let id = str_field(&json, "id");
let title = str_field(&json, "title");
let context = str_field(&json, "context");
let decision = str_field(&json, "decision");
// Also search constraint claims
let constraint_text = json
.get("constraints")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|c| c.get("claim").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
if matches(q, &[id, title, context, decision, &constraint_text]) {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
out.push(SearchResult {
kind: ResultKind::Adr,
id: str_field(&json, "id").to_string(),
title: str_field(&json, "title").to_string(),
description: truncate(str_field(&json, "context"), 180),
detail_html: adr_html(&json),
path: rel,
pole: None,
level: None,
});
}
}
Err(e) => warn!(path = %path.display(), error = %e, "search: adr export failed"),
}
}
}
async fn search_modes(
root: &Path,
cache: &Arc<NclCache>,
import_path: Option<&str>,
q: &str,
out: &mut Vec<SearchResult>,
) {
let modes_dir = root.join("reflection").join("modes");
let Ok(entries) = std::fs::read_dir(&modes_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
match cache.export(&path, import_path).await {
Ok((json, _)) => {
let id = str_field(&json, "id");
let desc = str_field(&json, "description");
if matches(q, &[id, desc]) {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
out.push(SearchResult {
kind: ResultKind::Mode,
id: str_field(&json, "id").to_string(),
title: str_field(&json, "id").to_string(),
description: truncate(str_field(&json, "description"), 180),
detail_html: mode_html(&json),
path: rel,
pole: None,
level: None,
});
}
}
Err(e) => warn!(path = %path.display(), error = %e, "search: mode export failed"),
}
}
}
// ── Detail HTML builders ─────────────────────────────────────────────────────
fn node_html(n: &serde_json::Value) -> String {
let desc = str_field(n, "description");
let level = str_field(n, "level");
let pole = str_field(n, "pole");
let invariant = n
.get("invariant")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let artifacts = n
.get("artifact_paths")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
let mut h = String::new();
h.push_str(&para(desc));
h.push_str("<div class=\"flex flex-wrap gap-1 my-2\">");
h.push_str(&badge(level, "badge-ghost"));
h.push_str(&badge(pole, "badge-ghost"));
if invariant {
h.push_str(&badge("invariant", "badge-warning"));
}
h.push_str("</div>");
if !artifacts.is_empty() {
h.push_str(&section_header("Artifacts"));
h.push_str("<ul class=\"text-xs font-mono space-y-0.5\">");
for a in artifacts {
h.push_str(&format!(
"<li class=\"text-base-content/60\">{}</li>",
esc(a)
));
}
h.push_str("</ul>");
}
h
}
fn adr_html(json: &serde_json::Value) -> String {
let mut h = String::new();
if let Some(status) = json.get("status").and_then(|v| v.as_str()) {
h.push_str(&format!(
"<div class=\"mb-2\"><span class=\"badge badge-sm badge-outline\">{}</span></div>",
esc(status)
));
}
for field in &["context", "decision"] {
let label = if *field == "context" {
"Context"
} else {
"Decision"
};
let text = str_field(json, field);
if !text.is_empty() {
h.push_str(&section_header(label));
h.push_str(&text_block(text));
}
}
if let Some(cons) = json.get("consequences") {
h.push_str(&section_header("Consequences"));
for (label, key) in &[("Positive", "positive"), ("Negative", "negative")] {
let items = cons
.get(key)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
.unwrap_or(&[]);
if !items.is_empty() {
h.push_str(&format!(
"<p class=\"text-xs font-medium text-base-content/60 mt-1 mb-0.5\">{label}</p>"
));
h.push_str(&bullet_list(items));
}
}
}
if let Some(constraints) = json.get("constraints").and_then(|v| v.as_array()) {
if !constraints.is_empty() {
h.push_str(&section_header("Constraints"));
for c in constraints {
let claim = c.get("claim").and_then(|v| v.as_str()).unwrap_or("");
let rationale = c.get("rationale").and_then(|v| v.as_str()).unwrap_or("");
let severity = c.get("severity").and_then(|v| v.as_str()).unwrap_or("");
let badge_cls = if severity == "Hard" {
"badge-error"
} else {
"badge-warning"
};
h.push_str("<div class=\"border border-base-content/10 rounded p-2 mb-1.5\">");
h.push_str(&format!(
"<div class=\"flex items-start gap-1.5 mb-1\"><span class=\"badge badge-xs \
{badge_cls} flex-shrink-0\">{severity}</span><p class=\"text-xs \
font-medium\">{}</p></div>",
esc(claim)
));
if !rationale.is_empty() {
h.push_str(&format!(
"<p class=\"text-xs text-base-content/60\">{}</p>",
esc(rationale)
));
}
h.push_str("</div>");
}
}
}
if let Some(alts) = json
.get("alternatives_considered")
.and_then(|v| v.as_array())
{
if !alts.is_empty() {
h.push_str(&section_header("Alternatives Considered"));
for alt in alts {
let opt = alt.get("option").and_then(|v| v.as_str()).unwrap_or("");
let why = alt
.get("why_rejected")
.and_then(|v| v.as_str())
.unwrap_or("");
h.push_str("<div class=\"border border-base-content/10 rounded p-2 mb-1.5\">");
h.push_str(&format!(
"<p class=\"text-xs font-medium mb-0.5\">{}</p>",
esc(opt)
));
if !why.is_empty() {
h.push_str(&format!(
"<p class=\"text-xs text-base-content/50\">{}</p>",
esc(why)
));
}
h.push_str("</div>");
}
}
}
h
}
fn mode_html(json: &serde_json::Value) -> String {
let mut h = String::new();
let desc = str_field(json, "description");
if !desc.is_empty() {
h.push_str(&para(desc));
}
for (label, key) in &[
("Preconditions", "preconditions"),
("Postconditions", "postconditions"),
] {
let items = json
.get(key)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
.unwrap_or(&[]);
if !items.is_empty() {
h.push_str(&section_header(label));
h.push_str(&bullet_list(items));
}
}
if let Some(steps) = json.get("steps").and_then(|v| v.as_array()) {
if !steps.is_empty() {
h.push_str(&section_header("Steps"));
h.push_str("<ol class=\"space-y-1.5\">");
for step in steps {
let step_id = step.get("id").and_then(|v| v.as_str()).unwrap_or("");
let actor = step.get("actor").and_then(|v| v.as_str()).unwrap_or("");
let note = step.get("note").and_then(|v| v.as_str()).unwrap_or("");
let cmd = step.get("cmd").and_then(|v| v.as_str()).unwrap_or("");
h.push_str("<li class=\"border border-base-content/10 rounded p-2\">");
h.push_str(&format!(
"<div class=\"flex items-center gap-1.5 mb-0.5\"><span class=\"badge badge-xs \
badge-ghost font-mono\">{}</span><span class=\"badge badge-xs \
badge-outline\">{}</span></div>",
esc(step_id),
esc(actor)
));
if !note.is_empty() {
h.push_str(&format!(
"<p class=\"text-xs text-base-content/70\">{}</p>",
esc(note)
));
}
if !cmd.is_empty() {
h.push_str(&format!(
"<pre class=\"text-xs font-mono bg-base-300 rounded px-2 py-1 mt-1 \
overflow-x-auto\">{}</pre>",
esc(cmd)
));
}
h.push_str("</li>");
}
h.push_str("</ol>");
}
}
h
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Returns true only for actual ADR record files: `adr-NNN-*.ncl`
/// where NNN is one or more digits immediately after the second hyphen.
fn is_adr_record(fname: &str) -> bool {
let Some(rest) = fname.strip_prefix("adr-") else {
return false;
};
// next char must be a digit (adr-001-..., adr-1-..., etc.)
rest.starts_with(|c: char| c.is_ascii_digit()) && fname.ends_with(".ncl")
}
fn str_field<'a>(v: &'a serde_json::Value, key: &str) -> &'a str {
v.get(key).and_then(|v| v.as_str()).unwrap_or("")
}
fn matches(q: &str, fields: &[&str]) -> bool {
fields.iter().any(|f| f.to_lowercase().contains(q))
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
} else {
format!("{}", &s[..s.floor_char_boundary(n)])
}
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn section_header(label: &str) -> String {
format!(
"<p class=\"text-xs font-semibold text-base-content/40 uppercase tracking-wider mt-3 \
mb-1\">{}</p>",
esc(label)
)
}
fn para(text: &str) -> String {
let escaped = esc(text);
// double-newline → paragraph break, single newline → <br>
let paras: Vec<_> = escaped.split("\n\n").collect();
paras
.iter()
.map(|p| {
format!(
"<p class=\"text-sm leading-relaxed\">{}</p>",
p.replace('\n', "<br>")
)
})
.collect::<Vec<_>>()
.join("")
}
fn text_block(text: &str) -> String {
format!(
"<div class=\"text-sm leading-relaxed text-base-content/80 mb-1\">{}</div>",
para(text)
)
}
fn badge(text: &str, cls: &str) -> String {
format!("<span class=\"badge badge-xs {cls}\">{}</span>", esc(text))
}
fn bullet_list(items: &[serde_json::Value]) -> String {
let mut h = String::from(
"<ul class=\"list-disc list-inside text-xs space-y-0.5 text-base-content/80\">",
);
for s in items.iter().filter_map(|v| v.as_str()) {
h.push_str(&format!("<li>{}</li>", esc(s)));
}
h.push_str("</ul>");
h
}
#[cfg(test)]
mod tests {
use super::*;
// ── esc ───────────────────────────────────────────────────────────────
#[test]
fn esc_passthrough_plain() {
assert_eq!(esc("hello world"), "hello world");
}
#[test]
fn esc_all_special_chars() {
assert_eq!(
esc("<script>&\"</script>"),
"&lt;script&gt;&amp;&quot;&lt;/script&gt;"
);
}
#[test]
fn esc_empty() {
assert_eq!(esc(""), "");
}
// ── matches ───────────────────────────────────────────────────────────
#[test]
fn matches_case_insensitive_fields() {
// q is pre-lowercased by the caller; fields are lowercased inside matches.
assert!(matches("ontology", &["Ontology Design"]));
assert!(matches("design", &["Ontology Design"]));
}
#[test]
fn matches_any_field_sufficient() {
assert!(matches("adr", &["unrelated", "ADR-001 migration"]));
}
#[test]
fn matches_no_hit() {
assert!(!matches("xyz", &["alpha", "beta", "gamma"]));
}
#[test]
fn matches_empty_query_always_true() {
// Empty string is contained in every string.
assert!(matches("", &["anything"]));
}
// ── truncate ──────────────────────────────────────────────────────────
#[test]
fn truncate_short_string_unchanged() {
assert_eq!(truncate("hello", 180), "hello");
}
#[test]
fn truncate_at_boundary_unchanged() {
let s = "a".repeat(180);
assert_eq!(truncate(&s, 180), s);
}
#[test]
fn truncate_long_string_appends_ellipsis() {
let s = "a".repeat(200);
let r = truncate(&s, 180);
assert!(r.ends_with('…'));
assert!(r.len() < 200);
}
// ── is_adr_record ─────────────────────────────────────────────────────
#[test]
fn is_adr_record_valid() {
assert!(is_adr_record("adr-001-initial-decision.ncl"));
assert!(is_adr_record("adr-1-something.ncl"));
assert!(is_adr_record("adr-999-very-long-title.ncl"));
}
#[test]
fn is_adr_record_rejects_non_numeric() {
assert!(!is_adr_record("adr-schema.ncl"));
assert!(!is_adr_record("adr-new_adr.ncl"));
}
#[test]
fn is_adr_record_rejects_wrong_prefix() {
assert!(!is_adr_record("schema.ncl"));
assert!(!is_adr_record("001-something.ncl"));
}
#[test]
fn is_adr_record_rejects_wrong_extension() {
assert!(!is_adr_record("adr-001-title.toml"));
assert!(!is_adr_record("adr-001-title"));
}
// ── para ──────────────────────────────────────────────────────────────
#[test]
fn para_single_line() {
let r = para("hello world");
assert!(r.contains("<p "));
assert!(r.contains("hello world"));
assert!(r.contains("</p>"));
}
#[test]
fn para_double_newline_splits_paragraphs() {
let r = para("first\n\nsecond");
assert_eq!(r.matches("<p ").count(), 2);
}
#[test]
fn para_single_newline_becomes_br() {
let r = para("line one\nline two");
assert!(r.contains("<br>"));
assert!(!r.contains("line one\nline two"));
}
#[test]
fn para_escapes_html_in_content() {
let r = para("<b>bold</b>");
assert!(r.contains("&lt;b&gt;"));
assert!(!r.contains("<b>"));
}
// ── node_html ─────────────────────────────────────────────────────────
#[test]
fn node_html_escapes_malicious_content() {
let node = serde_json::json!({
"description": "<script>alert(1)</script>",
"level": "core",
"pole": "technical",
});
let html = node_html(&node);
assert!(!html.contains("<script>"));
assert!(html.contains("&lt;script&gt;"));
}
#[test]
fn node_html_invariant_badge_present_when_true() {
let node = serde_json::json!({
"description": "desc",
"invariant": true,
});
let html = node_html(&node);
assert!(html.contains("invariant"));
assert!(html.contains("badge-warning"));
}
#[test]
fn node_html_no_invariant_badge_when_false() {
let node = serde_json::json!({
"description": "desc",
"invariant": false,
});
let html = node_html(&node);
assert!(!html.contains("badge-warning"));
}
// ── adr_html ──────────────────────────────────────────────────────────
#[test]
fn adr_html_escapes_claim_content() {
let json = serde_json::json!({
"constraints": [{
"claim": "<inject>",
"rationale": "r",
"severity": "Hard",
}]
});
let html = adr_html(&json);
assert!(!html.contains("<inject>"));
assert!(html.contains("&lt;inject&gt;"));
}
#[test]
fn adr_html_hard_constraint_uses_error_badge() {
let json = serde_json::json!({
"constraints": [{"claim": "c", "rationale": "", "severity": "Hard"}]
});
assert!(adr_html(&json).contains("badge-error"));
}
#[test]
fn adr_html_soft_constraint_uses_warning_badge() {
let json = serde_json::json!({
"constraints": [{"claim": "c", "rationale": "", "severity": "Soft"}]
});
assert!(adr_html(&json).contains("badge-warning"));
}
}