Skip to main content

haystack_server/ops/
graph.rs

1//! Graph visualization endpoints for React Flow UI integration.
2//!
3//! These endpoints expose the entity graph structure (nodes and edges) in
4//! formats optimized for graph visualization libraries like React Flow.
5//!
6//! # Endpoints
7//!
8//! | Route                     | Method | Description                              |
9//! |---------------------------|--------|------------------------------------------|
10//! | `/api/graph/flow`         | POST   | Full graph as nodes + edges for React Flow |
11//! | `/api/graph/edges`        | POST   | All ref relationships as explicit edges  |
12//! | `/api/graph/tree`         | POST   | Recursive subtree from a root entity     |
13//! | `/api/graph/neighbors`    | POST   | N-hop neighborhood around an entity      |
14//! | `/api/graph/path`         | POST   | Shortest path between two entities       |
15//! | `/api/graph/stats`        | GET    | Graph metrics and statistics             |
16//!
17//! # React Flow Data Model
18//!
19//! React Flow expects two arrays: `nodes` and `edges`.
20//!
21//! - **Node**: `{ id, type, data, position: { x, y }, parentId? }`
22//! - **Edge**: `{ id, source, target, label, type }`
23//!
24//! The `graph/flow` endpoint returns two grids: a nodes grid and an edges
25//! grid (edges encoded in the response grid's metadata under `edgesGrid`).
26//! When `Accept: application/json` is used, it returns the native React Flow
27//! JSON structure instead.
28
29use actix_web::{HttpRequest, HttpResponse, web};
30use std::collections::{HashMap, HashSet};
31
32use haystack_core::data::{HCol, HDict, HGrid};
33use haystack_core::kinds::{HRef, Kind, Number};
34
35use crate::content;
36use crate::error::HaystackError;
37use crate::state::AppState;
38
39// ── POST /api/graph/flow ──
40
41/// Returns the entity graph formatted for React Flow consumption.
42///
43/// # Request Grid Columns
44///
45/// | Column  | Kind   | Description                                   |
46/// |---------|--------|-----------------------------------------------|
47/// | `filter`| Str    | *(optional)* Filter expression to scope nodes |
48/// | `root`  | Ref    | *(optional)* Root entity for scoped subgraph  |
49/// | `depth` | Number | *(optional)* Max depth from root (default 10) |
50///
51/// # Response
52///
53/// Returns a nodes grid with columns: `nodeId`, `nodeType`, `dis`,
54/// `posX`, `posY`, `parentId`, plus all entity tags. The grid metadata
55/// contains an `edges` tag with a nested grid of edges.
56pub async fn handle_flow(
57    req: HttpRequest,
58    body: String,
59    state: web::Data<AppState>,
60) -> Result<HttpResponse, HaystackError> {
61    let content_type = req
62        .headers()
63        .get("Content-Type")
64        .and_then(|v| v.to_str().ok())
65        .unwrap_or("");
66    let accept = req
67        .headers()
68        .get("Accept")
69        .and_then(|v| v.to_str().ok())
70        .unwrap_or("");
71
72    // Parse optional request params
73    let (filter, root, depth) = if body.trim().is_empty() {
74        (None, None, 10usize)
75    } else {
76        let rg = content::decode_request_grid(&body, content_type)
77            .map_err(|e| HaystackError::bad_request(format!("failed to decode request: {e}")))?;
78        let row = rg.row(0);
79        let filter = row.and_then(|r| match r.get("filter") {
80            Some(Kind::Str(s)) if !s.is_empty() => Some(s.clone()),
81            _ => None,
82        });
83        let root = row.and_then(|r| match r.get("root") {
84            Some(Kind::Ref(r)) => Some(r.val.clone()),
85            _ => None,
86        });
87        let depth = row
88            .and_then(|r| match r.get("depth") {
89                Some(Kind::Number(n)) => Some(n.val as usize),
90                _ => None,
91            })
92            .unwrap_or(10);
93        (filter, root, depth)
94    };
95
96    // Collect entities
97    let entities: Vec<HDict> = match (&root, &filter) {
98        (Some(root_id), _) => state
99            .graph
100            .subtree(root_id, depth)
101            .into_iter()
102            .map(|(e, _)| e)
103            .collect(),
104        (None, Some(f)) => {
105            let f = if f == "*" {
106                return build_flow_all(&state, accept);
107            } else {
108                f
109            };
110            state
111                .graph
112                .read_all(f, 0)
113                .map_err(|e| HaystackError::bad_request(format!("filter error: {e}")))?
114        }
115        (None, None) => state.graph.all_entities(),
116    };
117
118    // Collect all edges between the selected entities
119    let entity_ids: HashSet<String> = entities
120        .iter()
121        .filter_map(|e| e.id().map(|r| r.val.clone()))
122        .collect();
123
124    let all_edges = state.graph.all_edges();
125    let edges: Vec<(String, String, String)> = all_edges
126        .into_iter()
127        .filter(|(src, _, tgt)| entity_ids.contains(src) && entity_ids.contains(tgt))
128        .collect();
129
130    build_flow_response(&entities, &edges, accept)
131}
132
133/// Build flow response for all entities (wildcard).
134fn build_flow_all(state: &AppState, accept: &str) -> Result<HttpResponse, HaystackError> {
135    let entities = state.graph.all_entities();
136    let edges = state.graph.all_edges();
137    build_flow_response(&entities, &edges, accept)
138}
139
140/// Build the flow response (nodes grid + edges grid).
141fn build_flow_response(
142    entities: &[HDict],
143    edges: &[(String, String, String)],
144    accept: &str,
145) -> Result<HttpResponse, HaystackError> {
146    if entities.is_empty() {
147        let (encoded, ct) = content::encode_response_grid(&HGrid::new(), accept)
148            .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
149        return Ok(HttpResponse::Ok().content_type(ct).body(encoded));
150    }
151
152    // Classify entity types for layered layout
153    let mut type_depths: HashMap<String, usize> = HashMap::new();
154    for entity in entities {
155        let id = entity.id().map(|r| r.val.clone()).unwrap_or_default();
156        let depth = entity_depth(entity);
157        type_depths.insert(id, depth);
158    }
159
160    // Group by depth for x-positioning
161    let mut depth_counts: HashMap<usize, usize> = HashMap::new();
162
163    // Build nodes grid
164    let mut node_rows: Vec<HDict> = Vec::with_capacity(entities.len());
165    let mut all_tags: HashSet<String> = HashSet::new();
166
167    for entity in entities {
168        let id = entity.id().map(|r| r.val.clone()).unwrap_or_default();
169        let depth = type_depths.get(&id).copied().unwrap_or(0);
170        let x_idx = depth_counts.entry(depth).or_insert(0);
171        let pos_x = (*x_idx as f64) * 280.0;
172        let pos_y = (depth as f64) * 200.0;
173        *x_idx += 1;
174
175        let mut row = entity.clone();
176        row.set("nodeId", Kind::Str(id.clone()));
177        row.set("nodeType", Kind::Str(entity_type_name(entity)));
178        row.set("posX", Kind::Number(Number::unitless(pos_x)));
179        row.set("posY", Kind::Number(Number::unitless(pos_y)));
180
181        // Set parentId from hierarchy refs
182        if let Some(parent) = find_parent_ref(entity) {
183            row.set("parentId", Kind::Str(parent));
184        }
185
186        for name in row.tag_names() {
187            all_tags.insert(name.to_string());
188        }
189        node_rows.push(row);
190    }
191
192    // Build edges grid
193    let edge_rows: Vec<HDict> = edges
194        .iter()
195        .map(|(src, tag, tgt)| {
196            let mut row = HDict::new();
197            row.set("edgeId", Kind::Str(format!("{src}:{tag}:{tgt}")));
198            row.set("source", Kind::Str(src.clone()));
199            row.set("target", Kind::Str(tgt.clone()));
200            row.set("label", Kind::Str(tag.clone()));
201            row
202        })
203        .collect();
204
205    let edge_cols = vec![
206        HCol::new("edgeId"),
207        HCol::new("source"),
208        HCol::new("target"),
209        HCol::new("label"),
210    ];
211    let edges_grid = HGrid::from_parts(HDict::new(), edge_cols, edge_rows);
212
213    // Ensure standard columns are first, then sorted remaining
214    let mut sorted_tags: Vec<String> = all_tags.into_iter().collect();
215    sorted_tags.sort();
216    let cols: Vec<HCol> = sorted_tags.iter().map(|n| HCol::new(n.as_str())).collect();
217
218    // Encode edges grid as Zinc and put in metadata
219    let mut meta = HDict::new();
220    let edges_zinc = haystack_core::codecs::codec_for("text/zinc")
221        .map(|c| c.encode_grid(&edges_grid).unwrap_or_default())
222        .unwrap_or_default();
223    meta.set("edges", Kind::Str(edges_zinc));
224
225    let nodes_grid = HGrid::from_parts(meta, cols, node_rows);
226
227    let (encoded, ct) = content::encode_response_grid(&nodes_grid, accept)
228        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
229
230    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
231}
232
233// ── POST /api/graph/edges ──
234
235/// Returns all ref relationships as explicit edge rows.
236///
237/// # Request Grid Columns
238///
239/// | Column    | Kind | Description                                    |
240/// |-----------|------|------------------------------------------------|
241/// | `filter`  | Str  | *(optional)* Filter to scope source entities   |
242/// | `refType` | Str  | *(optional)* Only edges of this ref tag type   |
243pub async fn handle_edges(
244    req: HttpRequest,
245    body: String,
246    state: web::Data<AppState>,
247) -> Result<HttpResponse, HaystackError> {
248    let content_type = req
249        .headers()
250        .get("Content-Type")
251        .and_then(|v| v.to_str().ok())
252        .unwrap_or("");
253    let accept = req
254        .headers()
255        .get("Accept")
256        .and_then(|v| v.to_str().ok())
257        .unwrap_or("");
258
259    let (filter, ref_type) = if body.trim().is_empty() {
260        (None, None)
261    } else {
262        let rg = content::decode_request_grid(&body, content_type)
263            .map_err(|e| HaystackError::bad_request(format!("failed to decode request: {e}")))?;
264        let row = rg.row(0);
265        let filter = row.and_then(|r| match r.get("filter") {
266            Some(Kind::Str(s)) if !s.is_empty() => Some(s.clone()),
267            _ => None,
268        });
269        let ref_type = row.and_then(|r| match r.get("refType") {
270            Some(Kind::Str(s)) if !s.is_empty() => Some(s.clone()),
271            _ => None,
272        });
273        (filter, ref_type)
274    };
275
276    // Get all edges from graph
277    let all_edges = state.graph.all_edges();
278
279    // Filter edges if needed
280    let entity_ids: Option<HashSet<String>> = filter.map(|f| {
281        if f == "*" {
282            return state
283                .graph
284                .all_entities()
285                .into_iter()
286                .filter_map(|e| e.id().map(|r| r.val.clone()))
287                .collect();
288        }
289        state
290            .graph
291            .read_all(&f, 0)
292            .unwrap_or_default()
293            .into_iter()
294            .filter_map(|e| e.id().map(|r| r.val.clone()))
295            .collect()
296    });
297
298    let edges: Vec<(String, String, String)> = all_edges
299        .into_iter()
300        .filter(|(src, tag, _)| {
301            if let Some(ref ids) = entity_ids
302                && !ids.contains(src)
303            {
304                return false;
305            }
306            if let Some(ref rt) = ref_type
307                && tag != rt
308            {
309                return false;
310            }
311            true
312        })
313        .collect();
314
315    if edges.is_empty() {
316        let (encoded, ct) = content::encode_response_grid(&HGrid::new(), accept)
317            .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
318        return Ok(HttpResponse::Ok().content_type(ct).body(encoded));
319    }
320
321    let cols = vec![
322        HCol::new("id"),
323        HCol::new("source"),
324        HCol::new("target"),
325        HCol::new("refTag"),
326    ];
327    let rows: Vec<HDict> = edges
328        .iter()
329        .map(|(src, tag, tgt)| {
330            let mut row = HDict::new();
331            row.set("id", Kind::Str(format!("{src}:{tag}:{tgt}")));
332            row.set("source", Kind::Ref(HRef::from_val(src)));
333            row.set("target", Kind::Ref(HRef::from_val(tgt)));
334            row.set("refTag", Kind::Str(tag.clone()));
335            row
336        })
337        .collect();
338
339    let grid = HGrid::from_parts(HDict::new(), cols, rows);
340    let (encoded, ct) = content::encode_response_grid(&grid, accept)
341        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
342
343    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
344}
345
346// ── POST /api/graph/tree ──
347
348/// Returns a hierarchical subtree from a root entity.
349///
350/// # Request Grid Columns
351///
352/// | Column     | Kind   | Description                                  |
353/// |------------|--------|----------------------------------------------|
354/// | `root`     | Ref    | Root entity to start tree from               |
355/// | `maxDepth` | Number | *(optional)* Maximum tree depth (default 10) |
356///
357/// # Response Grid Columns
358///
359/// All entity tags plus: `depth`, `parentId`, `navId`.
360pub async fn handle_tree(
361    req: HttpRequest,
362    body: String,
363    state: web::Data<AppState>,
364) -> Result<HttpResponse, HaystackError> {
365    let content_type = req
366        .headers()
367        .get("Content-Type")
368        .and_then(|v| v.to_str().ok())
369        .unwrap_or("");
370    let accept = req
371        .headers()
372        .get("Accept")
373        .and_then(|v| v.to_str().ok())
374        .unwrap_or("");
375
376    let rg = content::decode_request_grid(&body, content_type)
377        .map_err(|e| HaystackError::bad_request(format!("failed to decode request: {e}")))?;
378
379    let row = rg
380        .row(0)
381        .ok_or_else(|| HaystackError::bad_request("request grid has no rows"))?;
382
383    let root = match row.get("root") {
384        Some(Kind::Ref(r)) => r.val.clone(),
385        Some(Kind::Str(s)) => s.clone(),
386        _ => return Err(HaystackError::bad_request("'root' Ref is required")),
387    };
388
389    let max_depth = match row.get("maxDepth") {
390        Some(Kind::Number(n)) => n.val as usize,
391        _ => 10,
392    };
393
394    // Verify root exists
395    if !state.graph.contains(&root) {
396        return Err(HaystackError::not_found(format!(
397            "root entity not found: {root}"
398        )));
399    }
400
401    let subtree = state.graph.subtree(&root, max_depth);
402
403    if subtree.is_empty() {
404        let (encoded, ct) = content::encode_response_grid(&HGrid::new(), accept)
405            .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
406        return Ok(HttpResponse::Ok().content_type(ct).body(encoded));
407    }
408
409    // Build parent map from ref tags
410    let parent_map = build_parent_map(&subtree, &state);
411
412    let mut all_tags: HashSet<String> = HashSet::new();
413    let mut rows: Vec<HDict> = Vec::with_capacity(subtree.len());
414
415    for (entity, depth) in &subtree {
416        let mut row = entity.clone();
417        row.set("depth", Kind::Number(Number::unitless(*depth as f64)));
418
419        let id = entity.id().map(|r| r.val.clone()).unwrap_or_default();
420        if let Some(parent) = parent_map.get(&id) {
421            row.set("parentId", Kind::Ref(HRef::from_val(parent)));
422        }
423        row.set("navId", Kind::Str(id));
424
425        for name in row.tag_names() {
426            all_tags.insert(name.to_string());
427        }
428        rows.push(row);
429    }
430
431    let mut sorted_tags: Vec<String> = all_tags.into_iter().collect();
432    sorted_tags.sort();
433    let cols: Vec<HCol> = sorted_tags.iter().map(|n| HCol::new(n.as_str())).collect();
434    let grid = HGrid::from_parts(HDict::new(), cols, rows);
435
436    let (encoded, ct) = content::encode_response_grid(&grid, accept)
437        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
438
439    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
440}
441
442// ── POST /api/graph/neighbors ──
443
444/// Returns N-hop neighborhood around an entity.
445///
446/// # Request Grid Columns
447///
448/// | Column     | Kind   | Description                                    |
449/// |------------|--------|------------------------------------------------|
450/// | `id`       | Ref    | Center entity                                  |
451/// | `hops`     | Number | *(optional)* Traversal depth (default 1)       |
452/// | `refTypes` | Str    | *(optional)* Comma-separated ref types to follow |
453///
454/// # Response
455///
456/// Nodes grid with all entity tags. Edges encoded in grid metadata.
457pub async fn handle_neighbors(
458    req: HttpRequest,
459    body: String,
460    state: web::Data<AppState>,
461) -> Result<HttpResponse, HaystackError> {
462    let content_type = req
463        .headers()
464        .get("Content-Type")
465        .and_then(|v| v.to_str().ok())
466        .unwrap_or("");
467    let accept = req
468        .headers()
469        .get("Accept")
470        .and_then(|v| v.to_str().ok())
471        .unwrap_or("");
472
473    let rg = content::decode_request_grid(&body, content_type)
474        .map_err(|e| HaystackError::bad_request(format!("failed to decode request: {e}")))?;
475
476    let row = rg
477        .row(0)
478        .ok_or_else(|| HaystackError::bad_request("request grid has no rows"))?;
479
480    let id = match row.get("id") {
481        Some(Kind::Ref(r)) => r.val.clone(),
482        Some(Kind::Str(s)) => s.clone(),
483        _ => return Err(HaystackError::bad_request("'id' Ref is required")),
484    };
485
486    let hops = match row.get("hops") {
487        Some(Kind::Number(n)) => n.val as usize,
488        _ => 1,
489    };
490
491    let ref_types_str: Option<String> = match row.get("refTypes") {
492        Some(Kind::Str(s)) if !s.is_empty() => Some(s.clone()),
493        _ => None,
494    };
495
496    if !state.graph.contains(&id) {
497        return Err(HaystackError::not_found(format!("entity not found: {id}")));
498    }
499
500    let ref_types_vec: Option<Vec<String>> =
501        ref_types_str.map(|s| s.split(',').map(|t| t.trim().to_string()).collect());
502    let ref_types_refs: Option<Vec<&str>> = ref_types_vec
503        .as_ref()
504        .map(|v| v.iter().map(|s| s.as_str()).collect());
505
506    let (entities, edges) = state.graph.neighbors(&id, hops, ref_types_refs.as_deref());
507
508    build_flow_response(&entities, &edges, accept)
509}
510
511// ── POST /api/graph/path ──
512
513/// Finds the shortest path between two entities.
514///
515/// # Request Grid Columns
516///
517/// | Column | Kind | Description         |
518/// |--------|------|---------------------|
519/// | `from` | Ref  | Source entity       |
520/// | `to`   | Ref  | Destination entity  |
521///
522/// # Response Grid Columns
523///
524/// All entity tags plus `pathIndex` (Number, 0-based position in path).
525pub async fn handle_path(
526    req: HttpRequest,
527    body: String,
528    state: web::Data<AppState>,
529) -> Result<HttpResponse, HaystackError> {
530    let content_type = req
531        .headers()
532        .get("Content-Type")
533        .and_then(|v| v.to_str().ok())
534        .unwrap_or("");
535    let accept = req
536        .headers()
537        .get("Accept")
538        .and_then(|v| v.to_str().ok())
539        .unwrap_or("");
540
541    let rg = content::decode_request_grid(&body, content_type)
542        .map_err(|e| HaystackError::bad_request(format!("failed to decode request: {e}")))?;
543
544    let row = rg
545        .row(0)
546        .ok_or_else(|| HaystackError::bad_request("request grid has no rows"))?;
547
548    let from = match row.get("from") {
549        Some(Kind::Ref(r)) => r.val.clone(),
550        Some(Kind::Str(s)) => s.clone(),
551        _ => return Err(HaystackError::bad_request("'from' Ref is required")),
552    };
553
554    let to = match row.get("to") {
555        Some(Kind::Ref(r)) => r.val.clone(),
556        Some(Kind::Str(s)) => s.clone(),
557        _ => return Err(HaystackError::bad_request("'to' Ref is required")),
558    };
559
560    let path = state.graph.shortest_path(&from, &to);
561
562    if path.is_empty() {
563        let (encoded, ct) = content::encode_response_grid(&HGrid::new(), accept)
564            .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
565        return Ok(HttpResponse::Ok().content_type(ct).body(encoded));
566    }
567
568    let mut all_tags: HashSet<String> = HashSet::new();
569    let mut rows: Vec<HDict> = Vec::with_capacity(path.len());
570
571    for (idx, ref_val) in path.iter().enumerate() {
572        let mut row = state.graph.get(ref_val).unwrap_or_else(|| {
573            let mut stub = HDict::new();
574            stub.set("id", Kind::Ref(HRef::from_val(ref_val)));
575            stub
576        });
577        row.set("pathIndex", Kind::Number(Number::unitless(idx as f64)));
578        for name in row.tag_names() {
579            all_tags.insert(name.to_string());
580        }
581        rows.push(row);
582    }
583
584    let mut sorted_tags: Vec<String> = all_tags.into_iter().collect();
585    sorted_tags.sort();
586    let cols: Vec<HCol> = sorted_tags.iter().map(|n| HCol::new(n.as_str())).collect();
587    let grid = HGrid::from_parts(HDict::new(), cols, rows);
588
589    let (encoded, ct) = content::encode_response_grid(&grid, accept)
590        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
591
592    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
593}
594
595// ── GET /api/graph/stats ──
596
597/// Returns graph statistics and metrics.
598///
599/// # Response Grid Columns
600///
601/// | Column  | Kind   | Description              |
602/// |---------|--------|--------------------------|
603/// | `metric`| Str    | Metric name              |
604/// | `value` | Number | Metric value             |
605/// | `detail`| Str    | *(optional)* Breakdown   |
606pub async fn handle_stats(state: web::Data<AppState>) -> Result<HttpResponse, HaystackError> {
607    let entities = state.graph.all_entities();
608    let edges = state.graph.all_edges();
609
610    // Count entity types
611    let mut type_counts: HashMap<String, usize> = HashMap::new();
612    for entity in &entities {
613        let etype = entity_type_name(entity);
614        *type_counts.entry(etype).or_insert(0) += 1;
615    }
616
617    // Count ref types
618    let mut ref_counts: HashMap<String, usize> = HashMap::new();
619    for (_, tag, _) in &edges {
620        *ref_counts.entry(tag.clone()).or_insert(0) += 1;
621    }
622
623    // Connected components (union-find)
624    let component_count = count_components(&entities, &edges);
625
626    let cols = vec![HCol::new("metric"), HCol::new("value"), HCol::new("detail")];
627
628    let mut rows: Vec<HDict> = Vec::new();
629
630    // Total entities
631    let mut row = HDict::new();
632    row.set("metric", Kind::Str("totalEntities".into()));
633    row.set(
634        "value",
635        Kind::Number(Number::unitless(entities.len() as f64)),
636    );
637    rows.push(row);
638
639    // Total edges
640    let mut row = HDict::new();
641    row.set("metric", Kind::Str("totalEdges".into()));
642    row.set("value", Kind::Number(Number::unitless(edges.len() as f64)));
643    rows.push(row);
644
645    // Connected components
646    let mut row = HDict::new();
647    row.set("metric", Kind::Str("connectedComponents".into()));
648    row.set(
649        "value",
650        Kind::Number(Number::unitless(component_count as f64)),
651    );
652    rows.push(row);
653
654    // Entity type breakdown
655    let mut type_entries: Vec<_> = type_counts.into_iter().collect();
656    type_entries.sort_by(|a, b| b.1.cmp(&a.1));
657    for (etype, count) in type_entries {
658        let mut row = HDict::new();
659        row.set("metric", Kind::Str("entityType".into()));
660        row.set("value", Kind::Number(Number::unitless(count as f64)));
661        row.set("detail", Kind::Str(etype));
662        rows.push(row);
663    }
664
665    // Ref type breakdown
666    let mut ref_entries: Vec<_> = ref_counts.into_iter().collect();
667    ref_entries.sort_by(|a, b| b.1.cmp(&a.1));
668    for (rtype, count) in ref_entries {
669        let mut row = HDict::new();
670        row.set("metric", Kind::Str("refType".into()));
671        row.set("value", Kind::Number(Number::unitless(count as f64)));
672        row.set("detail", Kind::Str(rtype));
673        rows.push(row);
674    }
675
676    let grid = HGrid::from_parts(HDict::new(), cols, rows);
677
678    // Stats always returns JSON-compatible Zinc
679    let accept = "text/zinc";
680    let (encoded, ct) = content::encode_response_grid(&grid, accept)
681        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
682
683    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
684}
685
686// ── Helpers ──
687
688/// Determine the Haystack entity type for React Flow node typing.
689fn entity_type_name(entity: &HDict) -> String {
690    // Check common Haystack marker tags in priority order
691    for tag in &[
692        "site", "space", "floor", "wing", "equip", "point", "device", "conn", "weather",
693    ] {
694        if entity.has(tag) {
695            return (*tag).to_string();
696        }
697    }
698    "entity".to_string()
699}
700
701/// Determine layout depth based on entity type (for layered layout).
702fn entity_depth(entity: &HDict) -> usize {
703    if entity.has("site") {
704        0
705    } else if entity.has("space") || entity.has("floor") || entity.has("wing") {
706        1
707    } else if entity.has("equip") {
708        2
709    } else if entity.has("point") {
710        3
711    } else {
712        1 // Default middle layer
713    }
714}
715
716/// Find the primary parent ref for an entity (for React Flow parentId).
717fn find_parent_ref(entity: &HDict) -> Option<String> {
718    // Check hierarchy refs in priority order
719    for tag in &["equipRef", "spaceRef", "siteRef"] {
720        if let Some(Kind::Ref(r)) = entity.get(tag) {
721            return Some(r.val.clone());
722        }
723    }
724    None
725}
726
727/// Build a parent map: entity_id -> parent_id from ref tags in the subtree.
728fn build_parent_map(subtree: &[(HDict, usize)], state: &AppState) -> HashMap<String, String> {
729    let mut parent_map = HashMap::new();
730    let subtree_ids: HashSet<String> = subtree
731        .iter()
732        .filter_map(|(e, _)| e.id().map(|r| r.val.clone()))
733        .collect();
734
735    for (entity, _) in subtree {
736        let id = match entity.id() {
737            Some(r) => r.val.clone(),
738            None => continue,
739        };
740        if let Some(parent) = find_parent_ref(entity)
741            && subtree_ids.contains(&parent)
742        {
743            parent_map.insert(id, parent);
744        }
745    }
746    let _ = state; // state available if needed for extended lookup
747    parent_map
748}
749
750/// Count connected components using union-find.
751fn count_components(entities: &[HDict], edges: &[(String, String, String)]) -> usize {
752    if entities.is_empty() {
753        return 0;
754    }
755
756    let mut id_to_idx: HashMap<String, usize> = HashMap::new();
757    for (i, entity) in entities.iter().enumerate() {
758        if let Some(r) = entity.id() {
759            id_to_idx.insert(r.val.clone(), i);
760        }
761    }
762
763    let n = entities.len();
764    let mut parent: Vec<usize> = (0..n).collect();
765    let mut rank: Vec<usize> = vec![0; n];
766
767    fn find(parent: &mut [usize], x: usize) -> usize {
768        if parent[x] != x {
769            parent[x] = find(parent, parent[x]);
770        }
771        parent[x]
772    }
773
774    fn union(parent: &mut [usize], rank: &mut [usize], x: usize, y: usize) {
775        let rx = find(parent, x);
776        let ry = find(parent, y);
777        if rx != ry {
778            if rank[rx] < rank[ry] {
779                parent[rx] = ry;
780            } else if rank[rx] > rank[ry] {
781                parent[ry] = rx;
782            } else {
783                parent[ry] = rx;
784                rank[rx] += 1;
785            }
786        }
787    }
788
789    for (src, _, tgt) in edges {
790        if let (Some(&si), Some(&ti)) = (id_to_idx.get(src), id_to_idx.get(tgt)) {
791            union(&mut parent, &mut rank, si, ti);
792        }
793    }
794
795    let mut roots: HashSet<usize> = HashSet::new();
796    for i in 0..n {
797        roots.insert(find(&mut parent, i));
798    }
799    roots.len()
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    fn make_site(id: &str) -> HDict {
807        let mut d = HDict::new();
808        d.set("id", Kind::Ref(HRef::from_val(id)));
809        d.set("site", Kind::Marker);
810        d.set("dis", Kind::Str(format!("Site {id}")));
811        d
812    }
813
814    fn make_equip(id: &str, site_ref: &str) -> HDict {
815        let mut d = HDict::new();
816        d.set("id", Kind::Ref(HRef::from_val(id)));
817        d.set("equip", Kind::Marker);
818        d.set("siteRef", Kind::Ref(HRef::from_val(site_ref)));
819        d.set("dis", Kind::Str(format!("Equip {id}")));
820        d
821    }
822
823    fn make_point(id: &str, equip_ref: &str, site_ref: &str) -> HDict {
824        let mut d = HDict::new();
825        d.set("id", Kind::Ref(HRef::from_val(id)));
826        d.set("point", Kind::Marker);
827        d.set("equipRef", Kind::Ref(HRef::from_val(equip_ref)));
828        d.set("siteRef", Kind::Ref(HRef::from_val(site_ref)));
829        d.set("dis", Kind::Str(format!("Point {id}")));
830        d
831    }
832
833    #[test]
834    fn entity_type_detection() {
835        assert_eq!(entity_type_name(&make_site("s1")), "site");
836        assert_eq!(entity_type_name(&make_equip("e1", "s1")), "equip");
837        assert_eq!(entity_type_name(&make_point("p1", "e1", "s1")), "point");
838
839        let empty = HDict::new();
840        assert_eq!(entity_type_name(&empty), "entity");
841    }
842
843    #[test]
844    fn entity_depth_classification() {
845        assert_eq!(entity_depth(&make_site("s1")), 0);
846        assert_eq!(entity_depth(&make_equip("e1", "s1")), 2);
847        assert_eq!(entity_depth(&make_point("p1", "e1", "s1")), 3);
848    }
849
850    #[test]
851    fn parent_ref_detection() {
852        let equip = make_equip("e1", "s1");
853        assert_eq!(find_parent_ref(&equip), Some("s1".to_string()));
854
855        let point = make_point("p1", "e1", "s1");
856        // equipRef has priority over siteRef
857        assert_eq!(find_parent_ref(&point), Some("e1".to_string()));
858
859        let site = make_site("s1");
860        assert_eq!(find_parent_ref(&site), None);
861    }
862
863    #[test]
864    fn connected_components_single() {
865        let entities = vec![make_site("s1"), make_equip("e1", "s1")];
866        let edges = vec![("e1".into(), "siteRef".into(), "s1".into())];
867        assert_eq!(count_components(&entities, &edges), 1);
868    }
869
870    #[test]
871    fn connected_components_disjoint() {
872        let entities = vec![make_site("s1"), make_site("s2")];
873        let edges: Vec<(String, String, String)> = vec![];
874        assert_eq!(count_components(&entities, &edges), 2);
875    }
876
877    #[test]
878    fn connected_components_empty() {
879        let entities: Vec<HDict> = vec![];
880        let edges: Vec<(String, String, String)> = vec![];
881        assert_eq!(count_components(&entities, &edges), 0);
882    }
883}