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::diff::{self, Change};
12use pgroles_core::manifest::{self, ExpandedManifest, PolicyManifest, RoleRetirement};
13use pgroles_core::model::RoleGraph;
14use pgroles_core::sql;
15
16// ---------------------------------------------------------------------------
17// File loading
18// ---------------------------------------------------------------------------
19
20/// Read a manifest file from disk and return the raw YAML string.
21pub fn read_manifest_file(path: &Path) -> Result<String> {
22    std::fs::read_to_string(path)
23        .with_context(|| format!("failed to read manifest file: {}", path.display()))
24}
25
26// ---------------------------------------------------------------------------
27// Validation pipeline (pure — no DB)
28// ---------------------------------------------------------------------------
29
30/// Parse and validate a YAML string into a `PolicyManifest`.
31pub fn parse(yaml: &str) -> Result<PolicyManifest> {
32    manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
33}
34
35/// Parse, validate, and expand a manifest YAML string into an `ExpandedManifest`.
36pub fn parse_and_expand(yaml: &str) -> Result<ExpandedManifest> {
37    let policy_manifest = parse(yaml)?;
38    manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))
39}
40
41/// Full validation: parse, expand, and build a RoleGraph from a manifest string.
42/// Returns the expanded manifest and the desired RoleGraph.
43pub fn validate_manifest(yaml: &str) -> Result<ValidatedManifest> {
44    let policy_manifest = parse(yaml)?;
45
46    if policy_manifest.roles.is_empty()
47        && policy_manifest.schemas.is_empty()
48        && policy_manifest.grants.is_empty()
49        && policy_manifest.memberships.is_empty()
50    {
51        tracing::warn!(
52            "manifest defines no roles, schemas, grants, or memberships — is the file correct?"
53        );
54    }
55
56    let expanded =
57        manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))?;
58
59    let default_owner = policy_manifest.default_owner.as_deref();
60    let desired = RoleGraph::from_expanded(&expanded, default_owner)
61        .map_err(|err| anyhow::anyhow!("{err}"))?;
62
63    Ok(ValidatedManifest {
64        manifest: policy_manifest,
65        expanded,
66        desired,
67    })
68}
69
70/// The result of successfully validating a manifest.
71pub struct ValidatedManifest {
72    pub manifest: PolicyManifest,
73    pub expanded: ExpandedManifest,
74    pub desired: RoleGraph,
75}
76
77// ---------------------------------------------------------------------------
78// Plan computation (pure — given both role graphs)
79// ---------------------------------------------------------------------------
80
81/// Compute the list of changes needed to bring `current` state to `desired` state.
82pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
83    diff::diff(current, desired)
84}
85
86/// Collect the role names that the current plan intends to drop.
87pub fn planned_role_drops(changes: &[Change]) -> Vec<String> {
88    changes
89        .iter()
90        .filter_map(|change| match change {
91            Change::DropRole { name } => Some(name.clone()),
92            _ => None,
93        })
94        .collect()
95}
96
97/// Insert explicit retirement actions before any matching role drops.
98pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
99    diff::apply_role_retirements(changes, retirements)
100}
101
102/// Resolve password sources from environment variables for roles that declare them.
103pub fn resolve_passwords(
104    expanded: &ExpandedManifest,
105) -> Result<std::collections::BTreeMap<String, String>> {
106    diff::resolve_passwords(&expanded.roles).map_err(|err| anyhow::anyhow!("{err}"))
107}
108
109/// Inject `SetPassword` changes into a plan for roles with resolved passwords.
110pub fn inject_password_changes(
111    changes: Vec<Change>,
112    resolved_passwords: &std::collections::BTreeMap<String, String>,
113) -> Vec<Change> {
114    diff::inject_password_changes(changes, resolved_passwords)
115}
116
117// ---------------------------------------------------------------------------
118// Output formatting
119// ---------------------------------------------------------------------------
120
121/// Format a plan as SQL statements.
122pub fn format_plan_sql(changes: &[Change]) -> String {
123    sql::render_all(changes)
124}
125
126/// Format a plan as SQL statements using an explicit SQL context.
127pub fn format_plan_sql_with_context(changes: &[Change], ctx: &sql::SqlContext) -> String {
128    sql::render_all_with_context(&redacted_changes(changes), ctx)
129}
130
131/// Format a plan as JSON for machine consumption.
132pub fn format_plan_json(changes: &[Change]) -> Result<String> {
133    serde_json::to_string_pretty(&redacted_changes(changes)).map_err(|err| anyhow::anyhow!("{err}"))
134}
135
136fn redacted_changes(changes: &[Change]) -> Vec<Change> {
137    changes
138        .iter()
139        .map(|change| match change {
140            Change::SetPassword { name, .. } => Change::SetPassword {
141                name: name.clone(),
142                password: "[REDACTED]".to_string(),
143            },
144            other => other.clone(),
145        })
146        .collect()
147}
148
149/// Summary statistics for a plan.
150#[derive(Debug, Default, PartialEq, Eq)]
151pub struct PlanSummary {
152    pub roles_created: usize,
153    pub roles_altered: usize,
154    pub roles_dropped: usize,
155    pub comments_changed: usize,
156    pub sessions_terminated: usize,
157    pub ownerships_reassigned: usize,
158    pub owned_objects_dropped: usize,
159    pub grants: usize,
160    pub revokes: usize,
161    pub default_privileges_set: usize,
162    pub default_privileges_revoked: usize,
163    pub members_added: usize,
164    pub members_removed: usize,
165    pub passwords_set: usize,
166}
167
168impl PlanSummary {
169    /// Compute summary statistics from a list of changes.
170    pub fn from_changes(changes: &[Change]) -> Self {
171        let mut summary = Self::default();
172        for change in changes {
173            match change {
174                Change::CreateRole { .. } => summary.roles_created += 1,
175                Change::AlterRole { .. } => summary.roles_altered += 1,
176                Change::DropRole { .. } => summary.roles_dropped += 1,
177                Change::SetComment { .. } => summary.comments_changed += 1,
178                Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
179                Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
180                Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
181                Change::Grant { .. } => summary.grants += 1,
182                Change::Revoke { .. } => summary.revokes += 1,
183                Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
184                Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
185                Change::AddMember { .. } => summary.members_added += 1,
186                Change::RemoveMember { .. } => summary.members_removed += 1,
187                Change::SetPassword { .. } => summary.passwords_set += 1,
188            }
189        }
190        summary
191    }
192
193    /// Total number of changes in the plan.
194    pub fn total(&self) -> usize {
195        self.roles_created
196            + self.roles_altered
197            + self.roles_dropped
198            + self.comments_changed
199            + self.sessions_terminated
200            + self.ownerships_reassigned
201            + self.owned_objects_dropped
202            + self.grants
203            + self.revokes
204            + self.default_privileges_set
205            + self.default_privileges_revoked
206            + self.members_added
207            + self.members_removed
208            + self.passwords_set
209    }
210
211    /// True if the plan has no changes.
212    pub fn is_empty(&self) -> bool {
213        self.total() == 0
214    }
215
216    /// True if the plan has structural drift (excluding password-only changes).
217    ///
218    /// Password changes always appear in plans because passwords cannot be read
219    /// back from PostgreSQL for comparison. This method allows CI gates
220    /// (`--exit-code`) to distinguish real drift from password-only changes.
221    pub fn has_structural_changes(&self) -> bool {
222        self.total() - self.passwords_set > 0
223    }
224
225    pub fn format_plan(&self) -> String {
226        self.format_with_header("Plan")
227    }
228
229    pub fn format_applied(&self) -> String {
230        self.format_with_header("Applied")
231    }
232
233    fn format_with_header(&self, header: &str) -> String {
234        if self.is_empty() {
235            return "No changes needed. Database is in sync with manifest.".to_string();
236        }
237
238        let mut output = String::new();
239        output.push_str(&format!("{header}: {} change(s)\n", self.total()));
240
241        let items: Vec<(&str, usize)> = vec![
242            ("role(s) to create", self.roles_created),
243            ("role(s) to alter", self.roles_altered),
244            ("role(s) to drop", self.roles_dropped),
245            ("comment(s) to change", self.comments_changed),
246            ("session termination step(s)", self.sessions_terminated),
247            ("ownership reassignment(s)", self.ownerships_reassigned),
248            ("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
249            ("grant(s) to add", self.grants),
250            ("grant(s) to revoke", self.revokes),
251            ("default privilege(s) to set", self.default_privileges_set),
252            (
253                "default privilege(s) to revoke",
254                self.default_privileges_revoked,
255            ),
256            ("membership(s) to add", self.members_added),
257            ("membership(s) to remove", self.members_removed),
258            ("password(s) to set", self.passwords_set),
259        ];
260
261        for (label, count) in items {
262            if count > 0 {
263                output.push_str(&format!("  {count} {label}\n"));
264            }
265        }
266
267        output
268    }
269}
270
271impl std::fmt::Display for PlanSummary {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        write!(f, "{}", self.format_plan())
274    }
275}
276
277/// Format validation results for human-readable output.
278pub fn format_validation_result(validated: &ValidatedManifest) -> String {
279    let mut output = String::new();
280    output.push_str("Manifest is valid.\n");
281    output.push_str(&format!(
282        "  {} role(s) defined\n",
283        validated.expanded.roles.len()
284    ));
285    output.push_str(&format!(
286        "  {} grant(s) defined\n",
287        validated.expanded.grants.len()
288    ));
289    output.push_str(&format!(
290        "  {} default privilege(s) defined\n",
291        validated.expanded.default_privileges.len()
292    ));
293    output.push_str(&format!(
294        "  {} membership(s) defined\n",
295        validated.expanded.memberships.len()
296    ));
297    output
298}
299
300// ---------------------------------------------------------------------------
301// Inspect output formatting
302// ---------------------------------------------------------------------------
303
304/// Format a RoleGraph as a human-readable summary.
305pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
306    let mut output = String::new();
307    output.push_str(&format!("Roles: {}\n", graph.roles.len()));
308    for (name, state) in &graph.roles {
309        let login_marker = if state.login { "LOGIN" } else { "NOLOGIN" };
310        output.push_str(&format!("  {name} ({login_marker})\n"));
311    }
312    output.push_str(&format!("Grants: {}\n", graph.grants.len()));
313    output.push_str(&format!(
314        "Default privileges: {}\n",
315        graph.default_privileges.len()
316    ));
317    output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
318    for edge in &graph.memberships {
319        output.push_str(&format!("  {} -> {}\n", edge.member, edge.role));
320    }
321    output
322}
323
324// ---------------------------------------------------------------------------
325// Tests
326// ---------------------------------------------------------------------------
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    const MINIMAL_MANIFEST: &str = r#"
333default_owner: app_owner
334
335roles:
336  - name: analytics
337    login: true
338    comment: "Analytics read-only role"
339
340grants:
341  - role: analytics
342    privileges: [CONNECT]
343    object: { type: database, name: mydb }
344"#;
345
346    const PROFILE_MANIFEST: &str = r#"
347default_owner: app_owner
348
349profiles:
350  editor:
351    grants:
352      - privileges: [USAGE]
353        object: { type: schema }
354      - privileges: [SELECT, INSERT, UPDATE, DELETE]
355        object: { type: table, name: "*" }
356    default_privileges:
357      - privileges: [SELECT, INSERT, UPDATE, DELETE]
358        on_type: table
359  viewer:
360    grants:
361      - privileges: [USAGE]
362        object: { type: schema }
363      - privileges: [SELECT]
364        object: { type: table, name: "*" }
365    default_privileges:
366      - privileges: [SELECT]
367        on_type: table
368
369schemas:
370  - name: inventory
371    profiles: [editor, viewer]
372  - name: catalog
373    profiles: [viewer]
374
375roles:
376  - name: app-service
377    login: true
378
379grants:
380  - role: app-service
381    privileges: [CONNECT]
382    object: { type: database, name: mydb }
383
384memberships:
385  - role: inventory-editor
386    members:
387      - name: app-service
388"#;
389
390    const INVALID_YAML: &str = r#"
391this is: [not: valid yaml: [[
392"#;
393
394    const UNDEFINED_PROFILE: &str = r#"
395profiles:
396  editor:
397    grants: []
398
399schemas:
400  - name: myschema
401    profiles: [nonexistent]
402"#;
403
404    // -----------------------------------------------------------------------
405    // parse
406    // -----------------------------------------------------------------------
407
408    #[test]
409    fn parse_valid_manifest() {
410        let result = parse(MINIMAL_MANIFEST);
411        assert!(result.is_ok());
412        let manifest = result.unwrap();
413        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
414        assert_eq!(manifest.roles.len(), 1);
415        assert_eq!(manifest.roles[0].name, "analytics");
416    }
417
418    #[test]
419    fn parse_invalid_yaml() {
420        let result = parse(INVALID_YAML);
421        assert!(result.is_err());
422        let err_msg = result.unwrap_err().to_string();
423        assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
424    }
425
426    // -----------------------------------------------------------------------
427    // parse_and_expand
428    // -----------------------------------------------------------------------
429
430    #[test]
431    fn expand_profile_manifest() {
432        let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
433
434        // inventory-editor, inventory-viewer, catalog-viewer, app-service
435        assert_eq!(expanded.roles.len(), 4);
436
437        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
438        assert!(role_names.contains(&"inventory-editor"));
439        assert!(role_names.contains(&"inventory-viewer"));
440        assert!(role_names.contains(&"catalog-viewer"));
441        assert!(role_names.contains(&"app-service"));
442    }
443
444    #[test]
445    fn expand_undefined_profile_fails() {
446        let result = parse_and_expand(UNDEFINED_PROFILE);
447        assert!(result.is_err());
448        let err_msg = result.unwrap_err().to_string();
449        assert!(
450            err_msg.contains("nonexistent"),
451            "expected error about 'nonexistent' profile, got: {err_msg}"
452        );
453    }
454
455    // -----------------------------------------------------------------------
456    // validate_manifest
457    // -----------------------------------------------------------------------
458
459    #[test]
460    fn validate_builds_role_graph() {
461        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
462
463        // Check the desired graph has the expected roles
464        assert_eq!(validated.desired.roles.len(), 4);
465        assert!(validated.desired.roles.contains_key("inventory-editor"));
466        assert!(validated.desired.roles.contains_key("app-service"));
467
468        // Check grants were expanded
469        assert!(!validated.desired.grants.is_empty());
470
471        // Check memberships
472        assert!(!validated.desired.memberships.is_empty());
473    }
474
475    // -----------------------------------------------------------------------
476    // compute_plan + format
477    // -----------------------------------------------------------------------
478
479    #[test]
480    fn plan_from_empty_creates_roles() {
481        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
482        let current = RoleGraph::default(); // empty database
483
484        let changes = compute_plan(&current, &validated.desired);
485        assert!(!changes.is_empty());
486
487        let summary = PlanSummary::from_changes(&changes);
488        assert_eq!(summary.roles_created, 4); // inventory-editor, inventory-viewer, catalog-viewer, app-service
489        assert!(summary.grants > 0);
490        assert!(!summary.is_empty());
491    }
492
493    #[test]
494    fn plan_no_changes_when_in_sync() {
495        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
496        // Simulate a DB that already has the desired state
497        let current = validated.desired.clone();
498
499        let changes = compute_plan(&current, &validated.desired);
500        let summary = PlanSummary::from_changes(&changes);
501        assert!(summary.is_empty());
502        assert_eq!(summary.total(), 0);
503    }
504
505    #[test]
506    fn format_plan_sql_produces_sql() {
507        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
508        let current = RoleGraph::default();
509        let changes = compute_plan(&current, &validated.desired);
510
511        let sql_output = format_plan_sql(&changes);
512        assert!(
513            sql_output.contains("CREATE ROLE"),
514            "expected CREATE ROLE in: {sql_output}"
515        );
516        assert!(
517            sql_output.contains("\"analytics\""),
518            "expected quoted role name in: {sql_output}"
519        );
520    }
521
522    #[test]
523    fn planned_role_drops_only_returns_drop_changes() {
524        let changes = vec![
525            Change::CreateRole {
526                name: "new-role".to_string(),
527                state: pgroles_core::model::RoleState::default(),
528            },
529            Change::DropRole {
530                name: "old-role".to_string(),
531            },
532            Change::DropRole {
533                name: "stale-role".to_string(),
534            },
535        ];
536
537        assert_eq!(
538            planned_role_drops(&changes),
539            vec!["old-role".to_string(), "stale-role".to_string()]
540        );
541    }
542
543    #[test]
544    fn apply_role_retirements_updates_plan_summary() {
545        let changes = apply_role_retirements(
546            vec![Change::DropRole {
547                name: "legacy-app".to_string(),
548            }],
549            &[pgroles_core::manifest::RoleRetirement {
550                role: "legacy-app".to_string(),
551                reassign_owned_to: Some("app-owner".to_string()),
552                drop_owned: true,
553                terminate_sessions: true,
554            }],
555        );
556
557        let summary = PlanSummary::from_changes(&changes);
558        assert_eq!(summary.roles_dropped, 1);
559        assert_eq!(summary.sessions_terminated, 1);
560        assert_eq!(summary.ownerships_reassigned, 1);
561        assert_eq!(summary.owned_objects_dropped, 1);
562        assert_eq!(summary.total(), 4);
563    }
564
565    // -----------------------------------------------------------------------
566    // PlanSummary display
567    // -----------------------------------------------------------------------
568
569    #[test]
570    fn plan_summary_display_empty() {
571        let summary = PlanSummary::default();
572        let display = summary.to_string();
573        assert!(display.contains("No changes needed"));
574    }
575
576    #[test]
577    fn plan_summary_display_with_changes() {
578        let summary = PlanSummary {
579            roles_created: 2,
580            grants: 5,
581            members_added: 1,
582            ..Default::default()
583        };
584        let display = summary.to_string();
585        assert!(display.contains("8 change(s)"), "got: {display}");
586        assert!(display.contains("2 role(s) to create"), "got: {display}");
587        assert!(display.contains("5 grant(s) to add"), "got: {display}");
588        assert!(display.contains("1 membership(s) to add"), "got: {display}");
589        // Should not mention zero-count items
590        assert!(!display.contains("to drop"), "got: {display}");
591        assert!(!display.contains("to revoke"), "got: {display}");
592    }
593
594    // -----------------------------------------------------------------------
595    // format_validation_result
596    // -----------------------------------------------------------------------
597
598    #[test]
599    fn validation_result_shows_counts() {
600        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
601        let output = format_validation_result(&validated);
602        assert!(output.contains("Manifest is valid"), "got: {output}");
603        assert!(output.contains("4 role(s)"), "got: {output}");
604    }
605
606    // -----------------------------------------------------------------------
607    // read_manifest_file
608    // -----------------------------------------------------------------------
609
610    #[test]
611    fn read_nonexistent_file_fails() {
612        let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
613        assert!(result.is_err());
614        let err_msg = format!("{:#}", result.unwrap_err());
615        assert!(
616            err_msg.contains("failed to read manifest file"),
617            "got: {err_msg}"
618        );
619    }
620
621    // -----------------------------------------------------------------------
622    // format_role_graph_summary
623    // -----------------------------------------------------------------------
624
625    #[test]
626    fn role_graph_summary_format() {
627        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
628        let summary = format_role_graph_summary(&validated.desired);
629        assert!(summary.contains("Roles: 1"), "got: {summary}");
630        assert!(summary.contains("analytics (LOGIN)"), "got: {summary}");
631    }
632
633    // -----------------------------------------------------------------------
634    // has_structural_changes — password-only drift detection
635    // -----------------------------------------------------------------------
636
637    #[test]
638    fn has_structural_changes_true_for_non_password_changes() {
639        let summary = PlanSummary {
640            roles_created: 1,
641            grants: 2,
642            ..Default::default()
643        };
644        assert!(summary.has_structural_changes());
645    }
646
647    #[test]
648    fn has_structural_changes_false_for_password_only() {
649        let summary = PlanSummary {
650            passwords_set: 3,
651            ..Default::default()
652        };
653        assert!(
654            !summary.has_structural_changes(),
655            "password-only plan should NOT be considered structural drift"
656        );
657    }
658
659    #[test]
660    fn has_structural_changes_true_for_mixed() {
661        let summary = PlanSummary {
662            roles_created: 1,
663            passwords_set: 2,
664            ..Default::default()
665        };
666        assert!(
667            summary.has_structural_changes(),
668            "mixed plan with structural + password changes IS structural drift"
669        );
670    }
671
672    #[test]
673    fn has_structural_changes_false_for_empty() {
674        let summary = PlanSummary::default();
675        assert!(!summary.has_structural_changes());
676    }
677
678    #[test]
679    fn plan_summary_displays_password_count() {
680        let summary = PlanSummary {
681            passwords_set: 2,
682            roles_created: 1,
683            ..Default::default()
684        };
685        let display = summary.to_string();
686        assert!(display.contains("2 password(s) to set"), "got: {display}");
687        assert!(display.contains("3 change(s)"), "got: {display}");
688    }
689
690    // -----------------------------------------------------------------------
691    // ReconciliationMode integration through compute_plan + filter
692    // -----------------------------------------------------------------------
693
694    #[test]
695    fn additive_mode_filters_revokes_from_plan() {
696        use pgroles_core::diff::{ReconciliationMode, filter_changes};
697        use pgroles_core::model::RoleState;
698
699        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
700
701        let mut current = validated.desired.clone();
702        current
703            .roles
704            .insert("stale-role".to_string(), RoleState::default());
705
706        let changes = compute_plan(&current, &validated.desired);
707        assert!(changes.iter().any(|c| matches!(
708            c,
709            pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
710        )));
711
712        let filtered = filter_changes(changes, ReconciliationMode::Additive);
713        assert!(
714            !filtered
715                .iter()
716                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
717            "additive mode should filter out DropRole"
718        );
719    }
720
721    #[test]
722    fn adopt_mode_filters_drops_but_keeps_revokes() {
723        use pgroles_core::diff::{ReconciliationMode, filter_changes};
724        use pgroles_core::manifest::{ObjectType, Privilege};
725        use pgroles_core::model::{GrantKey, GrantState, RoleState};
726        use std::collections::BTreeSet;
727
728        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
729
730        let mut current = validated.desired.clone();
731        current
732            .roles
733            .insert("stale-role".to_string(), RoleState::default());
734        current.grants.insert(
735            GrantKey {
736                role: "analytics".to_string(),
737                object_type: ObjectType::Table,
738                schema: Some("public".to_string()),
739                name: Some("*".to_string()),
740            },
741            GrantState {
742                privileges: BTreeSet::from([Privilege::Select]),
743            },
744        );
745
746        let changes = compute_plan(&current, &validated.desired);
747
748        let filtered = filter_changes(changes, ReconciliationMode::Adopt);
749        assert!(
750            !filtered
751                .iter()
752                .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
753            "adopt mode should filter out DropRole"
754        );
755        assert!(
756            filtered
757                .iter()
758                .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
759            "adopt mode should keep Revoke changes"
760        );
761    }
762    // -----------------------------------------------------------------------
763    // format_plan_json
764    // -----------------------------------------------------------------------
765
766    #[test]
767    fn plan_json_produces_valid_json() {
768        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
769        let current = RoleGraph::default();
770        let changes = compute_plan(&current, &validated.desired);
771
772        let json_output = format_plan_json(&changes).unwrap();
773        // Should be parseable JSON
774        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
775        assert!(parsed.is_array());
776        // Should contain CreateRole
777        let text = json_output.to_string();
778        assert!(text.contains("CreateRole"), "got: {text}");
779        assert!(text.contains("analytics"), "got: {text}");
780    }
781
782    #[test]
783    fn format_plan_json_redacts_passwords() {
784        let changes = vec![Change::SetPassword {
785            name: "app-svc".to_string(),
786            password: "super-secret".to_string(),
787        }];
788
789        let json = format_plan_json(&changes).expect("json formatting should succeed");
790        assert!(json.contains("[REDACTED]"), "got: {json}");
791        assert!(!json.contains("super-secret"), "got: {json}");
792    }
793
794    #[test]
795    fn format_plan_sql_redacts_passwords() {
796        let changes = vec![Change::SetPassword {
797            name: "app-svc".to_string(),
798            password: "super-secret".to_string(),
799        }];
800
801        let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
802        assert!(sql.contains("[REDACTED]"), "got: {sql}");
803        assert!(!sql.contains("super-secret"), "got: {sql}");
804    }
805
806    #[test]
807    fn format_applied_uses_applied_header() {
808        let summary = PlanSummary {
809            roles_created: 1,
810            grants: 2,
811            ..Default::default()
812        };
813
814        let display = summary.format_applied();
815        assert!(
816            display.starts_with("Applied: 3 change(s)\n"),
817            "got: {display}"
818        );
819        assert!(display.contains("1 role(s) to create"), "got: {display}");
820        assert!(display.contains("2 grant(s) to add"), "got: {display}");
821        assert!(!display.contains("Plan:"), "got: {display}");
822    }
823}