synops/tools/synops-search/src/main.rs
vegard 6496434bd3 synops-common: delt lib for alle CLI-verktøy (oppgave 21.16)
Ny crate `tools/synops-common` samler duplisert kode som var
spredt over 13 CLI-verktøy:

- db::connect() — PG-pool fra DATABASE_URL (erstatter 10+ identiske blokker)
- cas::path() — CAS-stioppslag med to-nivå hash-katalog
- cas::root() — CAS_ROOT env med default
- cas::hash_bytes() / hash_file() / store() — SHA-256 hashing og lagring
- cas::mime_to_extension() — MIME → filendelse
- logging::init() — tracing til stderr med env-filter
- types::{NodeRow, EdgeRow, NodeSummary} — delte FromRow-structs

Alle verktøy (unntatt synops-tasks som ikke bruker DB) er refaktorert
til å bruke synops-common. Alle kompilerer og tester passerer.
2026-03-18 10:51:40 +00:00

151 lines
4.3 KiB
Rust

// synops-search — Fulltekstsøk i grafen.
//
// Bruker PostgreSQL sin tsvector/tsquery med norsk tekstkonfigurasjon
// for å søke i noder (title vektet A, content vektet B).
// Migration 011 setter opp search_vector-kolonnen og GIN-indeks.
//
// Output: matchende noder med relevans-score og utdrag til stdout.
// Logging: structured tracing til stderr.
//
// Miljøvariabler:
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
//
// Ref: docs/retninger/unix_filosofi.md, migrations/011_fulltext_search.sql
use clap::Parser;
use std::process;
use uuid::Uuid;
/// Fulltekstsøk i grafen (noder).
#[derive(Parser)]
#[command(name = "synops-search", about = "Fulltekstsøk i noder")]
struct Cli {
/// Søkestreng (fritekst)
query: String,
/// Filtrer på node_kind (f.eks. content, communication, task)
#[arg(long)]
kind: Option<String>,
/// Maks antall resultater (default: 20)
#[arg(long, default_value_t = 20)]
limit: i64,
}
#[derive(sqlx::FromRow)]
struct SearchResult {
id: Uuid,
node_kind: String,
title: Option<String>,
visibility: String,
created_at: chrono::DateTime<chrono::Utc>,
rank: f32,
headline: Option<String>,
}
#[tokio::main]
async fn main() {
synops_common::logging::init("synops_search");
let cli = Cli::parse();
if let Err(e) = run(cli).await {
eprintln!("Feil: {e}");
process::exit(1);
}
}
async fn run(cli: Cli) -> Result<(), String> {
let db = synops_common::db::connect().await?;
let query = &cli.query;
// Bygg SQL dynamisk basert på om --kind er satt
let results = if let Some(ref kind) = cli.kind {
sqlx::query_as::<_, SearchResult>(
"SELECT \
n.id, \
n.node_kind::text, \
n.title, \
n.visibility::text, \
n.created_at, \
ts_rank(n.search_vector, websearch_to_tsquery('norwegian', $1)) AS rank, \
ts_headline('norwegian', COALESCE(n.content, ''), websearch_to_tsquery('norwegian', $1), \
'MaxWords=35, MinWords=15, MaxFragments=1, StartSel=«, StopSel=»') AS headline \
FROM nodes n \
WHERE n.search_vector @@ websearch_to_tsquery('norwegian', $1) \
AND n.node_kind = $2 \
ORDER BY rank DESC \
LIMIT $3",
)
.bind(query)
.bind(kind)
.bind(cli.limit)
.fetch_all(&db)
.await
.map_err(|e| format!("Søkefeil: {e}"))?
} else {
sqlx::query_as::<_, SearchResult>(
"SELECT \
n.id, \
n.node_kind::text, \
n.title, \
n.visibility::text, \
n.created_at, \
ts_rank(n.search_vector, websearch_to_tsquery('norwegian', $1)) AS rank, \
ts_headline('norwegian', COALESCE(n.content, ''), websearch_to_tsquery('norwegian', $1), \
'MaxWords=35, MinWords=15, MaxFragments=1, StartSel=«, StopSel=»') AS headline \
FROM nodes n \
WHERE n.search_vector @@ websearch_to_tsquery('norwegian', $1) \
ORDER BY rank DESC \
LIMIT $2",
)
.bind(query)
.bind(cli.limit)
.fetch_all(&db)
.await
.map_err(|e| format!("Søkefeil: {e}"))?
};
tracing::info!(
query = %query,
kind = cli.kind.as_deref().unwrap_or("alle"),
results = results.len(),
"Søk utført"
);
if results.is_empty() {
println!("Ingen treff for «{query}».");
return Ok(());
}
// Bygg markdown-output
println!("# Søkeresultater for «{query}»\n");
println!("{} treff:\n", results.len());
for (i, r) in results.iter().enumerate() {
let title = r.title.as_deref().unwrap_or("Uten tittel");
let date = r.created_at.format("%Y-%m-%d %H:%M");
println!(
"{}. **{}** (`{}`)\n ID: `{}` | {} | {}\n Relevans: {:.3}",
i + 1,
title,
r.node_kind,
r.id,
r.visibility,
date,
r.rank,
);
if let Some(ref headline) = r.headline {
let hl = headline.trim();
if !hl.is_empty() {
println!(" > {hl}");
}
}
println!();
}
Ok(())
}