Skip to main content

sqlite_graphrag/commands/graph_export/
handlers.rs

1//! Handlers for `graph` subcommands.
2
3use super::args::*;
4use super::formats::{
5    render_dot, render_json, render_mermaid, render_ndjson_streaming, EdgeOut, GraphSnapshot,
6    NodeOut,
7};
8use crate::cli::GraphExportFormat;
9use crate::errors::AppError;
10use crate::graph::{GraphWalk, InMemoryNeighbors, MemoryEdge, WalkDirection};
11use crate::output;
12use crate::paths::AppPaths;
13use crate::storage::connection::open_ro;
14use crate::storage::entities;
15use serde::Serialize;
16use std::collections::HashMap;
17use std::fs;
18use std::time::Instant;
19
20/// Dispatch `graph` subcommands (snapshot, traverse, stats, entities, recompute-degree).
21pub fn run(args: GraphArgs) -> Result<(), AppError> {
22    match args.subcommand {
23        None => run_entities_snapshot(
24            args.db.as_deref(),
25            args.namespace.as_deref(),
26            args.format,
27            args.json,
28            args.output.as_deref(),
29        ),
30        Some(GraphSubcommand::Traverse(mut a)) => {
31            if a.db.is_none() {
32                a.db = args.db;
33            }
34            if a.namespace.is_none() {
35                a.namespace = args.namespace;
36            }
37            run_traverse(a)
38        }
39        Some(GraphSubcommand::Stats(mut a)) => {
40            if a.db.is_none() {
41                a.db = args.db;
42            }
43            if a.namespace.is_none() {
44                a.namespace = args.namespace;
45            }
46            run_stats(a)
47        }
48        Some(GraphSubcommand::Entities(mut a)) => {
49            if a.db.is_none() {
50                a.db = args.db;
51            }
52            if a.namespace.is_none() {
53                a.namespace = args.namespace;
54            }
55            run_entities(a)
56        }
57        Some(GraphSubcommand::EntityTypes(mut a)) => {
58            if a.db.is_none() {
59                a.db = args.db;
60            }
61            if a.namespace.is_none() {
62                a.namespace = args.namespace;
63            }
64            run_entity_types(a)
65        }
66        Some(GraphSubcommand::RecomputeDegree(mut a)) => {
67            if a.db.is_none() {
68                a.db = args.db;
69            }
70            if a.namespace.is_none() {
71                a.namespace = args.namespace;
72            }
73            run_recompute_degree(a)
74        }
75    }
76}
77
78/// v1.1.1 (P3): summary of one degree-reconciliation pass.
79///
80/// `total` is every entity scanned; `updated` diverged to a non-zero real
81/// degree; `zeroed` diverged to zero (no live edges); `unchanged` already
82/// matched. `updated + zeroed + unchanged == total`.
83#[derive(Debug, Serialize, PartialEq, Eq)]
84pub(crate) struct RecomputeDegreeSummary {
85    pub(crate) total: i64,
86    pub(crate) updated: i64,
87    pub(crate) zeroed: i64,
88    pub(crate) unchanged: i64,
89}
90
91#[derive(Serialize)]
92struct RecomputeDegreeResponse {
93    namespace: Option<String>,
94    dry_run: bool,
95    total: i64,
96    updated: i64,
97    zeroed: i64,
98    unchanged: i64,
99    elapsed_ms: u64,
100}
101
102/// v1.1.1 (P3): recomputes `entities.degree` from the real `relationships`
103/// rows inside one IMMEDIATE transaction.
104///
105/// Uses the SAME per-entity semantics as the canonical
106/// [`entities::recalculate_degree`] helper (`COUNT(*) WHERE source_id = id OR
107/// target_id = id` — a self-loop counts once), so a reconciled graph is
108/// byte-identical to one maintained exclusively through link/merge/delete.
109/// With `dry_run` the transaction never writes and is rolled back on drop.
110pub(crate) fn recompute_degrees(
111    conn: &mut rusqlite::Connection,
112    namespace: Option<&str>,
113    dry_run: bool,
114) -> Result<RecomputeDegreeSummary, AppError> {
115    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
116
117    const SELECT_BASE: &str = "SELECT e.id, e.degree, \
118         (SELECT COUNT(*) FROM relationships r \
119          WHERE r.source_id = e.id OR r.target_id = e.id) \
120         FROM entities e";
121    let rows: Vec<(i64, i64, i64)> = if let Some(ns) = namespace {
122        let mut stmt = tx.prepare(&format!("{SELECT_BASE} WHERE e.namespace = ?1"))?;
123        let r = stmt
124            .query_map(rusqlite::params![ns], |r| {
125                Ok((r.get(0)?, r.get(1)?, r.get(2)?))
126            })?
127            .collect::<Result<Vec<_>, _>>()?;
128        r
129    } else {
130        let mut stmt = tx.prepare(SELECT_BASE)?;
131        let r = stmt
132            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
133            .collect::<Result<Vec<_>, _>>()?;
134        r
135    };
136
137    let mut summary = RecomputeDegreeSummary {
138        total: rows.len() as i64,
139        updated: 0,
140        zeroed: 0,
141        unchanged: 0,
142    };
143    for (id, stored, real) in rows {
144        if stored == real {
145            summary.unchanged += 1;
146            continue;
147        }
148        if !dry_run {
149            tx.execute(
150                "UPDATE entities SET degree = ?1, updated_at = unixepoch() WHERE id = ?2",
151                rusqlite::params![real, id],
152            )?;
153        }
154        if real == 0 {
155            summary.zeroed += 1;
156        } else {
157            summary.updated += 1;
158        }
159    }
160
161    if dry_run {
162        // Dropping the transaction rolls back; nothing was written anyway.
163        drop(tx);
164    } else {
165        tx.commit()?;
166    }
167    Ok(summary)
168}
169
170pub(crate) fn run_recompute_degree(args: GraphRecomputeDegreeArgs) -> Result<(), AppError> {
171    let started = Instant::now();
172    let paths = AppPaths::resolve(args.db.as_deref())?;
173    crate::storage::connection::ensure_db_ready(&paths)?;
174    let mut conn = crate::storage::connection::open_rw(&paths.db)?;
175
176    let summary = recompute_degrees(&mut conn, args.namespace.as_deref(), args.dry_run)?;
177
178    output::emit_json(&RecomputeDegreeResponse {
179        namespace: args.namespace,
180        dry_run: args.dry_run,
181        total: summary.total,
182        updated: summary.updated,
183        zeroed: summary.zeroed,
184        unchanged: summary.unchanged,
185        elapsed_ms: started.elapsed().as_millis() as u64,
186    })?;
187    Ok(())
188}
189
190pub(crate) fn run_entities_snapshot(
191    db: Option<&str>,
192    namespace: Option<&str>,
193    format: GraphExportFormat,
194    json: bool,
195    output_path: Option<&std::path::Path>,
196) -> Result<(), AppError> {
197    let started = Instant::now();
198    let paths = AppPaths::resolve(db)?;
199
200    crate::storage::connection::ensure_db_ready(&paths)?;
201
202    let conn = open_ro(&paths.db)?;
203
204    let nodes_raw = entities::list_entities(&conn, namespace)?;
205    let edges_raw = entities::list_relationships_by_namespace(&conn, namespace)?;
206
207    let id_to_name: HashMap<i64, String> =
208        nodes_raw.iter().map(|n| (n.id, n.name.clone())).collect();
209
210    let nodes: Vec<NodeOut> = nodes_raw
211        .into_iter()
212        .map(|n| NodeOut {
213            id: n.id,
214            name: n.name,
215            namespace: n.namespace,
216            r#type: n.kind.clone(),
217            kind: n.kind,
218            description: n.description,
219        })
220        .collect();
221
222    let mut edges: Vec<EdgeOut> = Vec::with_capacity(edges_raw.len());
223    let mut orphan_edges: usize = 0;
224    for r in edges_raw {
225        let from = match id_to_name.get(&r.source_id) {
226            Some(n) => n.clone(),
227            None => {
228                orphan_edges += 1;
229                tracing::warn!(target: "graph_export", source_id = r.source_id, relation = %r.relation, "edge skipped: source entity not found in id_to_name map");
230                continue;
231            }
232        };
233        let to = match id_to_name.get(&r.target_id) {
234            Some(n) => n.clone(),
235            None => {
236                orphan_edges += 1;
237                tracing::warn!(target: "graph_export", target_id = r.target_id, relation = %r.relation, "edge skipped: target entity not found in id_to_name map");
238                continue;
239            }
240        };
241        edges.push(EdgeOut {
242            from,
243            to,
244            relation: r.relation,
245            weight: r.weight,
246        });
247    }
248    if orphan_edges > 0 {
249        tracing::warn!(target: "graph_export",
250            count = orphan_edges,
251            "edges skipped due to orphaned entity references"
252        );
253    }
254
255    let effective_format = if json {
256        GraphExportFormat::Json
257    } else {
258        format
259    };
260
261    if effective_format == GraphExportFormat::Ndjson {
262        let elapsed_ms = started.elapsed().as_millis() as u64;
263        render_ndjson_streaming(&nodes, &edges, elapsed_ms, output_path)?;
264        return Ok(());
265    }
266
267    // The single-envelope JSON snapshot goes through `output::emit_json` so the
268    // agent-native surface (`--select`, `--filter`, …) is applied to it; it used
269    // to reach stdout as pre-serialized text and bypassed that layer entirely.
270    // The file destination keeps `fs::write`, but serializes via `render_json`,
271    // which applies the same surface, so both destinations stay in sync.
272    // `dot` and `mermaid` are rendered text, not JSON, so there is no record for
273    // a knob to act on and they deliberately keep their unshaped paths.
274    // GAP-SG-229: the NDJSON stream used to be listed here too. It is not text —
275    // it is one JSON object per line — and leaving it out meant the surface flags
276    // were parsed and then dropped in silence. It now emits through the stream
277    // pair in `formats::render_ndjson_streaming`, like `export`.
278    if effective_format == GraphExportFormat::Json {
279        let entities = nodes.clone();
280        let snapshot = GraphSnapshot {
281            nodes,
282            entities,
283            edges,
284            elapsed_ms: started.elapsed().as_millis() as u64,
285        };
286        if let Some(path) = output_path.filter(|_| !json) {
287            fs::write(path, render_json(&snapshot)?)?;
288            output::emit_progress(&format!("wrote {}", path.display()));
289        } else {
290            output::emit_json(&snapshot)?;
291        }
292        return Ok(());
293    }
294
295    let rendered = match effective_format {
296        GraphExportFormat::Dot => render_dot(&nodes, &edges),
297        GraphExportFormat::Mermaid => render_mermaid(&nodes, &edges),
298        GraphExportFormat::Json => unreachable!("json handled above"),
299        GraphExportFormat::Ndjson => unreachable!("ndjson handled above"),
300    };
301
302    if let Some(path) = output_path.filter(|_| !json) {
303        fs::write(path, &rendered)?;
304        output::emit_progress(&format!("wrote {}", path.display()));
305    } else {
306        output::emit_text(&rendered);
307    }
308
309    Ok(())
310}
311
312/// Expands `from_id` outward over `edges`, emitting one hop per edge examined.
313///
314/// Bidirectional and unfiltered by weight: `graph traverse` shows the whole
315/// neighbourhood of an entity, in both directions, exactly as stored.
316///
317/// The walk is breadth-first, so `depth` is the *minimum* distance from the
318/// seed. It used to run on a LIFO frontier, which made it a depth-first search
319/// and let an entity one hop away be reported at depth 3 — a number the
320/// `--depth` flag promises is a distance.
321///
322/// # Errors
323///
324/// Propagates [`AppError::Database`] (exit 10); the in-memory source never fails today.
325pub(super) fn traverse_hops(
326    edges: &[MemoryEdge],
327    id_to_name: &HashMap<i64, String>,
328    from_id: i64,
329    depth: u32,
330) -> Result<Vec<TraverseHop>, AppError> {
331    let mut hops: Vec<TraverseHop> = Vec::with_capacity(16);
332    let walk = GraphWalk {
333        direction: WalkDirection::Bidirectional,
334        weight_floor: None,
335        max_hops: depth,
336        max_neighbors_per_hop: None,
337        relation_filter: None,
338    };
339    walk.run_observed(
340        &InMemoryNeighbors::new(edges, id_to_name),
341        &[from_id],
342        |edge, hop_depth| {
343            let (entity, direction) = if edge.inbound {
344                (edge.source_name.clone(), "inbound")
345            } else {
346                (edge.target_name.clone(), "outbound")
347            };
348            hops.push(TraverseHop {
349                entity: entity.unwrap_or_default(),
350                relation: edge.relation.clone(),
351                direction: direction.to_string(),
352                weight: edge.weight,
353                depth: hop_depth,
354            });
355        },
356    )?;
357    Ok(hops)
358}
359
360pub(crate) fn run_traverse(args: GraphTraverseArgs) -> Result<(), AppError> {
361    let started = Instant::now();
362    let _ = args.format;
363    let paths = AppPaths::resolve(args.db.as_deref())?;
364
365    crate::storage::connection::ensure_db_ready(&paths)?;
366
367    let conn = open_ro(&paths.db)?;
368    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
369
370    // v1.1.05 Bug 3: exact match first; with --fuzzy auto-resolve clear
371    // nickname/prefix hits; without it, NotFound includes ranked suggestions.
372    let (from_id, resolved_name) =
373        match entities::resolve_entity_fuzzy(&conn, &namespace, &args.from, args.fuzzy)? {
374            Some((id, name, was_fuzzy)) => {
375                if was_fuzzy {
376                    tracing::warn!(
377                        target: "graph_export",
378                        query = %args.from,
379                        resolved = %name,
380                        "traverse: fuzzy-resolved entity name"
381                    );
382                }
383                (id, name)
384            }
385            None => {
386                return Err(entities::entity_not_found_with_suggestions(
387                    &conn, &namespace, &args.from,
388                ));
389            }
390        };
391
392    let all_rels = entities::list_relationships_by_namespace(&conn, Some(&namespace))?;
393    let all_entities = entities::list_entities(&conn, Some(&namespace))?;
394    let id_to_name: HashMap<i64, String> = all_entities
395        .iter()
396        .map(|e| (e.id, e.name.clone()))
397        .collect();
398
399    let edges: Vec<MemoryEdge> = all_rels
400        .iter()
401        .map(|rel| MemoryEdge {
402            source_id: rel.source_id,
403            target_id: rel.target_id,
404            relation: rel.relation.clone(),
405            weight: rel.weight,
406        })
407        .collect();
408
409    let hops = traverse_hops(&edges, &id_to_name, from_id, args.depth)?;
410
411    output::emit_json(&GraphTraverseResponse {
412        from: resolved_name,
413        namespace,
414        depth: args.depth,
415        hops,
416        elapsed_ms: started.elapsed().as_millis() as u64,
417    })?;
418
419    Ok(())
420}
421
422/// Highest edge count held by any entity, measured over the edges themselves.
423///
424/// Deliberately NOT `MAX(entities.degree)`. That column is a cache refreshed
425/// only by `merge-entities`, `normalize-entities` and `graph recompute-degree`,
426/// never by an ordinary write. Reading it made one field of the stats envelope
427/// describe a stale snapshot while `node_count`, `edge_count` and the
428/// `avg_degree` derived from them described the live graph — measured at 856
429/// against 1 452 for the same graph in `health`, which counts live.
430///
431/// The envelope also carries an arithmetic invariant: a maximum cannot fall
432/// below the mean of the same set. A cache drifting toward zero breaks that
433/// outright, making the envelope contradict itself.
434pub(crate) fn measure_max_degree(
435    conn: &rusqlite::Connection,
436    ns: Option<&str>,
437) -> Result<i64, AppError> {
438    let degree = match ns {
439        Some(n) => conn.query_row(
440            "SELECT COALESCE(MAX(deg), 0) FROM ( \
441               SELECT COUNT(r.id) AS deg FROM entities e \
442               LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
443               WHERE e.namespace = ?1 \
444               GROUP BY e.id \
445             )",
446            rusqlite::params![n],
447            |r| r.get(0),
448        )?,
449        None => conn.query_row(
450            "SELECT COALESCE(MAX(deg), 0) FROM ( \
451               SELECT COUNT(r.id) AS deg FROM entities e \
452               LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
453               GROUP BY e.id \
454             )",
455            [],
456            |r| r.get(0),
457        )?,
458    };
459    Ok(degree)
460}
461
462pub(crate) fn run_stats(args: GraphStatsArgs) -> Result<(), AppError> {
463    let started = Instant::now();
464    let paths = AppPaths::resolve(args.db.as_deref())?;
465
466    crate::storage::connection::ensure_db_ready(&paths)?;
467
468    let conn = open_ro(&paths.db)?;
469    let ns = args.namespace.as_deref();
470
471    let node_count: i64 = if let Some(n) = ns {
472        conn.query_row(
473            "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
474            rusqlite::params![n],
475            |r| r.get(0),
476        )?
477    } else {
478        conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?
479    };
480
481    let edge_count: i64 = if let Some(n) = ns {
482        conn.query_row(
483            "SELECT COUNT(*) FROM relationships r
484             JOIN entities s ON s.id = r.source_id
485             WHERE s.namespace = ?1",
486            rusqlite::params![n],
487            |r| r.get(0),
488        )?
489    } else {
490        conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?
491    };
492
493    let max_degree = measure_max_degree(&conn, ns)?;
494
495    // avg_degree = 2 * edge_count / node_count (each edge contributes 2 to total degree sum).
496    let avg_degree = if node_count > 0 {
497        2.0 * (edge_count as f64) / (node_count as f64)
498    } else {
499        0.0
500    };
501
502    let resp = GraphStatsResponse {
503        namespace: args.namespace,
504        node_count,
505        edge_count,
506        avg_degree,
507        max_degree,
508        elapsed_ms: started.elapsed().as_millis() as u64,
509    };
510
511    let effective_format = if args.json {
512        GraphStatsFormat::Json
513    } else {
514        args.format
515    };
516
517    match effective_format {
518        GraphStatsFormat::Json => output::emit_json(&resp)?,
519        GraphStatsFormat::Text => {
520            output::emit_text(&format!(
521                "nodes={} edges={} avg_degree={:.2} max_degree={} namespace={}",
522                resp.node_count,
523                resp.edge_count,
524                resp.avg_degree,
525                resp.max_degree,
526                resp.namespace.as_deref().unwrap_or("all"),
527            ));
528        }
529    }
530
531    Ok(())
532}
533
534/// Builds the `ORDER BY` clause fragment from sort options.
535///
536/// Returns a static SQL fragment such as `ORDER BY e.name ASC`.
537pub(crate) fn build_order_by(sort_by: Option<EntitySortField>, order: SortOrder) -> &'static str {
538    // The combinations are enumerated as static strings to avoid
539    // format!() allocations in the hot path and satisfy the borrow checker
540    // when the string is used inside conn.prepare().
541    match (sort_by, order) {
542        (None, SortOrder::Asc) | (Some(EntitySortField::Name), SortOrder::Asc) => {
543            "ORDER BY e.name ASC"
544        }
545        (Some(EntitySortField::Name), SortOrder::Desc) => "ORDER BY e.name DESC",
546        (Some(EntitySortField::Degree), SortOrder::Asc) => "ORDER BY degree ASC",
547        (Some(EntitySortField::Degree), SortOrder::Desc) => "ORDER BY degree DESC",
548        (Some(EntitySortField::CreatedAt), SortOrder::Asc) => "ORDER BY e.created_at ASC",
549        (Some(EntitySortField::CreatedAt), SortOrder::Desc) => "ORDER BY e.created_at DESC",
550        // Fallback: None/Desc → sort by name desc (consistent with dir variable).
551        (None, SortOrder::Desc) => "ORDER BY e.name DESC",
552    }
553}
554
555pub(crate) fn run_entities(args: GraphEntitiesArgs) -> Result<(), AppError> {
556    let started = Instant::now();
557    let paths = AppPaths::resolve(args.db.as_deref())?;
558
559    crate::storage::connection::ensure_db_ready(&paths)?;
560
561    let conn = open_ro(&paths.db)?;
562
563    let row_to_item = |r: &rusqlite::Row<'_>| -> rusqlite::Result<EntityItem> {
564        let ts: i64 = r.get(4)?;
565        let created_at = chrono::DateTime::from_timestamp(ts, 0)
566            .unwrap_or_default()
567            .format("%Y-%m-%dT%H:%M:%SZ")
568            .to_string();
569        Ok(EntityItem {
570            id: r.get(0)?,
571            name: r.get(1)?,
572            entity_type: r.get(2)?,
573            namespace: r.get(3)?,
574            created_at,
575            degree: r.get(5)?,
576            description: r.get(6)?,
577        })
578    };
579
580    let limit_i = args.limit as i64;
581    let offset_i = args.offset as i64;
582    let order_clause = build_order_by(args.sort_by, args.order);
583
584    let base_select = "SELECT e.id, e.name, COALESCE(e.type, ''), e.namespace, e.created_at,
585                        (SELECT COUNT(*) FROM relationships r
586                         WHERE r.source_id = e.id OR r.target_id = e.id) AS degree,
587                        e.description
588                 FROM entities e";
589
590    let (total_count, items) = match (args.namespace.as_deref(), args.entity_type.as_deref()) {
591        (Some(ns), Some(et)) => {
592            let count: i64 = conn.query_row(
593                "SELECT COUNT(*) FROM entities WHERE namespace = ?1 AND type = ?2",
594                rusqlite::params![ns, et],
595                |r| r.get(0),
596            )?;
597            let sql = format!(
598                "{base_select} WHERE e.namespace = ?1 AND e.type = ?2 {order_clause} LIMIT ?3 OFFSET ?4"
599            );
600            let mut stmt = conn.prepare(&sql)?;
601            let rows = stmt
602                .query_map(rusqlite::params![ns, et, limit_i, offset_i], row_to_item)?
603                .collect::<rusqlite::Result<Vec<_>>>()?;
604            (count, rows)
605        }
606        (Some(ns), None) => {
607            let count: i64 = conn.query_row(
608                "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
609                rusqlite::params![ns],
610                |r| r.get(0),
611            )?;
612            let sql =
613                format!("{base_select} WHERE e.namespace = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
614            let mut stmt = conn.prepare(&sql)?;
615            let rows = stmt
616                .query_map(rusqlite::params![ns, limit_i, offset_i], row_to_item)?
617                .collect::<rusqlite::Result<Vec<_>>>()?;
618            (count, rows)
619        }
620        (None, Some(et)) => {
621            let count: i64 = conn.query_row(
622                "SELECT COUNT(*) FROM entities WHERE type = ?1",
623                rusqlite::params![et],
624                |r| r.get(0),
625            )?;
626            let sql = format!("{base_select} WHERE e.type = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
627            let mut stmt = conn.prepare(&sql)?;
628            let rows = stmt
629                .query_map(rusqlite::params![et, limit_i, offset_i], row_to_item)?
630                .collect::<rusqlite::Result<Vec<_>>>()?;
631            (count, rows)
632        }
633        (None, None) => {
634            let count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
635            let sql = format!("{base_select} {order_clause} LIMIT ?1 OFFSET ?2");
636            let mut stmt = conn.prepare(&sql)?;
637            let rows = stmt
638                .query_map(rusqlite::params![limit_i, offset_i], row_to_item)?
639                .collect::<rusqlite::Result<Vec<_>>>()?;
640            (count, rows)
641        }
642    };
643
644    // GAP-SG-201: `--limit` here defaults to 50, so a caller that mentioned no
645    // limit at all still got a page — which is how the defect fired without
646    // anyone doing anything wrong: `--filter entity_type=person graph entities`
647    // judged 50 of 15 615 entities.
648    //
649    // The source is best-effort: clap collapses "the caller typed 50" and "the
650    // default supplied 50" into the same `usize`, and distinguishing them would
651    // mean threading `ArgMatches` here. Nothing branches on it — the refusal
652    // turns on whether the ceiling CUT, which is a fact — so the attribution
653    // only ever colours the message.
654    crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
655        applied: args.limit,
656        offset: args.offset,
657        source: if args.limit == crate::constants::K_GRAPH_ENTITIES_DEFAULT_LIMIT {
658            crate::agent_surface::universe::CeilingSource::Default
659        } else {
660            crate::agent_surface::universe::CeilingSource::Flag
661        },
662        kind: crate::agent_surface::universe::CeilingKind::Pagination,
663        universe_total: usize::try_from(total_count).ok(),
664    });
665
666    output::emit_json(&GraphEntitiesResponse {
667        entities: items,
668        total_count,
669        limit: args.limit,
670        offset: args.offset,
671        namespace: args.namespace,
672        elapsed_ms: started.elapsed().as_millis() as u64,
673    })
674}
675
676/// Reports the entity-type vocabulary actually present in the database.
677///
678/// v1.2.8 opened the vocabulary, which removed the one place the set of valid
679/// labels used to be written down. `graph entities --entity-type` can only
680/// filter by a label the caller already knows, so without this command an
681/// unknown label is unreachable: you cannot filter for what you cannot name.
682/// GROUP BY answers it from the data instead of from a constant.
683pub(crate) fn run_entity_types(args: GraphEntityTypesArgs) -> Result<(), AppError> {
684    let started = Instant::now();
685    let paths = AppPaths::resolve(args.db.as_deref())?;
686
687    crate::storage::connection::ensure_db_ready(&paths)?;
688
689    let conn = open_ro(&paths.db)?;
690
691    // One bound parameter serves both scopes: NULL means every namespace, so
692    // the SQL text is fixed and no branch interpolates a caller value.
693    let mut stmt = conn.prepare(
694        "SELECT COALESCE(type, ''), COUNT(*) AS count
695         FROM entities
696         WHERE (?1 IS NULL OR namespace = ?1)
697         GROUP BY type
698         ORDER BY count DESC, type ASC",
699    )?;
700    let types = stmt
701        .query_map(rusqlite::params![args.namespace.as_deref()], |r| {
702            let entity_type: String = r.get(0)?;
703            let count: i64 = r.get(1)?;
704            Ok(EntityTypeCount {
705                canonical: crate::entity_type::is_canonical_entity_type(&entity_type),
706                entity_type,
707                count,
708            })
709        })?
710        .collect::<rusqlite::Result<Vec<_>>>()?;
711
712    let total_types = types.len();
713    let total_entities = types.iter().map(|t| t.count).sum();
714
715    let response = GraphEntityTypesResponse {
716        types,
717        total_types,
718        total_entities,
719        namespace: args.namespace,
720        elapsed_ms: started.elapsed().as_millis() as u64,
721    };
722
723    match args.format {
724        GraphEntityTypesFormat::Json => output::emit_json(&response),
725        GraphEntityTypesFormat::Text => {
726            let lines: Vec<String> = response
727                .types
728                .iter()
729                .map(|t| {
730                    let mark = if t.canonical { "canonical" } else { "custom" };
731                    format!("{:>8}  {}  [{}]", t.count, t.entity_type, mark)
732                })
733                .collect();
734            output::emit_text(&format!(
735                "{}\n{} types, {} entities",
736                lines.join("\n"),
737                response.total_types,
738                response.total_entities
739            ));
740            Ok(())
741        }
742    }
743}