// 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, content: Option, visibility: String, metadata: serde_json::Value, created_at: chrono::DateTime, created_by: Option, } #[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, created_by: Option, } // --- Serialiserbare output-strukturer --- #[derive(Serialize)] struct NodeOutput { id: Uuid, node_kind: String, title: Option, content: Option, visibility: String, metadata: serde_json::Value, created_at: chrono::DateTime, created_by: Option, #[serde(skip_serializing_if = "Vec::is_empty")] edges_out: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] edges_in: Vec, } #[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, #[serde(skip_serializing_if = "Option::is_none")] created_by: Option, /// Sammendrag av noden i andre enden av edgen #[serde(skip_serializing_if = "Option::is_none")] peer_title: Option, #[serde(skip_serializing_if = "Option::is_none")] peer_kind: Option, } 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, } #[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 = 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 = HashSet::new(); visited.insert(cli.node_id); // Bygg frontier fra edges på hovednoden let mut frontier_set: HashSet = 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 = 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, 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, Vec), 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, ) -> Result, String)>, String> { if ids.is_empty() { return Ok(HashMap::new()); } let ids_vec: Vec = ids.iter().copied().collect(); // sqlx støtter ikke IN-klausul med Vec direkte, bruk ANY let rows = sqlx::query_as::<_, (Uuid, Option, 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, edges_in: Vec) -> 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, peers: &HashMap, String)>, peer_id_fn: fn(&EdgeRow) -> Uuid, ) -> Vec { 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!(); }