Skip to main content

pgroles_cli/
lib.rs

1//! Testable CLI logic for pgroles.
2//!
3//! All pure functions that don't require a live database connection live here.
4//! The binary (`main.rs`) delegates to these, making validation, plan formatting,
5//! and output rendering fully unit-testable.
6
7use std::path::Path;
8
9use anyhow::{Context, Result};
10
11use pgroles_core::composition::{self, ComposedPolicy, PolicyBundle, PolicyDocument};
12use pgroles_core::diff::{self, Change};
13use pgroles_core::manifest::{self, ExpandedManifest, PolicyManifest, RoleRetirement};
14use pgroles_core::model::RoleGraph;
15use pgroles_core::ownership::ManagedScope;
16use pgroles_core::report::{self, PlanOutputMode};
17use pgroles_core::sql;
18
19// ---------------------------------------------------------------------------
20// File loading
21// ---------------------------------------------------------------------------
22
23/// Read a manifest file from disk and return the raw YAML string.
24pub fn read_manifest_file(path: &Path) -> Result<String> {
25    std::fs::read_to_string(path)
26        .with_context(|| format!("failed to read manifest file: {}", path.display()))
27}
28
29// ---------------------------------------------------------------------------
30// Validation pipeline (pure — no DB)
31// ---------------------------------------------------------------------------
32
33/// Parse and validate a YAML string into a `PolicyManifest`.
34pub fn parse(yaml: &str) -> Result<PolicyManifest> {
35    manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
36}
37
38/// Parse, validate, and expand a manifest YAML string into an `ExpandedManifest`.
39pub fn parse_and_expand(yaml: &str) -> Result<ExpandedManifest> {
40    let policy_manifest = parse(yaml)?;
41    manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))
42}
43
44/// Full validation: parse, expand, and build a RoleGraph from a manifest string.
45/// Returns the expanded manifest and the desired RoleGraph.
46pub fn validate_manifest(yaml: &str) -> Result<ValidatedManifest> {
47    let policy_manifest = parse(yaml)?;
48
49    if policy_manifest.roles.is_empty()
50        && policy_manifest.schemas.is_empty()
51        && policy_manifest.grants.is_empty()
52        && policy_manifest.memberships.is_empty()
53    {
54        tracing::warn!(
55            "manifest defines no roles, schemas, grants, or memberships — is the file correct?"
56        );
57    }
58
59    let expanded =
60        manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))?;
61
62    let default_owner = policy_manifest.default_owner.as_deref();
63    let desired = RoleGraph::from_expanded(&expanded, default_owner)
64        .map_err(|err| anyhow::anyhow!("{err}"))?;
65
66    Ok(ValidatedManifest {
67        manifest: policy_manifest,
68        expanded,
69        desired,
70    })
71}
72
73/// The result of successfully validating a manifest.
74pub struct ValidatedManifest {
75    pub manifest: PolicyManifest,
76    pub expanded: ExpandedManifest,
77    pub desired: RoleGraph,
78}
79
80/// Load, validate, and compose a policy bundle from disk.
81pub fn validate_bundle_file(path: &Path) -> Result<ValidatedBundle> {
82    let yaml = read_manifest_file(path)?;
83    let bundle = composition::parse_policy_bundle(&yaml).map_err(|err| anyhow::anyhow!("{err}"))?;
84    let documents = load_policy_documents(path, &bundle)?;
85    let composed =
86        composition::compose_bundle(&bundle, &documents).map_err(|err| anyhow::anyhow!("{err}"))?;
87
88    Ok(ValidatedBundle {
89        bundle,
90        documents,
91        composed,
92    })
93}
94
95fn load_policy_documents(path: &Path, bundle: &PolicyBundle) -> Result<Vec<PolicyDocument>> {
96    let base_dir = path
97        .parent()
98        .with_context(|| format!("bundle path has no parent directory: {}", path.display()))?;
99
100    bundle
101        .sources
102        .iter()
103        .map(|source| {
104            let source_path = base_dir.join(&source.file);
105            let yaml = read_manifest_file(&source_path)?;
106            let fragment = composition::parse_policy_fragment(&yaml)
107                .map_err(|err| anyhow::anyhow!("{err}"))
108                .with_context(|| {
109                    format!("failed to parse policy document: {}", source_path.display())
110                })?;
111            Ok(PolicyDocument {
112                source: source.file.clone(),
113                fragment,
114            })
115        })
116        .collect()
117}
118
119/// The result of successfully validating a composed policy bundle.
120pub struct ValidatedBundle {
121    pub bundle: PolicyBundle,
122    pub documents: Vec<PolicyDocument>,
123    pub composed: ComposedPolicy,
124}
125
126// ---------------------------------------------------------------------------
127// Plan computation (pure — given both role graphs)
128// ---------------------------------------------------------------------------
129
130/// Compute the list of changes needed to bring `current` state to `desired` state.
131pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
132    diff::diff(current, desired)
133}
134
135/// Collect the role names that the current plan intends to drop.
136pub fn planned_role_drops(changes: &[Change]) -> Vec<String> {
137    changes
138        .iter()
139        .filter_map(|change| match change {
140            Change::DropRole { name } => Some(name.clone()),
141            _ => None,
142        })
143        .collect()
144}
145
146/// Insert explicit retirement actions before any matching role drops.
147pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
148    diff::apply_role_retirements(changes, retirements)
149}
150
151/// Resolve password sources from environment variables for roles that declare them.
152pub fn resolve_passwords(
153    expanded: &ExpandedManifest,
154) -> Result<std::collections::BTreeMap<String, String>> {
155    diff::resolve_passwords(&expanded.roles).map_err(|err| anyhow::anyhow!("{err}"))
156}
157
158/// Inject `SetPassword` changes into a plan for roles with resolved passwords.
159pub fn inject_password_changes(
160    changes: Vec<Change>,
161    resolved_passwords: &std::collections::BTreeMap<String, String>,
162) -> Vec<Change> {
163    diff::inject_password_changes(changes, resolved_passwords)
164}
165
166// ---------------------------------------------------------------------------
167// Output formatting
168// ---------------------------------------------------------------------------
169
170/// Format a plan as SQL statements.
171pub fn format_plan_sql(changes: &[Change]) -> String {
172    sql::render_all(changes)
173}
174
175/// Format a plan as SQL statements using an explicit SQL context.
176pub fn format_plan_sql_with_context(changes: &[Change], ctx: &sql::SqlContext) -> String {
177    sql::render_all_with_context(
178        &report::shape_plan_changes(changes, PlanOutputMode::Redacted),
179        ctx,
180    )
181}
182
183/// Format a plan as JSON for machine consumption.
184pub fn format_plan_json(changes: &[Change]) -> Result<String> {
185    report::render_plan_json(changes, PlanOutputMode::Redacted)
186        .map_err(|err| anyhow::anyhow!("{err}"))
187}
188
189/// Format a bundle plan as JSON with ownership annotations for each change.
190pub fn format_bundle_plan_json(changes: &[Change], composed: &ComposedPolicy) -> Result<String> {
191    report::render_bundle_plan_json(
192        changes,
193        &composed.report_context(),
194        PlanOutputMode::Redacted,
195    )
196    .map_err(|err| anyhow::anyhow!("{err}"))
197}
198
199/// Summary statistics for a plan.
200#[derive(Debug, Default, PartialEq, Eq)]
201pub struct PlanSummary {
202    pub roles_created: usize,
203    pub roles_altered: usize,
204    pub schemas_created: usize,
205    pub schema_owners_altered: usize,
206    pub roles_dropped: usize,
207    pub comments_changed: usize,
208    pub sessions_terminated: usize,
209    pub ownerships_reassigned: usize,
210    pub owned_objects_dropped: usize,
211    pub grants: usize,
212    pub revokes: usize,
213    pub default_privileges_set: usize,
214    pub default_privileges_revoked: usize,
215    pub members_added: usize,
216    pub members_removed: usize,
217    pub passwords_set: usize,
218}
219
220impl PlanSummary {
221    /// Compute summary statistics from a list of changes.
222    pub fn from_changes(changes: &[Change]) -> Self {
223        let mut summary = Self::default();
224        for change in changes {
225            match change {
226                Change::CreateRole { .. } => summary.roles_created += 1,
227                Change::CreateSchema { .. } => summary.schemas_created += 1,
228                Change::AlterSchemaOwner { .. } => summary.schema_owners_altered += 1,
229                Change::AlterRole { .. } => summary.roles_altered += 1,
230                Change::DropRole { .. } => summary.roles_dropped += 1,
231                Change::SetComment { .. } => summary.comments_changed += 1,
232                Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
233                Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
234                Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
235                Change::Grant { .. } | Change::EnsureSchemaOwnerPrivileges { .. } => {
236                    summary.grants += 1
237                }
238                Change::Revoke { .. } => summary.revokes += 1,
239                Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
240                Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
241                Change::AddMember { .. } => summary.members_added += 1,
242                Change::RemoveMember { .. } => summary.members_removed += 1,
243                Change::SetPassword { .. } => summary.passwords_set += 1,
244            }
245        }
246        summary
247    }
248
249    /// Total number of changes in the plan.
250    pub fn total(&self) -> usize {
251        self.roles_created
252            + self.roles_altered
253            + self.schemas_created
254            + self.schema_owners_altered
255            + self.roles_dropped
256            + self.comments_changed
257            + self.sessions_terminated
258            + self.ownerships_reassigned
259            + self.owned_objects_dropped
260            + self.grants
261            + self.revokes
262            + self.default_privileges_set
263            + self.default_privileges_revoked
264            + self.members_added
265            + self.members_removed
266            + self.passwords_set
267    }
268
269    /// True if the plan has no changes.
270    pub fn is_empty(&self) -> bool {
271        self.total() == 0
272    }
273
274    /// True if the plan has structural drift (excluding password-only changes).
275    ///
276    /// Password changes always appear in plans because passwords cannot be read
277    /// back from PostgreSQL for comparison. This method allows CI gates
278    /// (`--exit-code`) to distinguish real drift from password-only changes.
279    pub fn has_structural_changes(&self) -> bool {
280        self.total() - self.passwords_set > 0
281    }
282
283    pub fn format_plan(&self) -> String {
284        self.format_with_header("Plan")
285    }
286
287    pub fn format_applied(&self) -> String {
288        self.format_with_header("Applied")
289    }
290
291    fn format_with_header(&self, header: &str) -> String {
292        if self.is_empty() {
293            return "No changes needed. Database is in sync with manifest.".to_string();
294        }
295
296        let mut output = String::new();
297        output.push_str(&format!("{header}: {} change(s)\n", self.total()));
298
299        let items: Vec<(&str, usize)> = vec![
300            ("role(s) to create", self.roles_created),
301            ("role(s) to alter", self.roles_altered),
302            ("schema(s) to create", self.schemas_created),
303            ("schema owner change(s)", self.schema_owners_altered),
304            ("role(s) to drop", self.roles_dropped),
305            ("comment(s) to change", self.comments_changed),
306            ("session termination step(s)", self.sessions_terminated),
307            ("ownership reassignment(s)", self.ownerships_reassigned),
308            ("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
309            ("grant(s) to add", self.grants),
310            ("grant(s) to revoke", self.revokes),
311            ("default privilege(s) to set", self.default_privileges_set),
312            (
313                "default privilege(s) to revoke",
314                self.default_privileges_revoked,
315            ),
316            ("membership(s) to add", self.members_added),
317            ("membership(s) to remove", self.members_removed),
318            ("password(s) to set", self.passwords_set),
319        ];
320
321        for (label, count) in items {
322            if count > 0 {
323                output.push_str(&format!("  {count} {label}\n"));
324            }
325        }
326
327        output
328    }
329}
330
331impl std::fmt::Display for PlanSummary {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        write!(f, "{}", self.format_plan())
334    }
335}
336
337/// Format validation results for human-readable output.
338pub fn format_validation_result(validated: &ValidatedManifest) -> String {
339    let mut output = String::new();
340    output.push_str("Manifest is valid.\n");
341    output.push_str(&format!(
342        "  {} schema(s) defined\n",
343        validated.expanded.schemas.len()
344    ));
345    output.push_str(&format!(
346        "  {} role(s) defined\n",
347        validated.expanded.roles.len()
348    ));
349    output.push_str(&format!(
350        "  {} grant(s) defined\n",
351        validated.expanded.grants.len()
352    ));
353    output.push_str(&format!(
354        "  {} default privilege(s) defined\n",
355        validated.expanded.default_privileges.len()
356    ));
357    output.push_str(&format!(
358        "  {} membership(s) defined\n",
359        validated.expanded.memberships.len()
360    ));
361    output
362}
363
364/// Format bundle validation results for human-readable output.
365pub fn format_bundle_validation_result(validated: &ValidatedBundle) -> String {
366    let mut output = String::new();
367    output.push_str("Policy bundle is valid.\n");
368    output.push_str(&format!(
369        "  {} source document(s) loaded\n",
370        validated.documents.len()
371    ));
372    output.push_str(&format!(
373        "  {} shared profile(s) defined\n",
374        validated.bundle.shared.profiles.len()
375    ));
376    output.push_str(&format!(
377        "  {} schema(s) defined\n",
378        validated.composed.expanded.schemas.len()
379    ));
380    output.push_str(&format!(
381        "  {} role(s) defined\n",
382        validated.composed.expanded.roles.len()
383    ));
384    output.push_str(&format!(
385        "  {} grant(s) defined\n",
386        validated.composed.expanded.grants.len()
387    ));
388    output.push_str(&format!(
389        "  {} default privilege(s) defined\n",
390        validated.composed.expanded.default_privileges.len()
391    ));
392    output.push_str(&format!(
393        "  {} membership(s) defined\n",
394        validated.composed.expanded.memberships.len()
395    ));
396    output
397}
398
399/// Format a composed managed scope for human-readable debug output.
400pub fn format_managed_scope_summary(scope: &ManagedScope) -> String {
401    let mut output = String::new();
402    output.push_str("Managed scope:\n");
403    output.push_str(&format!("  {} role(s)\n", scope.roles.len()));
404    output.push_str(&format!("  {} schema(s)\n", scope.schemas.len()));
405
406    let owner_schemas: Vec<&str> = scope
407        .schemas
408        .iter()
409        .filter_map(|(schema, managed)| managed.owner.then_some(schema.as_str()))
410        .collect();
411    let binding_schemas: Vec<&str> = scope
412        .schemas
413        .iter()
414        .filter_map(|(schema, managed)| managed.bindings.then_some(schema.as_str()))
415        .collect();
416
417    output.push_str(&format!(
418        "  owner-managed schema(s): {}\n",
419        owner_schemas.len()
420    ));
421    if !owner_schemas.is_empty() {
422        output.push_str(&format!("  owner scope: {}\n", owner_schemas.join(", ")));
423    }
424
425    output.push_str(&format!(
426        "  binding-managed schema(s): {}\n",
427        binding_schemas.len()
428    ));
429    if !binding_schemas.is_empty() {
430        output.push_str(&format!(
431            "  binding scope: {}\n",
432            binding_schemas.join(", ")
433        ));
434    }
435
436    output
437}
438
439// ---------------------------------------------------------------------------
440// Inspect output formatting
441// ---------------------------------------------------------------------------
442
443/// Format a RoleGraph as a human-readable summary.
444pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
445    let mut output = String::new();
446    output.push_str(&format!("Roles: {}\n", graph.roles.len()));
447    for (name, state) in &graph.roles {
448        let login_marker = if state.login { "LOGIN" } else { "NOLOGIN" };
449        output.push_str(&format!("  {name} ({login_marker})\n"));
450    }
451    output.push_str(&format!("Schemas: {}\n", graph.schemas.len()));
452    for (name, state) in &graph.schemas {
453        match &state.owner {
454            Some(owner) => output.push_str(&format!("  {name} (owner: {owner})\n")),
455            None => output.push_str(&format!("  {name}\n")),
456        }
457    }
458    output.push_str(&format!("Grants: {}\n", graph.grants.len()));
459    output.push_str(&format!(
460        "Default privileges: {}\n",
461        graph.default_privileges.len()
462    ));
463    output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
464    for edge in &graph.memberships {
465        output.push_str(&format!("  {} -> {}\n", edge.member, edge.role));
466    }
467    output
468}
469
470// ---------------------------------------------------------------------------
471// Tests
472// ---------------------------------------------------------------------------
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use pgroles_core::ownership::ManagedSchemaScope;
478
479    const MINIMAL_MANIFEST: &str = r#"
480default_owner: app_owner
481
482schemas:
483  - name: analytics
484    owner: app_owner
485    profiles: []
486
487roles:
488  - name: analytics
489    login: true
490    comment: "Analytics read-only role"
491
492grants:
493  - role: analytics
494    privileges: [CONNECT]
495    object: { type: database, name: mydb }
496"#;
497
498    const PROFILE_MANIFEST: &str = r#"
499default_owner: app_owner
500
501profiles:
502  editor:
503    grants:
504      - privileges: [USAGE]
505        object: { type: schema }
506      - privileges: [SELECT, INSERT, UPDATE, DELETE]
507        object: { type: table, name: "*" }
508    default_privileges:
509      - privileges: [SELECT, INSERT, UPDATE, DELETE]
510        on_type: table
511  viewer:
512    grants:
513      - privileges: [USAGE]
514        object: { type: schema }
515      - privileges: [SELECT]
516        object: { type: table, name: "*" }
517    default_privileges:
518      - privileges: [SELECT]
519        on_type: table
520
521schemas:
522  - name: inventory
523    profiles: [editor, viewer]
524  - name: catalog
525    profiles: [viewer]
526
527roles:
528  - name: app-service
529    login: true
530
531grants:
532  - role: app-service
533    privileges: [CONNECT]
534    object: { type: database, name: mydb }
535
536memberships:
537  - role: inventory-editor
538    members:
539      - name: app-service
540"#;
541
542    const INVALID_YAML: &str = r#"
543this is: [not: valid yaml: [[
544"#;
545
546    const UNDEFINED_PROFILE: &str = r#"
547profiles:
548  editor:
549    grants: []
550
551schemas:
552  - name: myschema
553    profiles: [nonexistent]
554"#;
555
556    // -----------------------------------------------------------------------
557    // parse
558    // -----------------------------------------------------------------------
559
560    #[test]
561    fn parse_valid_manifest() {
562        let result = parse(MINIMAL_MANIFEST);
563        assert!(result.is_ok());
564        let manifest = result.unwrap();
565        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
566        assert_eq!(manifest.roles.len(), 1);
567        assert_eq!(manifest.roles[0].name, "analytics");
568    }
569
570    #[test]
571    fn parse_invalid_yaml() {
572        let result = parse(INVALID_YAML);
573        assert!(result.is_err());
574        let err_msg = result.unwrap_err().to_string();
575        assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
576    }
577
578    // -----------------------------------------------------------------------
579    // parse_and_expand
580    // -----------------------------------------------------------------------
581
582    #[test]
583    fn expand_profile_manifest() {
584        let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
585
586        assert_eq!(expanded.schemas.len(), 2);
587        // inventory-editor, inventory-viewer, catalog-viewer, app-service
588        assert_eq!(expanded.roles.len(), 4);
589
590        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
591        assert!(role_names.contains(&"inventory-editor"));
592        assert!(role_names.contains(&"inventory-viewer"));
593        assert!(role_names.contains(&"catalog-viewer"));
594        assert!(role_names.contains(&"app-service"));
595    }
596
597    #[test]
598    fn expand_undefined_profile_fails() {
599        let result = parse_and_expand(UNDEFINED_PROFILE);
600        assert!(result.is_err());
601        let err_msg = result.unwrap_err().to_string();
602        assert!(
603            err_msg.contains("nonexistent"),
604            "expected error about 'nonexistent' profile, got: {err_msg}"
605        );
606    }
607
608    // -----------------------------------------------------------------------
609    // validate_manifest
610    // -----------------------------------------------------------------------
611
612    #[test]
613    fn validate_builds_role_graph() {
614        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
615
616        // Check the desired graph has the expected roles
617        assert_eq!(validated.desired.roles.len(), 4);
618        assert!(validated.desired.roles.contains_key("inventory-editor"));
619        assert!(validated.desired.roles.contains_key("app-service"));
620
621        // Check grants were expanded
622        assert!(!validated.desired.grants.is_empty());
623
624        // Check memberships
625        assert!(!validated.desired.memberships.is_empty());
626    }
627
628    // -----------------------------------------------------------------------
629    // compute_plan + format
630    // -----------------------------------------------------------------------
631
632    #[test]
633    fn plan_from_empty_creates_roles() {
634        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
635        let current = RoleGraph::default(); // empty database
636
637        let changes = compute_plan(&current, &validated.desired);
638        assert!(!changes.is_empty());
639
640        let summary = PlanSummary::from_changes(&changes);
641        assert_eq!(summary.roles_created, 4); // inventory-editor, inventory-viewer, catalog-viewer, app-service
642        assert_eq!(summary.schemas_created, 2); // inventory, catalog
643        assert!(summary.grants > 0);
644        assert!(!summary.is_empty());
645    }
646
647    #[test]
648    fn plan_no_changes_when_in_sync() {
649        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
650        // Simulate a DB that already has the desired state
651        let current = validated.desired.clone();
652
653        let changes = compute_plan(&current, &validated.desired);
654        let summary = PlanSummary::from_changes(&changes);
655        assert!(summary.is_empty());
656        assert_eq!(summary.total(), 0);
657    }
658
659    #[test]
660    fn format_plan_sql_produces_sql() {
661        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
662        let current = RoleGraph::default();
663        let changes = compute_plan(&current, &validated.desired);
664
665        let sql_output = format_plan_sql(&changes);
666        assert!(
667            sql_output.contains("CREATE SCHEMA"),
668            "expected CREATE SCHEMA in: {sql_output}"
669        );
670        assert!(
671            sql_output.contains("CREATE ROLE"),
672            "expected CREATE ROLE in: {sql_output}"
673        );
674        assert!(
675            sql_output.contains("\"analytics\""),
676            "expected quoted role name in: {sql_output}"
677        );
678    }
679
680    #[test]
681    fn planned_role_drops_only_returns_drop_changes() {
682        let changes = vec![
683            Change::CreateRole {
684                name: "new-role".to_string(),
685                state: pgroles_core::model::RoleState::default(),
686            },
687            Change::DropRole {
688                name: "old-role".to_string(),
689            },
690            Change::DropRole {
691                name: "stale-role".to_string(),
692            },
693        ];
694
695        assert_eq!(
696            planned_role_drops(&changes),
697            vec!["old-role".to_string(), "stale-role".to_string()]
698        );
699    }
700
701    #[test]
702    fn apply_role_retirements_updates_plan_summary() {
703        let changes = apply_role_retirements(
704            vec![Change::DropRole {
705                name: "legacy-app".to_string(),
706            }],
707            &[pgroles_core::manifest::RoleRetirement {
708                role: "legacy-app".to_string(),
709                reassign_owned_to: Some("app-owner".to_string()),
710                drop_owned: true,
711                terminate_sessions: true,
712            }],
713        );
714
715        let summary = PlanSummary::from_changes(&changes);
716        assert_eq!(summary.roles_dropped, 1);
717        assert_eq!(summary.sessions_terminated, 1);
718        assert_eq!(summary.ownerships_reassigned, 1);
719        assert_eq!(summary.owned_objects_dropped, 1);
720        assert_eq!(summary.total(), 4);
721    }
722
723    // -----------------------------------------------------------------------
724    // PlanSummary display
725    // -----------------------------------------------------------------------
726
727    #[test]
728    fn plan_summary_display_empty() {
729        let summary = PlanSummary::default();
730        let display = summary.to_string();
731        assert!(display.contains("No changes needed"));
732    }
733
734    #[test]
735    fn plan_summary_display_with_changes() {
736        let summary = PlanSummary {
737            roles_created: 2,
738            schemas_created: 1,
739            grants: 5,
740            members_added: 1,
741            ..Default::default()
742        };
743        let display = summary.to_string();
744        assert!(display.contains("9 change(s)"), "got: {display}");
745        assert!(display.contains("2 role(s) to create"), "got: {display}");
746        assert!(display.contains("1 schema(s) to create"), "got: {display}");
747        assert!(display.contains("5 grant(s) to add"), "got: {display}");
748        assert!(display.contains("1 membership(s) to add"), "got: {display}");
749        // Should not mention zero-count items
750        assert!(!display.contains("to drop"), "got: {display}");
751        assert!(!display.contains("to revoke"), "got: {display}");
752    }
753
754    // -----------------------------------------------------------------------
755    // format_validation_result
756    // -----------------------------------------------------------------------
757
758    #[test]
759    fn validation_result_shows_counts() {
760        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
761        let output = format_validation_result(&validated);
762        assert!(output.contains("Manifest is valid"), "got: {output}");
763        assert!(output.contains("2 schema(s)"), "got: {output}");
764        assert!(output.contains("4 role(s)"), "got: {output}");
765    }
766
767    #[test]
768    fn managed_scope_summary_lists_owner_and_binding_facets() {
769        let scope = ManagedScope {
770            roles: ["app".to_string(), "app_owner".to_string()]
771                .into_iter()
772                .collect(),
773            schemas: [
774                (
775                    "inventory".to_string(),
776                    ManagedSchemaScope {
777                        owner: true,
778                        bindings: true,
779                    },
780                ),
781                (
782                    "audit".to_string(),
783                    ManagedSchemaScope {
784                        owner: true,
785                        bindings: false,
786                    },
787                ),
788            ]
789            .into_iter()
790            .collect(),
791        };
792
793        let output = format_managed_scope_summary(&scope);
794
795        assert!(output.contains("Managed scope:"), "got: {output}");
796        assert!(output.contains("2 role(s)"), "got: {output}");
797        assert!(output.contains("2 schema(s)"), "got: {output}");
798        assert!(
799            output.contains("owner scope: audit, inventory"),
800            "got: {output}"
801        );
802        assert!(output.contains("binding scope: inventory"), "got: {output}");
803    }
804
805    // -----------------------------------------------------------------------
806    // read_manifest_file
807    // -----------------------------------------------------------------------
808
809    #[test]
810    fn read_nonexistent_file_fails() {
811        let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
812        assert!(result.is_err());
813        let err_msg = format!("{:#}", result.unwrap_err());
814        assert!(
815            err_msg.contains("failed to read manifest file"),
816            "got: {err_msg}"
817        );
818    }
819
820    // -----------------------------------------------------------------------
821    // format_role_graph_summary
822    // -----------------------------------------------------------------------
823
824    #[test]
825    fn role_graph_summary_format() {
826        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
827        let summary = format_role_graph_summary(&validated.desired);
828        assert!(summary.contains("Roles: 1"), "got: {summary}");
829        assert!(summary.contains("Schemas: 1"), "got: {summary}");
830        assert!(summary.contains("analytics (LOGIN)"), "got: {summary}");
831    }
832
833    // -----------------------------------------------------------------------
834    // has_structural_changes — password-only drift detection
835    // -----------------------------------------------------------------------
836
837    #[test]
838    fn has_structural_changes_true_for_non_password_changes() {
839        let summary = PlanSummary {
840            roles_created: 1,
841            schemas_created: 1,
842            grants: 2,
843            ..Default::default()
844        };
845        assert!(summary.has_structural_changes());
846    }
847
848    #[test]
849    fn has_structural_changes_false_for_password_only() {
850        let summary = PlanSummary {
851            passwords_set: 3,
852            ..Default::default()
853        };
854        assert!(
855            !summary.has_structural_changes(),
856            "password-only plan should NOT be considered structural drift"
857        );
858    }
859
860    #[test]
861    fn has_structural_changes_true_for_mixed() {
862        let summary = PlanSummary {
863            roles_created: 1,
864            passwords_set: 2,
865            ..Default::default()
866        };
867        assert!(
868            summary.has_structural_changes(),
869            "mixed plan with structural + password changes IS structural drift"
870        );
871    }
872
873    #[test]
874    fn has_structural_changes_false_for_empty() {
875        let summary = PlanSummary::default();
876        assert!(!summary.has_structural_changes());
877    }
878
879    #[test]
880    fn plan_summary_displays_password_count() {
881        let summary = PlanSummary {
882            passwords_set: 2,
883            roles_created: 1,
884            ..Default::default()
885        };
886        let display = summary.to_string();
887        assert!(display.contains("2 password(s) to set"), "got: {display}");
888        assert!(display.contains("3 change(s)"), "got: {display}");
889    }
890
891    // -----------------------------------------------------------------------
892    // ReconciliationMode integration through compute_plan + filter
893    // -----------------------------------------------------------------------
894
895    #[test]
896    fn additive_mode_filters_revokes_from_plan() {
897        use pgroles_core::diff::{ReconciliationMode, filter_changes};
898        use pgroles_core::model::RoleState;
899
900        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
901
902        let mut current = validated.desired.clone();
903        current
904            .roles
905            .insert("stale-role".to_string(), RoleState::default());
906
907        let changes = compute_plan(&current, &validated.desired);
908        assert!(changes.iter().any(|c| matches!(
909            c,
910            pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
911        )));
912
913        let filtered = filter_changes(changes, ReconciliationMode::Additive);
914        assert!(
915            !filtered
916                .iter()
917                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
918            "additive mode should filter out DropRole"
919        );
920    }
921
922    #[test]
923    fn adopt_mode_filters_drops_but_keeps_revokes() {
924        use pgroles_core::diff::{ReconciliationMode, filter_changes};
925        use pgroles_core::manifest::{ObjectType, Privilege};
926        use pgroles_core::model::{GrantKey, GrantState, RoleState};
927        use std::collections::BTreeSet;
928
929        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
930
931        let mut current = validated.desired.clone();
932        current
933            .roles
934            .insert("stale-role".to_string(), RoleState::default());
935        current.grants.insert(
936            GrantKey {
937                role: "analytics".to_string(),
938                object_type: ObjectType::Table,
939                schema: Some("public".to_string()),
940                name: Some("*".to_string()),
941            },
942            GrantState {
943                privileges: BTreeSet::from([Privilege::Select]),
944            },
945        );
946
947        let changes = compute_plan(&current, &validated.desired);
948
949        let filtered = filter_changes(changes, ReconciliationMode::Adopt);
950        assert!(
951            !filtered
952                .iter()
953                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
954            "adopt mode should filter out DropRole"
955        );
956        assert!(
957            filtered
958                .iter()
959                .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
960            "adopt mode should keep Revoke changes"
961        );
962    }
963    // -----------------------------------------------------------------------
964    // format_plan_json
965    // -----------------------------------------------------------------------
966
967    #[test]
968    fn plan_json_produces_valid_json() {
969        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
970        let current = RoleGraph::default();
971        let changes = compute_plan(&current, &validated.desired);
972
973        let json_output = format_plan_json(&changes).unwrap();
974        // Should be parseable JSON
975        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
976        assert!(parsed.is_array());
977        // Should contain CreateRole
978        let text = json_output.to_string();
979        assert!(text.contains("CreateRole"), "got: {text}");
980        assert!(text.contains("analytics"), "got: {text}");
981    }
982
983    #[test]
984    fn format_plan_json_redacts_passwords() {
985        let changes = vec![Change::SetPassword {
986            name: "app-svc".to_string(),
987            password: "super-secret".to_string(),
988        }];
989
990        let json = format_plan_json(&changes).expect("json formatting should succeed");
991        assert!(json.contains("[REDACTED]"), "got: {json}");
992        assert!(!json.contains("super-secret"), "got: {json}");
993    }
994
995    #[test]
996    fn bundle_plan_json_includes_scope_and_ownership_annotations() {
997        let bundle = composition::parse_policy_bundle(
998            r#"
999sources:
1000  - file: app.yaml
1001"#,
1002        )
1003        .unwrap();
1004        let documents = vec![composition::PolicyDocument {
1005            source: "app.yaml".to_string(),
1006            fragment: composition::parse_policy_fragment(
1007                r#"
1008policy:
1009  name: app
1010scope:
1011  roles: [app]
1012roles:
1013  - name: app
1014    login: false
1015"#,
1016            )
1017            .unwrap(),
1018        }];
1019        let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1020        let changes = compute_plan(&RoleGraph::default(), &composed.desired);
1021
1022        let json_output = format_bundle_plan_json(&changes, &composed).unwrap();
1023        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1024
1025        assert!(parsed.is_object());
1026        assert_eq!(parsed["schema_version"], "pgroles.bundle_plan.v1");
1027        assert_eq!(parsed["managed_scope"]["roles"][0], "app");
1028        assert_eq!(parsed["changes"][0]["owner"]["document"], "app");
1029        assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["kind"], "role");
1030        assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["name"], "app");
1031    }
1032
1033    #[test]
1034    fn format_plan_sql_redacts_passwords() {
1035        let changes = vec![Change::SetPassword {
1036            name: "app-svc".to_string(),
1037            password: "super-secret".to_string(),
1038        }];
1039
1040        let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
1041        assert!(sql.contains("[REDACTED]"), "got: {sql}");
1042        assert!(!sql.contains("super-secret"), "got: {sql}");
1043    }
1044
1045    #[test]
1046    fn format_applied_uses_applied_header() {
1047        let summary = PlanSummary {
1048            roles_created: 1,
1049            schemas_created: 1,
1050            grants: 2,
1051            ..Default::default()
1052        };
1053
1054        let display = summary.format_applied();
1055        assert!(
1056            display.starts_with("Applied: 4 change(s)\n"),
1057            "got: {display}"
1058        );
1059        assert!(display.contains("1 role(s) to create"), "got: {display}");
1060        assert!(display.contains("1 schema(s) to create"), "got: {display}");
1061        assert!(display.contains("2 grant(s) to add"), "got: {display}");
1062        assert!(!display.contains("Plan:"), "got: {display}");
1063    }
1064}