480 lines
16 KiB
Rust
480 lines
16 KiB
Rust
|
|
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(¶(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(§ion_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(§ion_header(label));
|
||
|
|
h.push_str(&text_block(text));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if let Some(cons) = json.get("consequences") {
|
||
|
|
h.push_str(§ion_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(§ion_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(§ion_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(¶(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(§ion_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(§ion_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('&', "&")
|
||
|
|
.replace('<', "<")
|
||
|
|
.replace('>', ">")
|
||
|
|
.replace('"', """)
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|