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.
455 lines
14 KiB
Rust
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!();
|
|
}
|