use crate::query::RankedResult;
use std::collections::HashSet;
use std::path::Path;
use std::sync::OnceLock;
static FILE_CACHE: OnceLock<Vec<std::path::PathBuf>> = OnceLock::new();
static NU_SY_KANBAN_PATH: &str = "nusy-kanban";
fn get_cached_files() -> &'static Vec<std::path::PathBuf> {
FILE_CACHE.get_or_init(|| {
let root = Path::new(NU_SY_KANBAN_PATH);
if !root.exists() {
return Vec::new();
}
collect_md_files(root)
})
}
fn collect_md_files(dir: &Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(collect_md_files(&path));
} else if path.extension().is_some_and(|e| e == "md") {
files.push(path);
}
}
}
files
}
pub fn search_files(
search_text: &str,
arrow_ids: &HashSet<&str>,
) -> Vec<RankedResult> {
let files = get_cached_files();
if files.is_empty() {
return Vec::new();
}
let search_lower = search_text.to_lowercase();
let mut results = Vec::new();
for file_path in files {
let content = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(_) => continue,
};
if !content.to_lowercase().contains(&search_lower) {
continue;
}
let id = extract_id_from_frontmatter(&content)
.or_else(|| extract_id_from_filename(file_path))
.unwrap_or_else(|| {
file_path.to_string_lossy().replace('/', "-")
});
if arrow_ids.contains(id.as_str()) {
continue;
}
let title = extract_title(&content)
.unwrap_or_else(|| {
file_path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| id.clone())
});
let item_type = extract_type_from_frontmatter(&content)
.unwrap_or_else(|| derive_type_from_path(file_path));
results.push(RankedResult {
id,
title,
item_type,
status: String::new(),
priority: String::new(),
assignee: String::new(),
score: 0.5, });
}
results
}
fn extract_id_from_frontmatter(content: &str) -> Option<String> {
let first_dash = content.find("---")?;
let second_dash = content[first_dash + 3..].find("---")?;
let frontmatter = &content[first_dash + 3..first_dash + 3 + second_dash];
for line in frontmatter.lines() {
let trimmed = line.trim();
if trimmed.starts_with("id:") {
let val = trimmed.trim_start_matches("id:").trim();
return Some(val.trim_matches('"').trim_matches('\'').to_string());
}
}
None
}
fn extract_type_from_frontmatter(content: &str) -> Option<String> {
let first_dash = content.find("---")?;
let second_dash = content[first_dash + 3..].find("---")?;
let frontmatter = &content[first_dash + 3..first_dash + 3 + second_dash];
for line in frontmatter.lines() {
let line = line.trim();
if line.starts_with("type:") {
let val = line.trim_start_matches("type:").trim();
return Some(val.trim_matches('"').to_string());
}
}
None
}
fn extract_title(content: &str) -> Option<String> {
let body = content
.strip_prefix("---")
.and_then(|rest| rest.find("---").map(|i| &rest[i + 3..]))
.unwrap_or(content);
for line in body.lines() {
let line = line.trim();
if line.starts_with("# ") {
return Some(line.trim_start_matches("# ").to_string());
}
}
None
}
fn derive_type_from_path(path: &Path) -> String {
path.components()
.last()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.unwrap_or_else(|| "file".to_string())
}
fn extract_id_from_filename(path: &Path) -> Option<String> {
path.file_stem()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.map(|s| {
s.split('/').last().unwrap_or(s).to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_file(dir: &Path, rel_path: &str, content: &str) -> std::path::PathBuf {
let file_path = dir.join(rel_path);
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&file_path, content).unwrap();
file_path
}
#[test]
fn test_extract_id_from_frontmatter() {
let content = r#"---
id: PAPER-099
title: "Test Paper"
type: paper
---
# PAPER-099: Test
"#;
assert_eq!(extract_id_from_frontmatter(content), Some("PAPER-099".to_string()));
}
#[test]
fn test_extract_id_from_frontmatter_quoted() {
let content = r#"---
id: "PAPER-099"
title: "Test"
---
Test
"#;
assert_eq!(extract_id_from_frontmatter(content), Some("PAPER-099".to_string()));
}
#[test]
fn test_extract_title() {
let content = r#"---
id: TEST-001
---
# This Is The Title
Body content here.
"#;
assert_eq!(extract_title(content), Some("This Is The Title".to_string()));
}
#[test]
fn test_search_files_finds_match() {
let tmp = TempDir::new().unwrap();
let nusy_kanban = tmp.path().join("nusy-kanban");
create_test_file(
&nusy_kanban,
"research/papers/TEST-PAPER.md",
r#"---
id: TEST-PAPER
type: paper
---
# Test Paper Title
"#,
);
let files = collect_md_files(&nusy_kanban);
assert_eq!(files.len(), 1);
let content = std::fs::read_to_string(files[0].as_path()).unwrap();
assert!(content.contains("Test Paper"));
let _ = FILE_CACHE.set(Vec::new());
}
#[test]
fn test_extract_type_from_frontmatter() {
let content = r#"---
id: H-001
type: hypothesis
---
# Hypothesis
"#;
assert_eq!(
extract_type_from_frontmatter(content),
Some("hypothesis".to_string())
);
}
}