Skip to main content

pgroles_core/
visual.rs

1//! Visualization model and renderers for `RoleGraph`.
2//!
3//! Converts a [`RoleGraph`] into a [`VisualGraph`] — a graph-oriented DTO
4//! suitable for JSON export, DOT/Mermaid rendering, and terminal tree output.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::fmt::Write as _;
8
9use serde::{Deserialize, Serialize};
10
11use crate::manifest::{ObjectType, Privilege};
12use crate::model::{DefaultPrivKey, GrantKey, RoleGraph};
13use crate::ownership::ManagedScope;
14
15pub const VISUAL_GRAPH_SCHEMA_VERSION: &str = "pgroles.visual_graph.v1";
16
17// ---------------------------------------------------------------------------
18// DTO types
19// ---------------------------------------------------------------------------
20
21/// Top-level visualization graph.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct VisualGraph {
24    pub schema_version: String,
25    pub meta: VisualMeta,
26    pub nodes: Vec<VisualNode>,
27    pub edges: Vec<VisualEdge>,
28}
29
30/// Metadata about the graph.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct VisualMeta {
33    pub source: VisualSource,
34    pub role_count: usize,
35    pub grant_count: usize,
36    pub default_privilege_count: usize,
37    pub membership_count: usize,
38    pub collapsed: bool,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub managed_scope: Option<VisualManagedScope>,
41}
42
43/// Optional managed-scope metadata for bundle-aware graph output.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct VisualManagedScope {
46    pub roles: Vec<String>,
47    pub schemas: Vec<VisualManagedSchema>,
48}
49
50/// A schema plus the managed facets owned within that schema.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct VisualManagedSchema {
53    pub name: String,
54    pub owner: bool,
55    pub bindings: bool,
56}
57
58impl From<&ManagedScope> for VisualManagedScope {
59    fn from(scope: &ManagedScope) -> Self {
60        Self {
61            roles: scope.roles.iter().cloned().collect(),
62            schemas: scope
63                .schemas
64                .iter()
65                .map(|(name, managed)| VisualManagedSchema {
66                    name: name.clone(),
67                    owner: managed.owner,
68                    bindings: managed.bindings,
69                })
70                .collect(),
71        }
72    }
73}
74
75/// Where the graph data came from.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum VisualSource {
79    Desired,
80    Current,
81}
82
83/// A node in the visual graph.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct VisualNode {
86    pub id: String,
87    pub label: String,
88    pub kind: NodeKind,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub managed: Option<bool>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub login: Option<bool>,
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub privileges: Vec<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub comment: Option<String>,
97}
98
99/// The kind of a visual node.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum NodeKind {
103    Role,
104    ExternalPrincipal,
105    GrantTarget,
106    DefaultPrivilegeTarget,
107}
108
109/// An edge in the visual graph.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct VisualEdge {
112    pub source: String,
113    pub target: String,
114    pub kind: EdgeKind,
115    pub label: String,
116}
117
118/// The kind of a visual edge.
119#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum EdgeKind {
122    Membership,
123    Grant,
124    DefaultPrivilege,
125}
126
127// ---------------------------------------------------------------------------
128// RoleGraph -> VisualGraph transformation
129// ---------------------------------------------------------------------------
130
131/// Build a [`VisualGraph`] from a [`RoleGraph`].
132///
133/// Grant targets are collapsed by schema and object type by default:
134/// individual object names become `schema.tables[*]` etc.
135pub fn build_visual_graph(graph: &RoleGraph, source: VisualSource) -> VisualGraph {
136    let mut nodes: Vec<VisualNode> = Vec::new();
137    let mut edges: Vec<VisualEdge> = Vec::new();
138    let mut node_ids: BTreeSet<String> = BTreeSet::new();
139
140    // Track which members are external (referenced in memberships but not in roles).
141    let managed_role_names: BTreeSet<&str> = graph.roles.keys().map(|name| name.as_str()).collect();
142
143    // --- Role nodes ---
144    for (name, state) in &graph.roles {
145        let node_id = format!("role:{name}");
146        nodes.push(VisualNode {
147            id: node_id.clone(),
148            label: name.clone(),
149            kind: NodeKind::Role,
150            managed: Some(true),
151            login: Some(state.login),
152            privileges: Vec::new(),
153            comment: state.comment.clone(),
154        });
155        node_ids.insert(node_id);
156    }
157
158    // --- External principal nodes (from memberships) ---
159    for edge in &graph.memberships {
160        if !managed_role_names.contains(edge.member.as_str()) {
161            let node_id = format!("external:{}", edge.member);
162            if node_ids.insert(node_id.clone()) {
163                nodes.push(VisualNode {
164                    id: node_id,
165                    label: edge.member.clone(),
166                    kind: NodeKind::ExternalPrincipal,
167                    managed: Some(false),
168                    login: None,
169                    privileges: Vec::new(),
170                    comment: None,
171                });
172            }
173        }
174    }
175
176    // --- Membership edges ---
177    for edge in &graph.memberships {
178        let source_id = if managed_role_names.contains(edge.member.as_str()) {
179            format!("role:{}", edge.member)
180        } else {
181            format!("external:{}", edge.member)
182        };
183        let target_id = format!("role:{}", edge.role);
184
185        let label = membership_label(edge.inherit, edge.admin);
186        edges.push(VisualEdge {
187            source: source_id,
188            target: target_id,
189            kind: EdgeKind::Membership,
190            label,
191        });
192    }
193
194    // --- Collapsed grant target nodes and edges ---
195    // Group grants by (role, object_type, schema_or_db) to collapse.
196    let collapsed_grants = collapse_grants(&graph.grants);
197    for (collapsed_key, privileges) in &collapsed_grants {
198        let node_id = collapsed_key.node_id();
199        if node_ids.insert(node_id.clone()) {
200            nodes.push(VisualNode {
201                id: node_id.clone(),
202                label: collapsed_key.label(),
203                kind: NodeKind::GrantTarget,
204                managed: None,
205                login: None,
206                privileges: privileges.iter().map(|p| p.to_string()).collect(),
207                comment: None,
208            });
209        } else {
210            // Node already exists from another role — merge privileges.
211            if let Some(existing) = nodes.iter_mut().find(|n| n.id == node_id) {
212                for priv_str in privileges.iter().map(|p| p.to_string()) {
213                    if !existing.privileges.contains(&priv_str) {
214                        existing.privileges.push(priv_str);
215                    }
216                }
217                existing.privileges.sort();
218            }
219        }
220
221        let privilege_label = privileges
222            .iter()
223            .map(|p| p.to_string())
224            .collect::<Vec<_>>()
225            .join(",");
226        edges.push(VisualEdge {
227            source: format!("role:{}", collapsed_key.role),
228            target: node_id,
229            kind: EdgeKind::Grant,
230            label: privilege_label,
231        });
232    }
233
234    // --- Default privilege nodes and edges ---
235    for (key, state) in &graph.default_privileges {
236        let node_id = default_priv_node_id(key);
237        let node_label = format!("defaults: {} -> {}.{}s", key.owner, key.schema, key.on_type);
238
239        if node_ids.insert(node_id.clone()) {
240            nodes.push(VisualNode {
241                id: node_id.clone(),
242                label: node_label,
243                kind: NodeKind::DefaultPrivilegeTarget,
244                managed: None,
245                login: None,
246                privileges: state.privileges.iter().map(|p| p.to_string()).collect(),
247                comment: None,
248            });
249        }
250
251        let privilege_label = state
252            .privileges
253            .iter()
254            .map(|p| p.to_string())
255            .collect::<Vec<_>>()
256            .join(",");
257        edges.push(VisualEdge {
258            source: node_id,
259            target: format!("role:{}", key.grantee),
260            kind: EdgeKind::DefaultPrivilege,
261            label: privilege_label,
262        });
263    }
264
265    // Sort nodes and edges for deterministic output.
266    nodes.sort_by(|a, b| a.id.cmp(&b.id));
267    edges.sort_by(|a, b| (&a.source, &a.target, &a.kind).cmp(&(&b.source, &b.target, &b.kind)));
268
269    VisualGraph {
270        schema_version: VISUAL_GRAPH_SCHEMA_VERSION.to_string(),
271        meta: VisualMeta {
272            source,
273            role_count: graph.roles.len(),
274            grant_count: graph.grants.len(),
275            default_privilege_count: graph.default_privileges.len(),
276            membership_count: graph.memberships.len(),
277            collapsed: true,
278            managed_scope: None,
279        },
280        nodes,
281        edges,
282    }
283}
284
285// ---------------------------------------------------------------------------
286// Grant collapsing
287// ---------------------------------------------------------------------------
288
289/// Key for a collapsed grant group: (role, object_type, schema_or_db).
290#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
291struct CollapsedGrantKey {
292    role: String,
293    object_type: ObjectType,
294    /// Schema name for schema-scoped grants, or the database/object name for
295    /// schema-level and database-level grants.
296    scope: String,
297}
298
299impl CollapsedGrantKey {
300    fn node_id(&self) -> String {
301        match self.object_type {
302            ObjectType::Schema => format!("grant:schema:{}:{}", self.scope, self.scope),
303            ObjectType::Database => format!("grant:database:{}:{}", self.scope, self.scope),
304            _ => format!("grant:{}:{}:*", self.object_type, self.scope),
305        }
306    }
307
308    fn label(&self) -> String {
309        match self.object_type {
310            ObjectType::Schema => format!("{}.schema", self.scope),
311            ObjectType::Database => format!("{}.database", self.scope),
312            _ => format!("{}.{}s[*]", self.scope, self.object_type),
313        }
314    }
315}
316
317fn collapse_grants(
318    grants: &BTreeMap<GrantKey, crate::model::GrantState>,
319) -> BTreeMap<CollapsedGrantKey, BTreeSet<Privilege>> {
320    let mut collapsed: BTreeMap<CollapsedGrantKey, BTreeSet<Privilege>> = BTreeMap::new();
321
322    for (key, state) in grants {
323        let scope = match key.object_type {
324            // Schema-level grants: the schema name is in key.name (from manifest expansion).
325            ObjectType::Schema => key
326                .name
327                .as_deref()
328                .or(key.schema.as_deref())
329                .unwrap_or("public")
330                .to_string(),
331            // Database-level grants: the database name is in key.name.
332            ObjectType::Database => key.name.as_deref().unwrap_or("db").to_string(),
333            // Object-level grants: use the schema.
334            _ => key.schema.as_deref().unwrap_or("public").to_string(),
335        };
336
337        let collapsed_key = CollapsedGrantKey {
338            role: key.role.clone(),
339            object_type: key.object_type,
340            scope,
341        };
342
343        collapsed
344            .entry(collapsed_key)
345            .or_default()
346            .extend(&state.privileges);
347    }
348
349    collapsed
350}
351
352fn default_priv_node_id(key: &DefaultPrivKey) -> String {
353    format!(
354        "default:{}:{}:{}:{}",
355        key.owner, key.schema, key.on_type, key.grantee
356    )
357}
358
359fn membership_label(inherit: bool, admin: bool) -> String {
360    let mut parts = vec!["member"];
361    if !inherit {
362        parts.push("NOINHERIT");
363    }
364    if admin {
365        parts.push("ADMIN");
366    }
367    parts.join(", ")
368}
369
370// ---------------------------------------------------------------------------
371// Renderers
372// ---------------------------------------------------------------------------
373
374/// Render the graph as pretty-printed JSON.
375pub fn render_json(graph: &VisualGraph) -> String {
376    serde_json::to_string_pretty(graph).expect("VisualGraph serialization should not fail")
377}
378
379/// Render the graph as Graphviz DOT.
380pub fn render_dot(graph: &VisualGraph) -> String {
381    let mut out = String::new();
382    writeln!(out, "digraph roles {{").unwrap();
383    writeln!(out, "  rankdir=LR;").unwrap();
384    writeln!(out, "  node [fontname=\"sans-serif\" fontsize=10];").unwrap();
385    writeln!(out, "  edge [fontname=\"sans-serif\" fontsize=9];").unwrap();
386    writeln!(out).unwrap();
387
388    for node in &graph.nodes {
389        let dot_id = dot_escape_id(&node.id);
390        let label = dot_escape_label(&node.label);
391        let shape = match node.kind {
392            NodeKind::Role => {
393                if node.login == Some(true) {
394                    "box"
395                } else {
396                    "ellipse"
397                }
398            }
399            NodeKind::ExternalPrincipal => "hexagon",
400            NodeKind::GrantTarget => "note",
401            NodeKind::DefaultPrivilegeTarget => "component",
402        };
403        let style = match node.kind {
404            NodeKind::Role => "filled",
405            NodeKind::ExternalPrincipal => "dashed,filled",
406            NodeKind::GrantTarget => "filled",
407            NodeKind::DefaultPrivilegeTarget => "filled",
408        };
409        let fillcolor = match node.kind {
410            NodeKind::Role => {
411                if node.login == Some(true) {
412                    "#e0f2fe" // light blue for login roles
413                } else {
414                    "#f0fdf4" // light green for group roles
415                }
416            }
417            NodeKind::ExternalPrincipal => "#fef3c7", // light amber
418            NodeKind::GrantTarget => "#f5f5f4",       // stone-100
419            NodeKind::DefaultPrivilegeTarget => "#f0fdfa", // teal-50
420        };
421        writeln!(
422            out,
423            "  {dot_id} [label=\"{label}\" shape={shape} style=\"{style}\" fillcolor=\"{fillcolor}\"];",
424        )
425        .unwrap();
426    }
427
428    writeln!(out).unwrap();
429
430    for edge in &graph.edges {
431        let source = dot_escape_id(&edge.source);
432        let target = dot_escape_id(&edge.target);
433        let label = dot_escape_label(&edge.label);
434        let style = match edge.kind {
435            EdgeKind::Membership => "solid",
436            EdgeKind::Grant => "solid",
437            EdgeKind::DefaultPrivilege => "dashed",
438        };
439        let color = match edge.kind {
440            EdgeKind::Membership => "#1e3a5f",
441            EdgeKind::Grant => "#374151",
442            EdgeKind::DefaultPrivilege => "#0d9488",
443        };
444        writeln!(
445            out,
446            "  {source} -> {target} [label=\"{label}\" style={style} color=\"{color}\" fontcolor=\"{color}\"];",
447        )
448        .unwrap();
449    }
450
451    writeln!(out, "}}").unwrap();
452    out
453}
454
455/// Render the graph as Mermaid flowchart syntax.
456pub fn render_mermaid(graph: &VisualGraph) -> String {
457    let mut out = String::new();
458    writeln!(out, "graph LR").unwrap();
459
460    for node in &graph.nodes {
461        let mermaid_id = mermaid_escape_id(&node.id);
462        let label = mermaid_escape_label(&node.label);
463        let shape = match node.kind {
464            NodeKind::Role => {
465                if node.login == Some(true) {
466                    format!("[{label}]")
467                } else {
468                    format!("([{label}])")
469                }
470            }
471            NodeKind::ExternalPrincipal => format!("{{{{{label}}}}}"),
472            NodeKind::GrantTarget => format!("[/{label}/]"),
473            NodeKind::DefaultPrivilegeTarget => format!("[\\{label}\\]"),
474        };
475        writeln!(out, "  {mermaid_id}{shape}").unwrap();
476    }
477
478    for edge in &graph.edges {
479        let source = mermaid_escape_id(&edge.source);
480        let target = mermaid_escape_id(&edge.target);
481        let label = mermaid_escape_label(&edge.label);
482        let arrow = match edge.kind {
483            EdgeKind::Membership => "-->",
484            EdgeKind::Grant => "-->",
485            EdgeKind::DefaultPrivilege => "-.->",
486        };
487        if label.is_empty() {
488            writeln!(out, "  {source} {arrow} {target}").unwrap();
489        } else {
490            writeln!(out, "  {source} {arrow}|{label}| {target}").unwrap();
491        }
492    }
493
494    out
495}
496
497/// Render the graph as an indented text tree for terminal display.
498pub fn render_tree(graph: &VisualGraph) -> String {
499    let mut out = String::new();
500
501    // Build lookup structures for edges by role.
502    let mut membership_edges: BTreeMap<&str, Vec<&VisualEdge>> = BTreeMap::new();
503    let mut grant_edges: BTreeMap<&str, Vec<&VisualEdge>> = BTreeMap::new();
504    let mut default_priv_edges: BTreeMap<&str, Vec<&VisualEdge>> = BTreeMap::new();
505    let node_map: BTreeMap<&str, &VisualNode> =
506        graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
507
508    for edge in &graph.edges {
509        match edge.kind {
510            EdgeKind::Membership => {
511                membership_edges
512                    .entry(edge.target.as_str())
513                    .or_default()
514                    .push(edge);
515            }
516            EdgeKind::Grant => {
517                grant_edges
518                    .entry(edge.source.as_str())
519                    .or_default()
520                    .push(edge);
521            }
522            EdgeKind::DefaultPrivilege => {
523                default_priv_edges
524                    .entry(edge.target.as_str())
525                    .or_default()
526                    .push(edge);
527            }
528        }
529    }
530
531    // Collect role nodes in order.
532    let role_nodes: Vec<&VisualNode> = graph
533        .nodes
534        .iter()
535        .filter(|n| n.kind == NodeKind::Role)
536        .collect();
537
538    for (role_idx, role_node) in role_nodes.iter().enumerate() {
539        let is_last_role = role_idx == role_nodes.len() - 1;
540        let role_connector = if is_last_role { "\u{2514}" } else { "\u{251c}" };
541        let role_tag = if role_node.login == Some(true) {
542            " [LOGIN]"
543        } else {
544            ""
545        };
546        writeln!(
547            out,
548            "{role_connector}\u{2500}\u{2500} {}{role_tag}",
549            role_node.label
550        )
551        .unwrap();
552
553        let child_prefix = if is_last_role { "    " } else { "\u{2502}   " };
554
555        // Count how many sections this role has for connector logic.
556        let members = membership_edges.get(role_node.id.as_str());
557        let grants = grant_edges.get(role_node.id.as_str());
558        let default_privs = default_priv_edges.get(role_node.id.as_str());
559
560        let section_count = members.is_some() as usize
561            + grants.is_some() as usize
562            + default_privs.is_some() as usize;
563        let mut section_idx = 0;
564
565        if let Some(member_list) = members {
566            let is_last_section = section_idx == section_count - 1;
567            render_tree_section(
568                &mut out,
569                child_prefix,
570                is_last_section,
571                "Members",
572                member_list,
573                &node_map,
574                |edge, node_map| {
575                    let label = node_map
576                        .get(edge.source.as_str())
577                        .map(|n| n.label.as_str())
578                        .unwrap_or(&edge.source);
579                    let flags = if edge.label != "member" {
580                        format!(" ({0})", edge.label)
581                    } else {
582                        String::new()
583                    };
584                    format!("{label}{flags}")
585                },
586            );
587            section_idx += 1;
588        }
589
590        if let Some(grant_list) = grants {
591            let is_last_section = section_idx == section_count - 1;
592            render_tree_section(
593                &mut out,
594                child_prefix,
595                is_last_section,
596                "Grants",
597                grant_list,
598                &node_map,
599                |edge, node_map| {
600                    let label = node_map
601                        .get(edge.target.as_str())
602                        .map(|n| n.label.as_str())
603                        .unwrap_or(&edge.target);
604                    format!("{label}: {}", edge.label)
605                },
606            );
607            section_idx += 1;
608        }
609
610        if let Some(dp_list) = default_privs {
611            let is_last_section = section_idx == section_count - 1;
612            render_tree_section(
613                &mut out,
614                child_prefix,
615                is_last_section,
616                "Default Privileges",
617                dp_list,
618                &node_map,
619                |edge, node_map| {
620                    let label = node_map
621                        .get(edge.source.as_str())
622                        .map(|n| n.label.as_str())
623                        .unwrap_or(&edge.source);
624                    format!("{label}: {}", edge.label)
625                },
626            );
627            let _ = section_idx;
628        }
629    }
630
631    // Show external principals.
632    let external_nodes: Vec<&VisualNode> = graph
633        .nodes
634        .iter()
635        .filter(|n| n.kind == NodeKind::ExternalPrincipal)
636        .collect();
637
638    if !external_nodes.is_empty() {
639        writeln!(out).unwrap();
640        writeln!(out, "External principals:").unwrap();
641        for (idx, node) in external_nodes.iter().enumerate() {
642            let is_last = idx == external_nodes.len() - 1;
643            let connector = if is_last { "\u{2514}" } else { "\u{251c}" };
644            writeln!(out, "{connector}\u{2500}\u{2500} {}", node.label).unwrap();
645        }
646    }
647
648    out
649}
650
651/// Render a single section (Members, Grants, Default Privileges) under a role
652/// in the tree output.
653fn render_tree_section(
654    out: &mut String,
655    child_prefix: &str,
656    is_last_section: bool,
657    section_name: &str,
658    edges: &[&VisualEdge],
659    node_map: &BTreeMap<&str, &VisualNode>,
660    format_item: impl Fn(&VisualEdge, &BTreeMap<&str, &VisualNode>) -> String,
661) {
662    let section_connector = if is_last_section {
663        "\u{2514}"
664    } else {
665        "\u{251c}"
666    };
667    let item_prefix = if is_last_section {
668        format!("{child_prefix}    ")
669    } else {
670        format!("{child_prefix}\u{2502}   ")
671    };
672    writeln!(
673        out,
674        "{child_prefix}{section_connector}\u{2500}\u{2500} {section_name}"
675    )
676    .unwrap();
677    for (idx, edge) in edges.iter().enumerate() {
678        let is_last = idx == edges.len() - 1;
679        let connector = if is_last { "\u{2514}" } else { "\u{251c}" };
680        let item_text = format_item(edge, node_map);
681        writeln!(out, "{item_prefix}{connector}\u{2500}\u{2500} {item_text}").unwrap();
682    }
683}
684
685// ---------------------------------------------------------------------------
686// Escape helpers
687// ---------------------------------------------------------------------------
688
689fn dot_escape_id(id: &str) -> String {
690    format!("\"{}\"", id.replace('\\', "\\\\").replace('"', "\\\""))
691}
692
693fn dot_escape_label(label: &str) -> String {
694    label
695        .replace('\\', "\\\\")
696        .replace('"', "\\\"")
697        .replace('\n', "\\n")
698}
699
700fn mermaid_escape_id(id: &str) -> String {
701    // Mermaid IDs must be alphanumeric + hyphens + underscores.
702    // Use distinct substitutions to avoid collisions between IDs that
703    // differ only by punctuation (e.g. "alice@x.com" vs "alice_x.com").
704    let mut out = String::with_capacity(id.len());
705    for ch in id.chars() {
706        match ch {
707            ':' => out.push_str("__"),
708            '.' => out.push_str("_d_"),
709            '@' => out.push_str("_at_"),
710            '*' => out.push_str("_star_"),
711            ' ' => out.push_str("_sp_"),
712            '/' => out.push_str("_sl_"),
713            '\\' => out.push_str("_bs_"),
714            c if c.is_alphanumeric() || c == '-' || c == '_' => out.push(c),
715            _ => {
716                out.push_str(&format!("_x{:02x}_", ch as u32));
717            }
718        }
719    }
720    out
721}
722
723fn mermaid_escape_label(label: &str) -> String {
724    // Mermaid labels: escape quotes and brackets.
725    label.replace('"', "#quot;").replace(['[', ']'], "")
726}
727
728// ---------------------------------------------------------------------------
729// Tests
730// ---------------------------------------------------------------------------
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use crate::manifest::{expand_manifest, parse_manifest};
736    use crate::model::RoleGraph;
737    use crate::ownership::{ManagedSchemaScope, ManagedScope};
738
739    fn build_test_graph() -> RoleGraph {
740        let yaml = r#"
741default_owner: app_owner
742
743profiles:
744  editor:
745    grants:
746      - privileges: [USAGE]
747        object: { type: schema }
748      - privileges: [SELECT, INSERT, UPDATE, DELETE]
749        object: { type: table, name: "*" }
750    default_privileges:
751      - privileges: [SELECT, INSERT, UPDATE, DELETE]
752        on_type: table
753
754schemas:
755  - name: orders
756    profiles: [editor]
757
758roles:
759  - name: analytics
760    login: true
761    comment: "Read-only analytics"
762
763grants:
764  - role: analytics
765    privileges: [CONNECT]
766    object: { type: database, name: mydb }
767
768memberships:
769  - role: orders-editor
770    members:
771      - name: "team@example.com"
772      - name: analytics
773"#;
774        let manifest = parse_manifest(yaml).unwrap();
775        let expanded = expand_manifest(&manifest).unwrap();
776        RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap()
777    }
778
779    #[test]
780    fn visual_graph_has_correct_node_count() {
781        let graph = build_test_graph();
782        let visual = build_visual_graph(&graph, VisualSource::Desired);
783
784        // Roles: orders-editor, analytics
785        let role_nodes: Vec<_> = visual
786            .nodes
787            .iter()
788            .filter(|n| n.kind == NodeKind::Role)
789            .collect();
790        assert_eq!(role_nodes.len(), 2);
791
792        // External: team@example.com
793        let external_nodes: Vec<_> = visual
794            .nodes
795            .iter()
796            .filter(|n| n.kind == NodeKind::ExternalPrincipal)
797            .collect();
798        assert_eq!(external_nodes.len(), 1);
799        assert_eq!(external_nodes[0].label, "team@example.com");
800    }
801
802    #[test]
803    fn visual_graph_login_flag_correct() {
804        let graph = build_test_graph();
805        let visual = build_visual_graph(&graph, VisualSource::Desired);
806
807        let analytics = visual
808            .nodes
809            .iter()
810            .find(|n| n.label == "analytics")
811            .unwrap();
812        assert_eq!(analytics.login, Some(true));
813
814        let editor = visual
815            .nodes
816            .iter()
817            .find(|n| n.label == "orders-editor")
818            .unwrap();
819        assert_eq!(editor.login, Some(false));
820    }
821
822    #[test]
823    fn visual_graph_has_membership_edges() {
824        let graph = build_test_graph();
825        let visual = build_visual_graph(&graph, VisualSource::Desired);
826
827        let membership_edges: Vec<_> = visual
828            .edges
829            .iter()
830            .filter(|e| e.kind == EdgeKind::Membership)
831            .collect();
832        assert_eq!(membership_edges.len(), 2);
833    }
834
835    #[test]
836    fn visual_graph_collapses_grants() {
837        let graph = build_test_graph();
838        let visual = build_visual_graph(&graph, VisualSource::Desired);
839
840        let grant_targets: Vec<_> = visual
841            .nodes
842            .iter()
843            .filter(|n| n.kind == NodeKind::GrantTarget)
844            .collect();
845
846        // orders.schema, orders.tables[*], mydb.database
847        assert_eq!(grant_targets.len(), 3, "grant targets: {grant_targets:?}");
848    }
849
850    #[test]
851    fn visual_graph_has_default_privilege_nodes() {
852        let graph = build_test_graph();
853        let visual = build_visual_graph(&graph, VisualSource::Desired);
854
855        let dp_nodes: Vec<_> = visual
856            .nodes
857            .iter()
858            .filter(|n| n.kind == NodeKind::DefaultPrivilegeTarget)
859            .collect();
860        assert_eq!(dp_nodes.len(), 1);
861        assert!(dp_nodes[0].label.contains("app_owner"));
862        assert!(dp_nodes[0].label.contains("orders"));
863    }
864
865    #[test]
866    fn visual_graph_nodes_are_sorted() {
867        let graph = build_test_graph();
868        let visual = build_visual_graph(&graph, VisualSource::Desired);
869
870        let ids: Vec<&str> = visual.nodes.iter().map(|n| n.id.as_str()).collect();
871        let mut sorted_ids = ids.clone();
872        sorted_ids.sort();
873        assert_eq!(ids, sorted_ids, "nodes should be sorted by ID");
874    }
875
876    #[test]
877    fn json_roundtrips() {
878        let graph = build_test_graph();
879        let visual = build_visual_graph(&graph, VisualSource::Desired);
880        let json = render_json(&visual);
881        let deserialized: VisualGraph = serde_json::from_str(&json).unwrap();
882        assert_eq!(deserialized.nodes.len(), visual.nodes.len());
883        assert_eq!(deserialized.edges.len(), visual.edges.len());
884    }
885
886    #[test]
887    fn json_contract_includes_schema_version_and_managed_scope() {
888        let graph = build_test_graph();
889        let mut visual = build_visual_graph(&graph, VisualSource::Desired);
890        visual.meta.managed_scope = Some(VisualManagedScope::from(&ManagedScope {
891            roles: ["analytics".to_string()].into_iter().collect(),
892            schemas: [(
893                "orders".to_string(),
894                ManagedSchemaScope {
895                    owner: true,
896                    bindings: true,
897                },
898            )]
899            .into_iter()
900            .collect(),
901        }));
902
903        let parsed: serde_json::Value =
904            serde_json::from_str(&render_json(&visual)).expect("json should parse");
905
906        assert_eq!(parsed["schema_version"], VISUAL_GRAPH_SCHEMA_VERSION);
907        assert_eq!(parsed["meta"]["managed_scope"]["roles"][0], "analytics");
908        assert_eq!(
909            parsed["meta"]["managed_scope"]["schemas"][0]["name"],
910            "orders"
911        );
912        assert_eq!(parsed["meta"]["managed_scope"]["schemas"][0]["owner"], true);
913        assert_eq!(
914            parsed["meta"]["managed_scope"]["schemas"][0]["bindings"],
915            true
916        );
917    }
918
919    #[test]
920    fn dot_output_is_valid() {
921        let graph = build_test_graph();
922        let visual = build_visual_graph(&graph, VisualSource::Desired);
923        let dot = render_dot(&visual);
924
925        assert!(dot.starts_with("digraph roles {"));
926        assert!(dot.contains("orders-editor"));
927        assert!(dot.contains("analytics"));
928        assert!(dot.contains("team@example.com"));
929        assert!(dot.ends_with("}\n"));
930    }
931
932    #[test]
933    fn mermaid_output_is_valid() {
934        let graph = build_test_graph();
935        let visual = build_visual_graph(&graph, VisualSource::Desired);
936        let mermaid = render_mermaid(&visual);
937
938        assert!(mermaid.starts_with("graph LR\n"));
939        assert!(mermaid.contains("orders-editor"));
940        assert!(mermaid.contains("analytics"));
941    }
942
943    #[test]
944    fn tree_output_shows_roles() {
945        let graph = build_test_graph();
946        let visual = build_visual_graph(&graph, VisualSource::Desired);
947        let tree = render_tree(&visual);
948
949        assert!(
950            tree.contains("analytics"),
951            "tree should contain analytics role"
952        );
953        assert!(
954            tree.contains("orders-editor"),
955            "tree should contain orders-editor role"
956        );
957        assert!(tree.contains("[LOGIN]"), "tree should show LOGIN tag");
958        assert!(
959            tree.contains("team@example.com"),
960            "tree should show external member"
961        );
962    }
963
964    #[test]
965    fn membership_label_defaults_to_member() {
966        assert_eq!(membership_label(true, false), "member");
967    }
968
969    #[test]
970    fn membership_label_noinherit() {
971        assert_eq!(membership_label(false, false), "member, NOINHERIT");
972    }
973
974    #[test]
975    fn membership_label_admin() {
976        assert_eq!(membership_label(true, true), "member, ADMIN");
977    }
978
979    #[test]
980    fn membership_label_both_flags() {
981        assert_eq!(membership_label(false, true), "member, NOINHERIT, ADMIN");
982    }
983
984    #[test]
985    fn empty_graph_produces_empty_visual() {
986        let graph = RoleGraph::default();
987        let visual = build_visual_graph(&graph, VisualSource::Current);
988        assert!(visual.nodes.is_empty());
989        assert!(visual.edges.is_empty());
990        assert_eq!(visual.meta.role_count, 0);
991    }
992
993    #[test]
994    fn grant_node_privileges_merge_across_roles() {
995        // Two roles granting different privileges to the same schema.tables[*] target.
996        let yaml = r#"
997roles:
998  - name: role-a
999  - name: role-b
1000
1001grants:
1002  - role: role-a
1003    privileges: [SELECT]
1004    object: { type: table, schema: app, name: "*" }
1005  - role: role-b
1006    privileges: [SELECT, INSERT, UPDATE]
1007    object: { type: table, schema: app, name: "*" }
1008"#;
1009        let manifest = parse_manifest(yaml).unwrap();
1010        let expanded = expand_manifest(&manifest).unwrap();
1011        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
1012        let visual = build_visual_graph(&graph, VisualSource::Desired);
1013
1014        let grant_nodes: Vec<_> = visual
1015            .nodes
1016            .iter()
1017            .filter(|n| n.kind == NodeKind::GrantTarget && n.label.contains("tables"))
1018            .collect();
1019
1020        // Should be one collapsed node, not two.
1021        assert_eq!(grant_nodes.len(), 1, "grant nodes: {grant_nodes:?}");
1022
1023        // The node's privileges should be the union of both roles' privileges.
1024        let privs = &grant_nodes[0].privileges;
1025        assert!(
1026            privs.contains(&"INSERT".to_string()),
1027            "missing INSERT in {privs:?}"
1028        );
1029        assert!(
1030            privs.contains(&"SELECT".to_string()),
1031            "missing SELECT in {privs:?}"
1032        );
1033        assert!(
1034            privs.contains(&"UPDATE".to_string()),
1035            "missing UPDATE in {privs:?}"
1036        );
1037    }
1038
1039    #[test]
1040    fn mermaid_ids_do_not_collide_for_similar_names() {
1041        let id_at = mermaid_escape_id("role:alice@example.com");
1042        let id_dot = mermaid_escape_id("role:alice.example.com");
1043        let id_under = mermaid_escape_id("role:alice_example_com");
1044        assert_ne!(id_at, id_dot, "@ and . should produce different IDs");
1045        assert_ne!(id_at, id_under, "@ and _ should produce different IDs");
1046        assert_ne!(id_dot, id_under, ". and _ should produce different IDs");
1047    }
1048}