synops/tools/synops-node/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

455 lines
14 KiB
Rust

// synops-node — Hent og vis en node med edges.
//
// Henter en node fra PostgreSQL og viser den med alle tilkoblede edges.
// Støtter rekursiv traversering med --depth for å vise nabolag i grafen.
// To output-formater: markdown (lesbart) og JSON (maskinlesbart).
//
// Output: node-data med edges til stdout (markdown eller JSON).
// Logging: structured tracing til stderr.
//
// Miljøvariabler:
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
//
// Ref: docs/retninger/unix_filosofi.md, docs/primitiver/nodes.md, docs/primitiver/edges.md
use clap::{Parser, ValueEnum};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::process;
use uuid::Uuid;
/// Hent og vis en node med edges.
#[derive(Parser)]
#[command(name = "synops-node", about = "Hent/vis en node med edges")]
struct Cli {
/// Node-ID (UUID)
node_id: Uuid,
/// Traverseringsdybde for edges (0 = bare noden, 1 = noden + direkte edges, osv.)
#[arg(long, default_value_t = 1)]
depth: u32,
/// Output-format
#[arg(long, default_value = "md")]
format: OutputFormat,
}
#[derive(Clone, ValueEnum)]
enum OutputFormat {
Json,
Md,
}
// --- Database-rader ---
#[derive(sqlx::FromRow)]
struct NodeRow {
id: Uuid,
node_kind: String,
title: Option<String>,
content: Option<String>,
visibility: String,
metadata: serde_json::Value,
created_at: chrono::DateTime<chrono::Utc>,
created_by: Option<Uuid>,
}
#[derive(sqlx::FromRow)]
struct EdgeRow {
id: Uuid,
source_id: Uuid,
target_id: Uuid,
edge_type: String,
metadata: serde_json::Value,
system: bool,
created_at: chrono::DateTime<chrono::Utc>,
created_by: Option<Uuid>,
}
// --- Serialiserbare output-strukturer ---
#[derive(Serialize)]
struct NodeOutput {
id: Uuid,
node_kind: String,
title: Option<String>,
content: Option<String>,
visibility: String,
metadata: serde_json::Value,
created_at: chrono::DateTime<chrono::Utc>,
created_by: Option<Uuid>,
#[serde(skip_serializing_if = "Vec::is_empty")]
edges_out: Vec<EdgeOutput>,
#[serde(skip_serializing_if = "Vec::is_empty")]
edges_in: Vec<EdgeOutput>,
}
#[derive(Serialize)]
struct EdgeOutput {
id: Uuid,
source_id: Uuid,
target_id: Uuid,
edge_type: String,
#[serde(skip_serializing_if = "is_empty_object")]
metadata: serde_json::Value,
#[serde(skip_serializing_if = "std::ops::Not::not")]
system: bool,
created_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
created_by: Option<Uuid>,
/// Sammendrag av noden i andre enden av edgen
#[serde(skip_serializing_if = "Option::is_none")]
peer_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
peer_kind: Option<String>,
}
fn is_empty_object(v: &serde_json::Value) -> bool {
v.as_object().is_some_and(|o| o.is_empty())
}
#[derive(Serialize)]
struct FullOutput {
node: NodeOutput,
#[serde(skip_serializing_if = "Vec::is_empty")]
connected_nodes: Vec<NodeOutput>,
}
#[tokio::main]
async fn main() {
synops_common::logging::init("synops_node");
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?;
// Hent hovednoden
let root_node = fetch_node(&db, cli.node_id).await?
.ok_or_else(|| format!("Node {} finnes ikke", cli.node_id))?;
// Depth 0: bare noden, ingen edges
let (edges_out, edges_in, peer_info) = if cli.depth == 0 {
(vec![], vec![], HashMap::new())
} else {
// Hent edges for hovednoden
let (eo, ei) = fetch_edges(&db, cli.node_id).await?;
// Samle peer-noder vi trenger for visning
let mut peer_ids: HashSet<Uuid> = HashSet::new();
for e in &eo {
peer_ids.insert(e.target_id);
}
for e in &ei {
peer_ids.insert(e.source_id);
}
peer_ids.remove(&cli.node_id);
let pi = fetch_node_summaries(&db, &peer_ids).await?;
(eo, ei, pi)
};
// Berik edges med peer-info
let edges_out = enrich_edges(edges_out, &peer_info, |e| e.target_id);
let edges_in = enrich_edges(edges_in, &peer_info, |e| e.source_id);
let mut root_output = node_to_output(root_node, edges_out, edges_in);
// Depth > 1: hent connected nodes med deres edges
let mut connected_nodes = Vec::new();
if cli.depth > 1 {
let mut visited: HashSet<Uuid> = HashSet::new();
visited.insert(cli.node_id);
// Bygg frontier fra edges på hovednoden
let mut frontier_set: HashSet<Uuid> = HashSet::new();
for e in &root_output.edges_out {
frontier_set.insert(e.target_id);
}
for e in &root_output.edges_in {
frontier_set.insert(e.source_id);
}
frontier_set.remove(&cli.node_id);
let mut frontier: Vec<Uuid> = frontier_set.into_iter().collect();
for _level in 1..cli.depth {
if frontier.is_empty() {
break;
}
let mut next_frontier = Vec::new();
for nid in &frontier {
if visited.contains(nid) {
continue;
}
visited.insert(*nid);
if let Some(node) = fetch_node(&db, *nid).await? {
let (eo, ei) = fetch_edges(&db, *nid).await?;
// Samle nye peers
let mut new_peers = HashSet::new();
for e in &eo {
if !visited.contains(&e.target_id) {
new_peers.insert(e.target_id);
next_frontier.push(e.target_id);
}
}
for e in &ei {
if !visited.contains(&e.source_id) {
new_peers.insert(e.source_id);
next_frontier.push(e.source_id);
}
}
let extra_peer_info = fetch_node_summaries(&db, &new_peers).await?;
// Merge peer info
let mut combined = peer_info.clone();
combined.extend(extra_peer_info);
let eo = enrich_edges(eo, &combined, |e| e.target_id);
let ei = enrich_edges(ei, &combined, |e| e.source_id);
connected_nodes.push(node_to_output(node, eo, ei));
}
}
frontier = next_frontier;
}
}
tracing::info!(
node_id = %cli.node_id,
edges_out = root_output.edges_out.len(),
edges_in = root_output.edges_in.len(),
connected = connected_nodes.len(),
depth = cli.depth,
"Node hentet"
);
match cli.format {
OutputFormat::Json => {
let output = if connected_nodes.is_empty() {
// Enkel output uten wrapper for depth <= 1
serde_json::to_string_pretty(&root_output)
} else {
let full = FullOutput {
node: root_output,
connected_nodes,
};
serde_json::to_string_pretty(&full)
};
println!("{}", output.map_err(|e| format!("JSON-serialisering feilet: {e}"))?);
}
OutputFormat::Md => {
print_markdown(&mut root_output, &connected_nodes);
}
}
Ok(())
}
// --- Database-funksjoner ---
async fn fetch_node(
db: &sqlx::PgPool,
id: Uuid,
) -> Result<Option<NodeRow>, String> {
sqlx::query_as::<_, NodeRow>(
"SELECT id, node_kind::text, title, content, visibility::text, \
metadata, created_at, created_by \
FROM nodes WHERE id = $1",
)
.bind(id)
.fetch_optional(db)
.await
.map_err(|e| format!("DB-feil (node): {e}"))
}
async fn fetch_edges(
db: &sqlx::PgPool,
node_id: Uuid,
) -> Result<(Vec<EdgeRow>, Vec<EdgeRow>), String> {
let edges_out = sqlx::query_as::<_, EdgeRow>(
"SELECT id, source_id, target_id, edge_type, metadata, system, created_at, created_by \
FROM edges WHERE source_id = $1 ORDER BY edge_type, created_at",
)
.bind(node_id)
.fetch_all(db)
.await
.map_err(|e| format!("DB-feil (edges ut): {e}"))?;
let edges_in = sqlx::query_as::<_, EdgeRow>(
"SELECT id, source_id, target_id, edge_type, metadata, system, created_at, created_by \
FROM edges WHERE target_id = $1 ORDER BY edge_type, created_at",
)
.bind(node_id)
.fetch_all(db)
.await
.map_err(|e| format!("DB-feil (edges inn): {e}"))?;
Ok((edges_out, edges_in))
}
async fn fetch_node_summaries(
db: &sqlx::PgPool,
ids: &HashSet<Uuid>,
) -> Result<HashMap<Uuid, (Option<String>, String)>, String> {
if ids.is_empty() {
return Ok(HashMap::new());
}
let ids_vec: Vec<Uuid> = ids.iter().copied().collect();
// sqlx støtter ikke IN-klausul med Vec direkte, bruk ANY
let rows = sqlx::query_as::<_, (Uuid, Option<String>, String)>(
"SELECT id, title, node_kind::text FROM nodes WHERE id = ANY($1)",
)
.bind(&ids_vec)
.fetch_all(db)
.await
.map_err(|e| format!("DB-feil (peer-noder): {e}"))?;
Ok(rows.into_iter().map(|(id, title, kind)| (id, (title, kind))).collect())
}
// --- Transformasjoner ---
fn node_to_output(node: NodeRow, edges_out: Vec<EdgeOutput>, edges_in: Vec<EdgeOutput>) -> NodeOutput {
NodeOutput {
id: node.id,
node_kind: node.node_kind,
title: node.title,
content: node.content,
visibility: node.visibility,
metadata: node.metadata,
created_at: node.created_at,
created_by: node.created_by,
edges_out,
edges_in,
}
}
fn enrich_edges(
edges: Vec<EdgeRow>,
peers: &HashMap<Uuid, (Option<String>, String)>,
peer_id_fn: fn(&EdgeRow) -> Uuid,
) -> Vec<EdgeOutput> {
edges
.into_iter()
.map(|e| {
let pid = peer_id_fn(&e);
let (peer_title, peer_kind) = peers
.get(&pid)
.map(|(t, k)| (t.clone(), Some(k.clone())))
.unwrap_or((None, None));
EdgeOutput {
id: e.id,
source_id: e.source_id,
target_id: e.target_id,
edge_type: e.edge_type,
metadata: e.metadata,
system: e.system,
created_at: e.created_at,
created_by: e.created_by,
peer_title,
peer_kind,
}
})
.collect()
}
// --- Markdown-formattering ---
fn print_markdown(node: &NodeOutput, connected: &[NodeOutput]) {
let title = node.title.as_deref().unwrap_or("Uten tittel");
println!("# {title}\n");
println!("| Felt | Verdi |");
println!("|------|-------|");
println!("| **ID** | `{}` |", node.id);
println!("| **Kind** | `{}` |", node.node_kind);
println!("| **Synlighet** | {} |", node.visibility);
println!("| **Opprettet** | {} |", node.created_at.format("%Y-%m-%d %H:%M"));
if let Some(cb) = node.created_by {
println!("| **Opprettet av** | `{cb}` |");
}
// Metadata (kompakt)
if let Some(obj) = node.metadata.as_object() {
if !obj.is_empty() {
println!("\n## Metadata\n");
println!("```json\n{}\n```", serde_json::to_string_pretty(&node.metadata).unwrap_or_default());
}
}
// Content (forkortet)
if let Some(ref content) = node.content {
println!("\n## Innhold\n");
if content.len() > 500 {
println!("{}...\n", &content[..500]);
} else {
println!("{content}\n");
}
}
// Edges ut
if !node.edges_out.is_empty() {
println!("## Edges ut ({} stk)\n", node.edges_out.len());
print_edge_table(&node.edges_out, "target");
}
// Edges inn
if !node.edges_in.is_empty() {
println!("## Edges inn ({} stk)\n", node.edges_in.len());
print_edge_table(&node.edges_in, "source");
}
// Connected nodes (depth > 1)
if !connected.is_empty() {
println!("---\n");
println!("## Tilkoblede noder ({} stk)\n", connected.len());
for cn in connected {
let ct = cn.title.as_deref().unwrap_or("Uten tittel");
println!("### {ct} (`{}`)\n", cn.node_kind);
println!("ID: `{}` | {} | {}\n", cn.id, cn.visibility, cn.created_at.format("%Y-%m-%d %H:%M"));
if !cn.edges_out.is_empty() {
println!("**Edges ut ({}):**\n", cn.edges_out.len());
print_edge_table(&cn.edges_out, "target");
}
if !cn.edges_in.is_empty() {
println!("**Edges inn ({}):**\n", cn.edges_in.len());
print_edge_table(&cn.edges_in, "source");
}
}
}
}
fn print_edge_table(edges: &[EdgeOutput], peer_col: &str) {
println!("| Type | {} | Tittel | System |", if peer_col == "target" { "Mål" } else { "Kilde" });
println!("|------|------|--------|--------|");
for e in edges {
let peer_id = if peer_col == "target" { e.target_id } else { e.source_id };
let pt = e.peer_title.as_deref().unwrap_or("-");
let pk = e.peer_kind.as_deref().unwrap_or("");
let kind_suffix = if pk.is_empty() { String::new() } else { format!(" ({})", pk) };
let sys = if e.system { "ja" } else { "" };
println!("| `{}` | `{}`{} | {} | {} |", e.edge_type, peer_id, kind_suffix, pt, sys);
// Vis edge-metadata inline hvis den ikke er tom
if let Some(obj) = e.metadata.as_object() {
if !obj.is_empty() {
let meta_str = serde_json::to_string(&e.metadata).unwrap_or_default();
println!("| | ↳ metadata: `{meta_str}` | | |");
}
}
}
println!();
}