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(¶(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
|
|
|
|
|
}
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
|
|
|
|
|
#[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>"),
|
|
|
|
|
"<script>&"</script>"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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("<b>"));
|
|
|
|
|
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("<script>"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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("<inject>"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
}
|