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, pub level: Option, } /// Case-insensitive full-text search across ontology nodes, ADRs, and /// reflection modes. pub async fn search_project( root: &Path, cache: &Arc, import_path: Option<&str>, query: &str, ) -> Vec { 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, import_path: Option<&str>, q: &str, out: &mut Vec, ) { 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, import_path: Option<&str>, q: &str, out: &mut Vec, ) { 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::>() .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, import_path: Option<&str>, q: &str, out: &mut Vec, ) { 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::>()) .unwrap_or_default(); let mut h = String::new(); h.push_str(¶(desc)); h.push_str("
"); 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("
"); if !artifacts.is_empty() { h.push_str(§ion_header("Artifacts")); h.push_str("
    "); for a in artifacts { h.push_str(&format!( "
  • {}
  • ", esc(a) )); } h.push_str("
"); } 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!( "
{}
", 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!( "

{label}

" )); 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("
"); h.push_str(&format!( "
{severity}

{}

", esc(claim) )); if !rationale.is_empty() { h.push_str(&format!( "

{}

", esc(rationale) )); } h.push_str("
"); } } } 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("
"); h.push_str(&format!( "

{}

", esc(opt) )); if !why.is_empty() { h.push_str(&format!( "

{}

", esc(why) )); } h.push_str("
"); } } } 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("
    "); 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("
  1. "); h.push_str(&format!( "
    {}{}
    ", esc(step_id), esc(actor) )); if !note.is_empty() { h.push_str(&format!( "

    {}

    ", esc(note) )); } if !cmd.is_empty() { h.push_str(&format!( "
    {}
    ", esc(cmd) )); } h.push_str("
  2. "); } h.push_str("
"); } } 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!( "

{}

", esc(label) ) } fn para(text: &str) -> String { let escaped = esc(text); // double-newline → paragraph break, single newline →
let paras: Vec<_> = escaped.split("\n\n").collect(); paras .iter() .map(|p| { format!( "

{}

", p.replace('\n', "
") ) }) .collect::>() .join("") } fn text_block(text: &str) -> String { format!( "
{}
", para(text) ) } fn badge(text: &str, cls: &str) -> String { format!("{}", esc(text)) } fn bullet_list(items: &[serde_json::Value]) -> String { let mut h = String::from( "
    ", ); for s in items.iter().filter_map(|v| v.as_str()) { h.push_str(&format!("
  • {}
  • ", esc(s))); } h.push_str("
"); 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>" ); } #[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("

")); } #[test] fn para_double_newline_splits_paragraphs() { let r = para("first\n\nsecond"); assert_eq!(r.matches("

")); assert!(!r.contains("line one\nline two")); } #[test] fn para_escapes_html_in_content() { let r = para("bold"); assert!(r.contains("<b>")); assert!(!r.contains("")); } // ── node_html ───────────────────────────────────────────────────────── #[test] fn node_html_escapes_malicious_content() { let node = serde_json::json!({ "description": "", "level": "core", "pole": "technical", }); let html = node_html(&node); assert!(!html.contains("