use crate::config::Config;
use crate::markdown::Node;
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Serialize)]
pub struct SearchResult {
pub term: String,
pub ring: Option<u32>,
pub score: u32,
pub definition: String,
}
const WEIGHT_NAME_EXACT: u32 = 100;
const WEIGHT_NAME_SUBSTRING: u32 = 60;
const WEIGHT_DEFINITION: u32 = 30;
const WEIGHT_BODY: u32 = 10;
pub fn run(
ontology_dir: &Path,
query: &str,
json: bool,
limit: usize,
) -> Result<(), String> {
let results = search(ontology_dir, query, limit)?;
if results.is_empty() {
if json {
println!("[]");
} else {
println!("No results for \"{query}\"");
}
return Ok(());
}
if json {
let json_output = serde_json::to_string_pretty(&results)
.map_err(|e| format!("JSON serialization error: {e}"))?;
println!("{json_output}");
} else {
for r in &results {
let ring_label = match r.ring {
Some(n) => format!("Ring {n}"),
None => "Ring ?".to_string(),
};
println!("{} ({ring_label}) — {}", r.term, r.definition);
}
}
Ok(())
}
pub fn search(
ontology_dir: &Path,
query: &str,
limit: usize,
) -> Result<Vec<SearchResult>, String> {
let src_dir = ontology_dir.join("src");
if !src_dir.is_dir() {
return Err(format!(
"Ontology src directory not found at {}",
src_dir.display()
));
}
let ring_map = load_ring_map(ontology_dir);
let query_lower = query.to_lowercase();
let mut results: Vec<SearchResult> = Vec::new();
let entries = std::fs::read_dir(&src_dir)
.map_err(|e| format!("Cannot read {}: {e}", src_dir.display()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let term_name = stem.to_string();
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let score = compute_score(&term_name, &content, &query_lower);
if score == 0 {
continue;
}
let definition = extract_definition(&content);
let ring = ring_map.as_ref().and_then(|m| m.get(&term_name).copied());
results.push(SearchResult {
term: term_name,
ring,
score,
definition,
});
}
results.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.term.cmp(&b.term)));
results.truncate(limit);
Ok(results)
}
fn compute_score(term_name: &str, content: &str, query_lower: &str) -> u32 {
let mut score = 0u32;
let term_lower = term_name.to_lowercase();
if term_lower == query_lower || term_lower.replace('-', " ") == query_lower {
score += WEIGHT_NAME_EXACT;
}
else if term_lower.contains(query_lower) || query_lower.contains(&term_lower) {
score += WEIGHT_NAME_SUBSTRING;
}
let definition = extract_definition(content);
if definition.to_lowercase().contains(query_lower) {
score += WEIGHT_DEFINITION;
}
if content.to_lowercase().contains(query_lower) {
score += WEIGHT_BODY;
}
score
}
fn extract_definition(content: &str) -> String {
if let Ok(node) = Node::parse(content)
&& let Some(ref ontology) = node.ontology
{
for line in ontology.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
return trimmed.trim_start_matches("# ").to_string();
}
}
"(no definition)".to_string()
}
fn load_ring_map(ontology_dir: &Path) -> Option<std::collections::HashMap<String, u32>> {
let config_path = ontology_dir.join("existence.toml");
let config = Config::load(&config_path).ok()?;
let mut map = std::collections::HashMap::new();
for (level, ring) in config.rings_sorted() {
for term in &ring.terms {
map.insert(term.clone(), level);
}
}
Some(map)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn setup_test_ontology(tmp: &std::path::Path) {
let src = tmp.join("src");
fs::create_dir_all(&src).unwrap();
fs::write(
tmp.join("existence.toml"),
r#"[meta]
name = "test"
description = "test ontology"
[rings.0]
name = "kernel"
description = "core"
terms = ["existence", "evolution"]
[rings.1]
name = "software"
description = "bridge"
terms = ["state"]
"#,
)
.unwrap();
fs::write(
src.join("existence.md"),
r#"# Existence
## [Ontology](./ontology.md)
Everything that 'is', or more simply, everything.
## [Axiology](./axiology.md)
Existence is the Universal Set of everything, including itself.
"#,
)
.unwrap();
fs::write(
src.join("evolution.md"),
r#"# Evolution
## [Ontology](./ontology.md)
The altering of an Entity or lineage of Entities as a learned response to the environment.
## [Axiology](./axiology.md)
Evolution is how an Entity changes in response to its context.
"#,
)
.unwrap();
fs::write(
src.join("state.md"),
r#"# State
## [Ontology](./ontology.md)
The condition of the Entity and its members.
## [Axiology](./axiology.md)
State is change captured at a moment.
"#,
)
.unwrap();
}
#[test]
fn test_search_exact_name() {
let tmp = tempfile::tempdir().unwrap();
setup_test_ontology(tmp.path());
let results = search(tmp.path(), "existence", 10).unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].term, "existence");
assert!(results[0].score >= WEIGHT_NAME_EXACT);
}
#[test]
fn test_search_substring_name() {
let tmp = tempfile::tempdir().unwrap();
setup_test_ontology(tmp.path());
let results = search(tmp.path(), "exist", 10).unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].term, "existence");
}
#[test]
fn test_search_body_match() {
let tmp = tempfile::tempdir().unwrap();
setup_test_ontology(tmp.path());
let results = search(tmp.path(), "change", 10).unwrap();
assert!(!results.is_empty());
let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
assert!(
terms.contains(&"evolution") || terms.contains(&"state"),
"Expected to find evolution or state, got: {terms:?}"
);
}
#[test]
fn test_search_limit() {
let tmp = tempfile::tempdir().unwrap();
setup_test_ontology(tmp.path());
let results = search(tmp.path(), "entity", 1).unwrap();
assert!(results.len() <= 1);
}
#[test]
fn test_search_no_results() {
let tmp = tempfile::tempdir().unwrap();
setup_test_ontology(tmp.path());
let results = search(tmp.path(), "zzzznonexistent", 10).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_search_ring_mapping() {
let tmp = tempfile::tempdir().unwrap();
setup_test_ontology(tmp.path());
let results = search(tmp.path(), "existence", 10).unwrap();
assert_eq!(results[0].ring, Some(0));
let results = search(tmp.path(), "state", 10).unwrap();
let state_result = results.iter().find(|r| r.term == "state").unwrap();
assert_eq!(state_result.ring, Some(1));
}
#[test]
fn test_extract_definition() {
let content = r#"# Existence
## [Ontology](./ontology.md)
Everything that 'is', or more simply, everything.
## [Axiology](./axiology.md)
Some value content.
"#;
let def = extract_definition(content);
assert_eq!(def, "Everything that 'is', or more simply, everything.");
}
#[test]
fn test_search_json_output() {
let tmp = tempfile::tempdir().unwrap();
setup_test_ontology(tmp.path());
let result = run(tmp.path(), "existence", true, 10);
assert!(result.is_ok());
}
#[test]
fn test_search_definition_match() {
let tmp = tempfile::tempdir().unwrap();
setup_test_ontology(tmp.path());
let results = search(tmp.path(), "condition", 10).unwrap();
assert!(!results.is_empty());
let state_result = results.iter().find(|r| r.term == "state").unwrap();
assert!(state_result.score >= WEIGHT_DEFINITION);
}
}