use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::fs::AsyncFileSystem;
use crate::workspace::Workspace;
#[derive(Debug, Clone, Serialize)]
pub struct SearchQuery {
pub pattern: String,
pub case_sensitive: bool,
pub mode: SearchMode,
}
#[derive(Debug, Clone, Serialize)]
pub enum SearchMode {
Content,
Frontmatter,
Property(String),
}
impl SearchQuery {
pub fn content(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
case_sensitive: false,
mode: SearchMode::Content,
}
}
pub fn frontmatter(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
case_sensitive: false,
mode: SearchMode::Frontmatter,
}
}
pub fn property(pattern: impl Into<String>, property_name: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
case_sensitive: false,
mode: SearchMode::Property(property_name.into()),
}
}
pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
self.case_sensitive = case_sensitive;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "bindings/")]
pub struct SearchMatch {
pub line_number: usize,
pub line_content: String,
pub match_start: usize,
pub match_end: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "bindings/")]
pub struct FileSearchResult {
pub path: PathBuf,
pub title: Option<String>,
pub matches: Vec<SearchMatch>,
}
impl FileSearchResult {
pub fn has_matches(&self) -> bool {
!self.matches.is_empty()
}
pub fn match_count(&self) -> usize {
self.matches.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "bindings/")]
pub struct SearchResults {
pub files: Vec<FileSearchResult>,
pub files_searched: usize,
}
impl SearchResults {
pub fn new() -> Self {
Self {
files: Vec::new(),
files_searched: 0,
}
}
pub fn total_matches(&self) -> usize {
self.files.iter().map(|f| f.match_count()).sum()
}
pub fn files_with_matches(&self) -> usize {
self.files.len()
}
}
impl Default for SearchResults {
fn default() -> Self {
Self::new()
}
}
pub struct Searcher<FS: AsyncFileSystem> {
fs: FS,
}
impl<FS: AsyncFileSystem> Searcher<FS> {
pub fn new(fs: FS) -> Self {
Self { fs }
}
pub async fn search_workspace(
&self,
workspace_root: &Path,
query: &SearchQuery,
) -> crate::error::Result<SearchResults>
where
FS: Clone,
{
let workspace = Workspace::new(self.fs.clone());
let files = workspace.collect_workspace_files(workspace_root).await?;
let mut results = SearchResults::new();
results.files_searched = files.len();
for file_path in files {
if let Some(file_result) = self.search_file(&file_path, query).await?
&& file_result.has_matches()
{
results.files.push(file_result);
}
}
Ok(results)
}
pub async fn search_file(
&self,
path: &Path,
query: &SearchQuery,
) -> crate::error::Result<Option<FileSearchResult>> {
let content = match self.fs.read_to_string(path).await {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(crate::error::DiaryxError::FileRead {
path: path.to_path_buf(),
source: e,
});
}
};
let (frontmatter_str, body, title) = self.parse_file_parts(&content);
let matches = match &query.mode {
SearchMode::Content => self.search_text(&body, &query.pattern, query.case_sensitive),
SearchMode::Frontmatter => {
self.search_text(&frontmatter_str, &query.pattern, query.case_sensitive)
}
SearchMode::Property(prop_name) => self.search_property(
&frontmatter_str,
prop_name,
&query.pattern,
query.case_sensitive,
),
};
Ok(Some(FileSearchResult {
path: path.to_path_buf(),
title,
matches,
}))
}
fn parse_file_parts(&self, content: &str) -> (String, String, Option<String>) {
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return (String::new(), content.to_string(), None);
}
let rest = &content[4..]; let end_idx = rest.find("\n---\n").or_else(|| rest.find("\n---\r\n"));
match end_idx {
Some(idx) => {
let frontmatter_str = rest[..idx].to_string();
let body = rest[idx + 5..].to_string();
let title = self.extract_title(&frontmatter_str);
(frontmatter_str, body, title)
}
None => {
(String::new(), content.to_string(), None)
}
}
}
fn extract_title(&self, frontmatter: &str) -> Option<String> {
for line in frontmatter.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("title:") {
let title = rest.trim();
let title = title.trim_matches('"').trim_matches('\'');
if !title.is_empty() {
return Some(title.to_string());
}
}
}
None
}
fn search_text(&self, text: &str, pattern: &str, case_sensitive: bool) -> Vec<SearchMatch> {
let mut matches = Vec::new();
let search_pattern = if case_sensitive {
pattern.to_string()
} else {
pattern.to_lowercase()
};
for (line_idx, line) in text.lines().enumerate() {
let search_line = if case_sensitive {
line.to_string()
} else {
line.to_lowercase()
};
let mut start = 0;
while let Some(pos) = search_line[start..].find(&search_pattern) {
let match_start = start + pos;
let match_end = match_start + pattern.len();
matches.push(SearchMatch {
line_number: line_idx + 1,
line_content: line.to_string(),
match_start,
match_end,
});
start = match_end;
}
}
matches
}
fn search_property(
&self,
frontmatter: &str,
property: &str,
pattern: &str,
case_sensitive: bool,
) -> Vec<SearchMatch> {
let mut matches = Vec::new();
let mut in_property = false;
let mut property_indent: Option<usize> = None;
let prop_prefix = format!("{}:", property);
let search_pattern = if case_sensitive {
pattern.to_string()
} else {
pattern.to_lowercase()
};
for (line_idx, line) in frontmatter.lines().enumerate() {
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
if trimmed.contains(':') && !trimmed.starts_with('-') && !trimmed.starts_with('#') {
if trimmed.starts_with(&prop_prefix) {
in_property = true;
property_indent = Some(indent);
let value_part = trimmed[prop_prefix.len()..].trim();
if !value_part.is_empty() {
let search_value = if case_sensitive {
value_part.to_string()
} else {
value_part.to_lowercase()
};
if let Some(pos) = search_value.find(&search_pattern) {
let offset = line.find(value_part).unwrap_or(0);
matches.push(SearchMatch {
line_number: line_idx + 1,
line_content: line.to_string(),
match_start: offset + pos,
match_end: offset + pos + pattern.len(),
});
}
}
} else if indent <= property_indent.unwrap_or(0) {
in_property = false;
property_indent = None;
}
} else if in_property {
if let Some(prop_indent) = property_indent {
if indent <= prop_indent && !trimmed.is_empty() {
in_property = false;
property_indent = None;
} else {
let search_line = if case_sensitive {
line.to_string()
} else {
line.to_lowercase()
};
if let Some(pos) = search_line.find(&search_pattern) {
matches.push(SearchMatch {
line_number: line_idx + 1,
line_content: line.to_string(),
match_start: pos,
match_end: pos + pattern.len(),
});
}
}
}
}
}
matches
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{FileSystem, InMemoryFileSystem, SyncToAsyncFs, block_on_test};
type TestFs = SyncToAsyncFs<InMemoryFileSystem>;
fn make_test_fs() -> InMemoryFileSystem {
InMemoryFileSystem::new()
}
#[test]
fn test_search_content() {
let fs = make_test_fs();
fs.write_file(
Path::new("/test/entry.md"),
"---\ntitle: Test Entry\n---\n\nThis is some content.\nWith multiple lines.\n",
)
.unwrap();
let async_fs: TestFs = SyncToAsyncFs::new(fs);
let searcher = Searcher::new(async_fs);
let query = SearchQuery::content("content");
let result = block_on_test(searcher.search_file(Path::new("/test/entry.md"), &query))
.unwrap()
.unwrap();
assert_eq!(result.title, Some("Test Entry".to_string()));
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 2); assert!(result.matches[0].line_content.contains("content"));
}
#[test]
fn test_search_content_case_insensitive() {
let fs = make_test_fs();
fs.write_file(
Path::new("/test/entry.md"),
"---\ntitle: Test\n---\n\nHello WORLD and world.\n",
)
.unwrap();
let async_fs: TestFs = SyncToAsyncFs::new(fs);
let searcher = Searcher::new(async_fs);
let query = SearchQuery::content("world");
let result = block_on_test(searcher.search_file(Path::new("/test/entry.md"), &query))
.unwrap()
.unwrap();
assert_eq!(result.matches.len(), 2);
}
#[test]
fn test_search_content_case_sensitive() {
let fs = make_test_fs();
fs.write_file(
Path::new("/test/entry.md"),
"---\ntitle: Test\n---\n\nHello WORLD and world.\n",
)
.unwrap();
let async_fs: TestFs = SyncToAsyncFs::new(fs);
let searcher = Searcher::new(async_fs);
let query = SearchQuery::content("world").case_sensitive(true);
let result = block_on_test(searcher.search_file(Path::new("/test/entry.md"), &query))
.unwrap()
.unwrap();
assert_eq!(result.matches.len(), 1);
}
#[test]
fn test_search_frontmatter() {
let fs = make_test_fs();
fs.write_file(
Path::new("/test/entry.md"),
"---\ntitle: Important Meeting\ndescription: A very important meeting\n---\n\nBody content here.\n",
)
.unwrap();
let async_fs: TestFs = SyncToAsyncFs::new(fs);
let searcher = Searcher::new(async_fs);
let query = SearchQuery::frontmatter("important");
let result = block_on_test(searcher.search_file(Path::new("/test/entry.md"), &query))
.unwrap()
.unwrap();
assert_eq!(result.matches.len(), 2);
}
#[test]
fn test_search_specific_property() {
let fs = make_test_fs();
fs.write_file(
Path::new("/test/entry.md"),
"---\ntitle: Meeting Notes\ntags:\n - important\n - work\n---\n\nSome important content.\n",
)
.unwrap();
let async_fs: TestFs = SyncToAsyncFs::new(fs);
let searcher = Searcher::new(async_fs);
let query = SearchQuery::property("important", "tags");
let result = block_on_test(searcher.search_file(Path::new("/test/entry.md"), &query))
.unwrap()
.unwrap();
assert_eq!(result.matches.len(), 1);
assert!(result.matches[0].line_content.contains("important"));
}
#[test]
fn test_search_no_frontmatter() {
let fs = make_test_fs();
fs.write_file(
Path::new("/test/entry.md"),
"Just plain content.\nNo frontmatter.\n",
)
.unwrap();
let async_fs: TestFs = SyncToAsyncFs::new(fs);
let searcher = Searcher::new(async_fs);
let query = SearchQuery::content("plain");
let result = block_on_test(searcher.search_file(Path::new("/test/entry.md"), &query))
.unwrap()
.unwrap();
assert!(result.title.is_none());
assert_eq!(result.matches.len(), 1);
}
#[test]
fn test_extract_title_with_quotes() {
let fs = make_test_fs();
fs.write_file(
Path::new("/test/entry.md"),
"---\ntitle: \"Quoted Title\"\n---\n\nContent.\n",
)
.unwrap();
let async_fs: TestFs = SyncToAsyncFs::new(fs);
let searcher = Searcher::new(async_fs);
let query = SearchQuery::content("Content");
let result = block_on_test(searcher.search_file(Path::new("/test/entry.md"), &query))
.unwrap()
.unwrap();
assert_eq!(result.title, Some("Quoted Title".to_string()));
}
}