use anyhow::{bail, Result};
use std::path::Path;
use crate::external::{
read_data_ttl, read_url_ttl, resolve_repo, ExternalCache, ExternalKnowledgeReader, RepoSource,
};
use crate::knowledge::parse_frontmatter;
fn get_reader(
crosslink_dir: &Path,
repo_value: &str,
refresh: bool,
) -> Result<(ExternalKnowledgeReader, String)> {
let source = resolve_repo(repo_value, crosslink_dir)?;
match source {
RepoSource::Local(path) => {
let reader = ExternalKnowledgeReader::for_local(&path);
Ok((reader, repo_value.to_string()))
}
RepoSource::Remote(_) => {
let cache = ExternalCache::new(crosslink_dir, repo_value);
let data_ttl = read_data_ttl(crosslink_dir);
let url_ttl = read_url_ttl(crosslink_dir);
let knowledge_dir = cache.ensure_knowledge(data_ttl, url_ttl, refresh)?;
let reader = ExternalKnowledgeReader::new(knowledge_dir);
Ok((reader, repo_value.to_string()))
}
}
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
pub fn search(
crosslink_dir: &Path,
repo_value: &str,
query: Option<&str>,
context: usize,
source_domain: Option<&str>,
refresh: bool,
json: bool,
quiet: bool,
tag: Option<&str>,
since: Option<&str>,
contributor: Option<&str>,
) -> Result<()> {
if query.is_none() && source_domain.is_none() {
bail!("Provide a search query or --source domain");
}
let (reader, label) = get_reader(crosslink_dir, repo_value, refresh)?;
if let Some(domain) = source_domain {
let mut matches = reader.search_sources(domain)?;
if let Some(q) = query {
let q_lower = q.to_lowercase();
matches.retain(|page| {
reader
.read_page(&page.slug)
.is_ok_and(|content| content.to_lowercase().contains(&q_lower))
});
}
if json {
print_sources_json(&matches, &label);
} else if !quiet {
println!("--- Results from {label} ---\n");
if matches.is_empty() {
println!("No knowledge pages cite \"{domain}\".");
} else {
for page in &matches {
let matching_sources: Vec<_> = page
.frontmatter
.sources
.iter()
.filter(|src| src.url.to_lowercase().contains(&domain.to_lowercase()))
.collect();
println!("{}.md — {}", page.slug, page.frontmatter.title);
for src in matching_sources {
print!(" {} ({})", src.url, src.title);
if let Some(ref accessed) = src.accessed_at {
print!(" [accessed: {accessed}]");
}
println!();
}
}
}
println!("\n--- End external results ---");
} else {
for page in &matches {
println!("{}", page.slug);
}
}
return Ok(());
}
let Some(query) = query else {
bail!("Provide a search query or --source domain");
};
let mut matches = reader.search_content(query, context)?;
if tag.is_some() || since.is_some() || contributor.is_some() {
matches.retain(|m| {
let Ok(content) = reader.read_page(&m.slug) else {
return false;
};
let Some(fm) = parse_frontmatter(&content) else {
return false;
};
if let Some(tag) = tag {
if !fm.tags.iter().any(|t| t == tag) {
return false;
}
}
if let Some(since) = since {
if fm.updated.as_str() < since {
return false;
}
}
if let Some(contributor) = contributor {
if !fm.contributors.iter().any(|c| c == contributor) {
return false;
}
}
true
});
}
if json {
print_content_json(&matches, &label);
} else if !quiet {
println!("--- Results from {label} ---\n");
if matches.is_empty() {
println!("No knowledge pages match \"{query}\".");
} else {
for (i, m) in matches.iter().enumerate() {
if i > 0 {
println!();
}
println!(" {}.md (line {}):", m.slug, m.line_number);
for (line_num, line) in &m.context_lines {
println!(" {line_num:>4} | {line}");
}
}
}
println!("\n--- End external results ---");
} else {
for m in &matches {
println!("{}", m.slug);
}
}
Ok(())
}
pub fn show(
crosslink_dir: &Path,
repo_value: &str,
slug: &str,
refresh: bool,
json: bool,
quiet: bool,
) -> Result<()> {
let (reader, label) = get_reader(crosslink_dir, repo_value, refresh)?;
let content = reader.read_page(slug)?;
if json {
if let Some(fm) = parse_frontmatter(&content) {
let json_obj = serde_json::json!({
"slug": slug,
"title": fm.title,
"tags": fm.tags,
"sources": fm.sources.iter().map(|s| {
let mut m = serde_json::Map::new();
m.insert("url".to_string(), serde_json::Value::String(s.url.clone()));
m.insert("title".to_string(), serde_json::Value::String(s.title.clone()));
if let Some(ref a) = s.accessed_at {
m.insert("accessed_at".to_string(), serde_json::Value::String(a.clone()));
}
serde_json::Value::Object(m)
}).collect::<Vec<_>>(),
"contributors": fm.contributors,
"created": fm.created,
"updated": fm.updated,
"source": label,
});
println!("{}", serde_json::to_string_pretty(&json_obj)?);
} else {
bail!("Page '{slug}' has no valid frontmatter");
}
} else {
if !quiet {
println!("--- Results from {label} ---\n");
}
print!("{content}");
if !quiet {
println!("\n--- End external results ---");
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn list(
crosslink_dir: &Path,
repo_value: &str,
tag_filter: Option<&str>,
contributor_filter: Option<&str>,
since: Option<&str>,
refresh: bool,
json: bool,
quiet: bool,
) -> Result<()> {
let (reader, label) = get_reader(crosslink_dir, repo_value, refresh)?;
let pages = reader.list_pages()?;
let filtered: Vec<_> = pages
.iter()
.filter(|p| {
if let Some(tag) = tag_filter {
if !p.frontmatter.tags.iter().any(|t| t == tag) {
return false;
}
}
if let Some(contributor) = contributor_filter {
if !p.frontmatter.contributors.iter().any(|c| c == contributor) {
return false;
}
}
if let Some(since) = since {
if p.frontmatter.updated.as_str() < since {
return false;
}
}
true
})
.collect();
if json {
let entries: Vec<serde_json::Value> = filtered
.iter()
.map(|p| {
serde_json::json!({
"slug": p.slug,
"title": p.frontmatter.title,
"tags": p.frontmatter.tags,
"updated": p.frontmatter.updated,
"source": label,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
if !quiet {
println!("--- Results from {label} ---\n");
}
if filtered.is_empty() {
if !quiet {
println!("No knowledge pages found.");
}
} else {
if !quiet {
println!("{:<30} {:<30} {:<20} UPDATED", "SLUG", "TITLE", "TAGS");
println!("{}", "-".repeat(90));
}
for page in &filtered {
let tags_str = if page.frontmatter.tags.is_empty() {
String::new()
} else {
page.frontmatter.tags.join(", ")
};
println!(
"{:<30} {:<30} {:<20} {}",
page.slug,
crate::utils::truncate(&page.frontmatter.title, 28),
crate::utils::truncate(&tags_str, 18),
page.frontmatter.updated,
);
}
if !quiet {
println!("\n{} page(s)", filtered.len());
}
}
if !quiet {
println!("\n--- End external results ---");
}
Ok(())
}
fn print_content_json(matches: &[crate::knowledge::SearchMatch], source: &str) {
let entries: Vec<serde_json::Value> = matches
.iter()
.map(|m| {
let lines: Vec<serde_json::Value> = m
.context_lines
.iter()
.map(|(num, text)| serde_json::json!({"line": num, "text": text}))
.collect();
serde_json::json!({
"slug": m.slug,
"line_number": m.line_number,
"context_lines": lines,
"source": source,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
);
}
fn print_sources_json(matches: &[crate::knowledge::PageInfo], source: &str) {
let entries: Vec<serde_json::Value> = matches
.iter()
.map(|p| {
serde_json::json!({
"slug": p.slug,
"title": p.frontmatter.title,
"tags": p.frontmatter.tags,
"sources": p.frontmatter.sources.iter().map(|s| {
serde_json::json!({
"url": s.url,
"title": s.title,
})
}).collect::<Vec<_>>(),
"source": source,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
);
}