Skip to main content

pmcp_code_mode/
policy_annotations.rs

1//! Policy annotation parser for Cedar policies.
2//!
3//! This module parses rustdoc-style annotations from Cedar policy comments
4//! to extract metadata for UI display and policy management.
5//!
6//! ## Annotation Format
7//!
8//! ```cedar
9//! /// @title Allow Write Operations
10//! /// @description Permits adding and updating states in the policy database.
11//! /// These operations are considered safe for automated execution.
12//! /// @category write
13//! /// @risk medium
14//! /// @editable true
15//! permit(
16//!   principal,
17//!   action == Action::"executeMutation",
18//!   resource
19//! ) when {
20//!   resource.mutation in ["addState", "updateState"]
21//! };
22//! ```
23//!
24//! ## Supported Annotations
25//!
26//! | Annotation | Required | Description |
27//! |------------|----------|-------------|
28//! | `@title` | Yes | Short display name for the policy |
29//! | `@description` | Yes | Multi-line description (continuation lines without @) |
30//! | `@category` | Yes | One of: read, write, delete, fields, admin |
31//! | `@risk` | Yes | One of: low, medium, high, critical |
32//! | `@editable` | No | Whether admins can modify (default: true) |
33//! | `@reason` | No | Why the policy exists or is non-editable |
34//! | `@author` | No | Who created or last modified |
35//! | `@modified` | No | ISO date of last modification |
36//!
37//! ## Category Mapping
38//!
39//! The unified categories work across all server types:
40//! - `read`: Queries (GraphQL), GET (OpenAPI), SELECT (SQL)
41//! - `write`: Create/update mutations, POST/PUT/PATCH, INSERT/UPDATE
42//! - `delete`: Delete mutations, DELETE, DELETE/TRUNCATE
43//! - `admin`: Introspection, schema access, DDL
44//! - `fields`: Field-level access control
45//!
46//! Legacy category names are still supported for parsing: `queries` → `read`,
47//! `mutations` → `write`, `introspection` → `admin`.
48
49use serde::{Deserialize, Serialize};
50use std::fmt;
51use std::str::FromStr;
52
53/// Unified policy category for grouping in the UI.
54/// Works consistently across GraphQL, OpenAPI, and SQL servers.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
56#[serde(rename_all = "lowercase")]
57pub enum PolicyCategory {
58    /// Read operations (Query, GET, SELECT)
59    #[default]
60    Read,
61    /// Write operations (create/update mutations, POST/PUT/PATCH, INSERT/UPDATE)
62    Write,
63    /// Delete operations (delete mutations, DELETE, DELETE/TRUNCATE)
64    Delete,
65    /// Field-level access policies
66    Fields,
67    /// Administrative operations (introspection, DDL, schema changes)
68    Admin,
69}
70
71impl fmt::Display for PolicyCategory {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            PolicyCategory::Read => write!(f, "read"),
75            PolicyCategory::Write => write!(f, "write"),
76            PolicyCategory::Delete => write!(f, "delete"),
77            PolicyCategory::Fields => write!(f, "fields"),
78            PolicyCategory::Admin => write!(f, "admin"),
79        }
80    }
81}
82
83impl FromStr for PolicyCategory {
84    type Err = ();
85
86    fn from_str(s: &str) -> Result<Self, Self::Err> {
87        match s.to_lowercase().as_str() {
88            // Unified names
89            "read" | "reads" => Ok(PolicyCategory::Read),
90            "write" | "writes" => Ok(PolicyCategory::Write),
91            "delete" | "deletes" => Ok(PolicyCategory::Delete),
92            "fields" | "field" | "paths" => Ok(PolicyCategory::Fields),
93            "admin" | "safety" | "limits" => Ok(PolicyCategory::Admin),
94            // Legacy GraphQL names (backward compatibility)
95            "queries" | "query" => Ok(PolicyCategory::Read),
96            "mutations" | "mutation" => Ok(PolicyCategory::Write),
97            "introspection" => Ok(PolicyCategory::Admin),
98            _ => Err(()),
99        }
100    }
101}
102
103/// Risk level for visual indication in the UI.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
105#[serde(rename_all = "lowercase")]
106pub enum PolicyRiskLevel {
107    /// Low risk - typically read-only operations
108    #[default]
109    Low,
110    /// Medium risk - safe mutations
111    Medium,
112    /// High risk - sensitive operations
113    High,
114    /// Critical risk - destructive or admin operations
115    Critical,
116}
117
118impl fmt::Display for PolicyRiskLevel {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        match self {
121            PolicyRiskLevel::Low => write!(f, "low"),
122            PolicyRiskLevel::Medium => write!(f, "medium"),
123            PolicyRiskLevel::High => write!(f, "high"),
124            PolicyRiskLevel::Critical => write!(f, "critical"),
125        }
126    }
127}
128
129impl FromStr for PolicyRiskLevel {
130    type Err = ();
131
132    fn from_str(s: &str) -> Result<Self, Self::Err> {
133        match s.to_lowercase().as_str() {
134            "low" => Ok(PolicyRiskLevel::Low),
135            "medium" => Ok(PolicyRiskLevel::Medium),
136            "high" => Ok(PolicyRiskLevel::High),
137            "critical" => Ok(PolicyRiskLevel::Critical),
138            _ => Err(()),
139        }
140    }
141}
142
143/// Parsed policy metadata from Cedar doc comments.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct PolicyMetadata {
146    /// AVP policy ID
147    pub id: String,
148
149    /// Short display name (@title)
150    pub title: String,
151
152    /// Longer description (@description), may be multi-line
153    pub description: String,
154
155    /// Policy category for grouping (@category)
156    pub category: PolicyCategory,
157
158    /// Risk level for visual indication (@risk)
159    pub risk: PolicyRiskLevel,
160
161    /// Whether administrators can modify this policy (@editable)
162    pub editable: bool,
163
164    /// Reason for the policy or why it's non-editable (@reason)
165    pub reason: Option<String>,
166
167    /// Who created or last modified (@author)
168    pub author: Option<String>,
169
170    /// ISO date of last modification (@modified)
171    pub modified: Option<String>,
172
173    /// The full Cedar policy text
174    pub raw_cedar: String,
175
176    /// Whether this is a baseline policy (cannot be deleted)
177    pub is_baseline: bool,
178
179    /// Policy template ID (for template-linked policies)
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub template_id: Option<String>,
182}
183
184/// Infer category and risk from Cedar policy content when annotations are missing.
185///
186/// This analyzes the Cedar action clause to determine:
187/// - Category: based on `CodeMode::Action::"Read"/"Write"/"Delete"`
188/// - Risk: based on policy effect (permit vs forbid) and action type
189pub fn infer_category_and_risk_from_cedar(cedar: &str) -> (PolicyCategory, PolicyRiskLevel) {
190    let cedar_lower = cedar.to_lowercase();
191
192    // Determine category from action clause
193    let category = if cedar.contains("Action::\"Delete\"") || cedar.contains("Action::\"delete\"") {
194        PolicyCategory::Delete
195    } else if cedar.contains("Action::\"Write\"") || cedar.contains("Action::\"write\"") {
196        PolicyCategory::Write
197    } else if cedar.contains("Action::\"Read\"") || cedar.contains("Action::\"read\"") {
198        PolicyCategory::Read
199    } else if cedar.contains("Action::\"Admin\"")
200        || cedar.contains("Action::\"admin\"")
201        || cedar.contains("Action::\"Introspection\"")
202    {
203        PolicyCategory::Admin
204    } else {
205        // Check for generic patterns
206        if cedar_lower.contains("delete") {
207            PolicyCategory::Delete
208        } else if cedar_lower.contains("write") || cedar_lower.contains("mutation") {
209            PolicyCategory::Write
210        } else {
211            PolicyCategory::Read
212        }
213    };
214
215    // Determine risk based on effect and category
216    let _is_forbid = cedar_lower.trim_start().starts_with("forbid");
217    let is_permit = cedar_lower.trim_start().starts_with("permit");
218
219    let risk = match category {
220        PolicyCategory::Delete => {
221            if is_permit {
222                PolicyRiskLevel::High // Permitting deletes is high risk
223            } else {
224                PolicyRiskLevel::Low // Forbidding deletes is protective
225            }
226        },
227        PolicyCategory::Write => {
228            if is_permit {
229                PolicyRiskLevel::Medium // Permitting writes is medium risk
230            } else {
231                PolicyRiskLevel::Low // Forbidding writes is protective
232            }
233        },
234        PolicyCategory::Admin => {
235            if is_permit {
236                PolicyRiskLevel::High // Permitting admin is high risk
237            } else {
238                PolicyRiskLevel::Medium // Forbidding admin is protective but important
239            }
240        },
241        PolicyCategory::Read => PolicyRiskLevel::Low, // Reads are generally low risk
242        PolicyCategory::Fields => PolicyRiskLevel::Medium, // Field restrictions are medium
243    };
244
245    (category, risk)
246}
247
248impl Default for PolicyMetadata {
249    fn default() -> Self {
250        Self {
251            id: String::new(),
252            title: String::new(),
253            description: String::new(),
254            category: PolicyCategory::default(),
255            risk: PolicyRiskLevel::default(),
256            editable: true,
257            reason: None,
258            author: None,
259            modified: None,
260            raw_cedar: String::new(),
261            is_baseline: false,
262            template_id: None,
263        }
264    }
265}
266
267impl PolicyMetadata {
268    /// Create a new PolicyMetadata with the given ID and Cedar content.
269    pub fn new(id: impl Into<String>, cedar: impl Into<String>) -> Self {
270        let cedar = cedar.into();
271        let mut metadata = parse_policy_annotations(&cedar, &id.into());
272        metadata.raw_cedar = cedar;
273        metadata
274    }
275
276    /// Check if this policy has all required annotations.
277    pub fn validate(&self) -> Result<(), Vec<PolicyValidationError>> {
278        let mut errors = Vec::new();
279
280        if self.title.is_empty() {
281            errors.push(PolicyValidationError::MissingAnnotation(
282                "@title".to_string(),
283            ));
284        }
285
286        if self.description.is_empty() {
287            errors.push(PolicyValidationError::MissingAnnotation(
288                "@description".to_string(),
289            ));
290        }
291
292        // Category and risk have defaults, so we check if they were explicitly set
293        // by checking if the raw Cedar contains the annotations
294        if !self.raw_cedar.contains("@category") {
295            errors.push(PolicyValidationError::MissingAnnotation(
296                "@category".to_string(),
297            ));
298        }
299
300        if !self.raw_cedar.contains("@risk") {
301            errors.push(PolicyValidationError::MissingAnnotation(
302                "@risk".to_string(),
303            ));
304        }
305
306        if errors.is_empty() {
307            Ok(())
308        } else {
309            Err(errors)
310        }
311    }
312}
313
314/// Validation error for policy annotations.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub enum PolicyValidationError {
317    /// A required annotation is missing
318    MissingAnnotation(String),
319    /// An annotation has an invalid value
320    InvalidAnnotation { annotation: String, message: String },
321    /// Cedar syntax error
322    CedarSyntaxError { line: Option<u32>, message: String },
323}
324
325impl fmt::Display for PolicyValidationError {
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        match self {
328            PolicyValidationError::MissingAnnotation(ann) => {
329                write!(f, "Missing required annotation: {}", ann)
330            },
331            PolicyValidationError::InvalidAnnotation {
332                annotation,
333                message,
334            } => {
335                write!(f, "Invalid {}: {}", annotation, message)
336            },
337            PolicyValidationError::CedarSyntaxError { line, message } => {
338                if let Some(line) = line {
339                    write!(f, "Cedar syntax error at line {}: {}", line, message)
340                } else {
341                    write!(f, "Cedar syntax error: {}", message)
342                }
343            },
344        }
345    }
346}
347
348/// Parse Cedar policy annotations from doc comments.
349///
350/// # Example
351///
352/// ```ignore
353/// use pmcp_code_mode::policy_annotations::parse_policy_annotations;
354///
355/// let cedar = r#"
356/// /// @title Allow Queries
357/// /// @description Permits all read-only queries.
358/// /// @category queries
359/// /// @risk low
360/// permit(principal, action, resource);
361/// "#;
362///
363/// let metadata = parse_policy_annotations(cedar, "policy-123");
364/// assert_eq!(metadata.title, "Allow Queries");
365/// assert_eq!(metadata.description, "Permits all read-only queries.");
366/// ```
367pub fn parse_policy_annotations(cedar: &str, policy_id: &str) -> PolicyMetadata {
368    let mut metadata = PolicyMetadata {
369        id: policy_id.to_string(),
370        raw_cedar: cedar.to_string(),
371        ..Default::default()
372    };
373
374    let mut in_description = false;
375    let mut found_category = false;
376    let mut found_risk = false;
377
378    for line in cedar.lines() {
379        let line = line.trim();
380        in_description = process_annotation_line(
381            line,
382            &mut metadata,
383            in_description,
384            &mut found_category,
385            &mut found_risk,
386        );
387    }
388
389    metadata.description = metadata.description.trim().to_string();
390    apply_inferred_category_and_risk(&mut metadata, cedar, found_category, found_risk);
391    metadata
392}
393
394/// Process a single trimmed source line for `parse_policy_annotations`. Returns the
395/// updated `in_description` flag (true iff we are still inside an `@description` block).
396fn process_annotation_line(
397    line: &str,
398    metadata: &mut PolicyMetadata,
399    in_description: bool,
400    found_category: &mut bool,
401    found_risk: &mut bool,
402) -> bool {
403    if let Some(content) = line.strip_prefix("/// @") {
404        return apply_at_annotation(content, metadata, found_category, found_risk);
405    }
406    if let Some(content) = line.strip_prefix("/// ") {
407        if in_description {
408            append_description_line(metadata, content);
409        }
410        return in_description;
411    }
412    if line == "///" {
413        if in_description {
414            metadata.description.push_str("\n\n");
415        }
416        return in_description;
417    }
418    // Non-comment line — stop parsing description.
419    false
420}
421
422/// Apply a single `/// @key value` annotation. Returns the new `in_description` state
423/// (true only when the annotation is `@description`).
424fn apply_at_annotation(
425    content: &str,
426    metadata: &mut PolicyMetadata,
427    found_category: &mut bool,
428    found_risk: &mut bool,
429) -> bool {
430    let Some((key, value)) = content.split_once(' ') else {
431        return false;
432    };
433    let value = value.trim();
434    match key.to_lowercase().as_str() {
435        "title" => {
436            apply_title(metadata, value);
437            false
438        },
439        "description" => {
440            metadata.description = value.to_string();
441            true
442        },
443        "category" => {
444            metadata.category = value.parse().unwrap_or_default();
445            *found_category = true;
446            false
447        },
448        "risk" => {
449            metadata.risk = value.parse().unwrap_or_default();
450            *found_risk = true;
451            false
452        },
453        "editable" => {
454            metadata.editable = value.eq_ignore_ascii_case("true");
455            false
456        },
457        "reason" => {
458            metadata.reason = Some(value.to_string());
459            false
460        },
461        "author" => {
462            metadata.author = Some(value.to_string());
463            false
464        },
465        "modified" => {
466            metadata.modified = Some(value.to_string());
467            false
468        },
469        _ => false, // Unknown annotation, ignore
470    }
471}
472
473/// Apply the `@title` annotation. `Baseline:` titles flag the policy as immutable.
474fn apply_title(metadata: &mut PolicyMetadata, value: &str) {
475    metadata.title = value.to_string();
476    if value.starts_with("Baseline:") {
477        metadata.is_baseline = true;
478        metadata.editable = false;
479    }
480}
481
482/// Append a continuation line to the in-progress `@description`.
483fn append_description_line(metadata: &mut PolicyMetadata, content: &str) {
484    if !metadata.description.is_empty() {
485        metadata.description.push('\n');
486    }
487    metadata.description.push_str(content);
488}
489
490/// If `@category` and/or `@risk` annotations were absent, infer them from the policy body.
491fn apply_inferred_category_and_risk(
492    metadata: &mut PolicyMetadata,
493    cedar: &str,
494    found_category: bool,
495    found_risk: bool,
496) {
497    if found_category && found_risk {
498        return;
499    }
500    let (inferred_category, inferred_risk) = infer_category_and_risk_from_cedar(cedar);
501    if !found_category {
502        metadata.category = inferred_category;
503    }
504    if !found_risk {
505        metadata.risk = inferred_risk;
506    }
507}
508
509/// Generate Cedar policy text with annotations from metadata.
510///
511/// This creates a properly formatted Cedar policy with doc comments.
512pub fn generate_policy_cedar(metadata: &PolicyMetadata, policy_body: &str) -> String {
513    let mut lines = Vec::new();
514
515    // Title
516    lines.push(format!("/// @title {}", metadata.title));
517
518    // Description (handle multi-line)
519    for (i, desc_line) in metadata.description.lines().enumerate() {
520        if i == 0 {
521            lines.push(format!("/// @description {}", desc_line));
522        } else if desc_line.is_empty() {
523            lines.push("///".to_string());
524        } else {
525            lines.push(format!("/// {}", desc_line));
526        }
527    }
528
529    // Category and risk
530    lines.push(format!("/// @category {}", metadata.category));
531    lines.push(format!("/// @risk {}", metadata.risk));
532
533    // Optional annotations
534    if !metadata.editable {
535        lines.push("/// @editable false".to_string());
536    }
537
538    if let Some(ref reason) = metadata.reason {
539        lines.push(format!("/// @reason {}", reason));
540    }
541
542    if let Some(ref author) = metadata.author {
543        lines.push(format!("/// @author {}", author));
544    }
545
546    if let Some(ref modified) = metadata.modified {
547        lines.push(format!("/// @modified {}", modified));
548    }
549
550    // Add the policy body
551    lines.push(policy_body.to_string());
552
553    lines.join("\n")
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_parse_simple_policy() {
562        let cedar = r#"/// @title Allow Queries
563/// @description Permits all read-only queries.
564/// @category read
565/// @risk low
566permit(principal, action, resource);"#;
567
568        let metadata = parse_policy_annotations(cedar, "policy-123");
569
570        assert_eq!(metadata.id, "policy-123");
571        assert_eq!(metadata.title, "Allow Queries");
572        assert_eq!(metadata.description, "Permits all read-only queries.");
573        assert_eq!(metadata.category, PolicyCategory::Read);
574        assert_eq!(metadata.risk, PolicyRiskLevel::Low);
575        assert!(metadata.editable);
576        assert!(!metadata.is_baseline);
577    }
578
579    #[test]
580    fn test_parse_legacy_category_names() {
581        // Test that legacy category names are parsed correctly
582        let cedar = r#"/// @title Allow Queries
583/// @description Permits all read-only queries.
584/// @category queries
585/// @risk low
586permit(principal, action, resource);"#;
587
588        let metadata = parse_policy_annotations(cedar, "policy-legacy");
589        assert_eq!(metadata.category, PolicyCategory::Read);
590
591        let cedar2 = r#"/// @title Block Mutations
592/// @description Blocks write operations.
593/// @category mutations
594/// @risk high
595forbid(principal, action, resource);"#;
596
597        let metadata2 = parse_policy_annotations(cedar2, "policy-legacy2");
598        assert_eq!(metadata2.category, PolicyCategory::Write);
599    }
600
601    #[test]
602    fn test_parse_multiline_description() {
603        let cedar = r#"/// @title Block Mutations
604/// @description Prevents execution of dangerous mutations.
605/// This is a critical security policy.
606///
607/// Do not modify without approval.
608/// @category write
609/// @risk critical
610/// @editable false
611/// @reason Security compliance
612forbid(principal, action, resource);"#;
613
614        let metadata = parse_policy_annotations(cedar, "policy-456");
615
616        assert_eq!(metadata.title, "Block Mutations");
617        assert!(metadata.description.contains("Prevents execution"));
618        assert!(metadata.description.contains("Do not modify"));
619        assert_eq!(metadata.category, PolicyCategory::Write);
620        assert_eq!(metadata.risk, PolicyRiskLevel::Critical);
621        assert!(!metadata.editable);
622        assert_eq!(metadata.reason, Some("Security compliance".to_string()));
623    }
624
625    #[test]
626    fn test_parse_baseline_policy() {
627        let cedar = r#"/// @title Baseline: Allow Read-Only Queries
628/// @description Core functionality for Code Mode.
629/// @category read
630/// @risk low
631permit(principal, action, resource);"#;
632
633        let metadata = parse_policy_annotations(cedar, "baseline-1");
634
635        assert!(metadata.is_baseline);
636        assert!(!metadata.editable);
637    }
638
639    #[test]
640    fn test_validate_missing_annotations() {
641        let metadata = PolicyMetadata {
642            id: "test".to_string(),
643            title: "".to_string(), // Missing
644            description: "Has description".to_string(),
645            raw_cedar: "permit(principal, action, resource);".to_string(),
646            ..Default::default()
647        };
648
649        let result = metadata.validate();
650        assert!(result.is_err());
651
652        let errors = result.unwrap_err();
653        assert!(errors.iter().any(|e| matches!(e,
654            PolicyValidationError::MissingAnnotation(s) if s == "@title"
655        )));
656    }
657
658    #[test]
659    fn test_generate_policy_cedar() {
660        let metadata = PolicyMetadata {
661            id: "test".to_string(),
662            title: "Allow Writes".to_string(),
663            description: "Permits safe write operations.\nAdd operations to the list.".to_string(),
664            category: PolicyCategory::Write,
665            risk: PolicyRiskLevel::Medium,
666            editable: true,
667            reason: None,
668            author: Some("admin".to_string()),
669            modified: Some("2024-01-15".to_string()),
670            raw_cedar: String::new(),
671            is_baseline: false,
672            template_id: None,
673        };
674
675        let body = r#"permit(
676  principal,
677  action == Action::"executeMutation",
678  resource
679);"#;
680
681        let cedar = generate_policy_cedar(&metadata, body);
682
683        assert!(cedar.contains("/// @title Allow Writes"));
684        assert!(cedar.contains("/// @description Permits safe write operations."));
685        assert!(cedar.contains("/// Add operations to the list."));
686        assert!(cedar.contains("/// @category write"));
687        assert!(cedar.contains("/// @risk medium"));
688        assert!(cedar.contains("/// @author admin"));
689        assert!(cedar.contains("/// @modified 2024-01-15"));
690        assert!(cedar.contains("permit("));
691    }
692
693    #[test]
694    fn test_policy_category_parsing() {
695        // Unified names (singular)
696        assert_eq!(
697            "read".parse::<PolicyCategory>().unwrap(),
698            PolicyCategory::Read
699        );
700        assert_eq!(
701            "write".parse::<PolicyCategory>().unwrap(),
702            PolicyCategory::Write
703        );
704        assert_eq!(
705            "delete".parse::<PolicyCategory>().unwrap(),
706            PolicyCategory::Delete
707        );
708        assert_eq!(
709            "FIELDS".parse::<PolicyCategory>().unwrap(),
710            PolicyCategory::Fields
711        );
712        assert_eq!(
713            "admin".parse::<PolicyCategory>().unwrap(),
714            PolicyCategory::Admin
715        );
716        // Unified names (plural - used by OpenAPI policies)
717        assert_eq!(
718            "reads".parse::<PolicyCategory>().unwrap(),
719            PolicyCategory::Read
720        );
721        assert_eq!(
722            "writes".parse::<PolicyCategory>().unwrap(),
723            PolicyCategory::Write
724        );
725        assert_eq!(
726            "deletes".parse::<PolicyCategory>().unwrap(),
727            PolicyCategory::Delete
728        );
729        // OpenAPI-specific categories
730        assert_eq!(
731            "paths".parse::<PolicyCategory>().unwrap(),
732            PolicyCategory::Fields
733        );
734        assert_eq!(
735            "safety".parse::<PolicyCategory>().unwrap(),
736            PolicyCategory::Admin
737        );
738        assert_eq!(
739            "limits".parse::<PolicyCategory>().unwrap(),
740            PolicyCategory::Admin
741        );
742        // Legacy GraphQL names map to unified
743        assert_eq!(
744            "queries".parse::<PolicyCategory>().unwrap(),
745            PolicyCategory::Read
746        );
747        assert_eq!(
748            "mutation".parse::<PolicyCategory>().unwrap(),
749            PolicyCategory::Write
750        );
751        assert_eq!(
752            "introspection".parse::<PolicyCategory>().unwrap(),
753            PolicyCategory::Admin
754        );
755        assert!("unknown".parse::<PolicyCategory>().is_err());
756    }
757
758    #[test]
759    fn test_policy_risk_parsing() {
760        assert_eq!(
761            "low".parse::<PolicyRiskLevel>().unwrap(),
762            PolicyRiskLevel::Low
763        );
764        assert_eq!(
765            "CRITICAL".parse::<PolicyRiskLevel>().unwrap(),
766            PolicyRiskLevel::Critical
767        );
768        assert!("unknown".parse::<PolicyRiskLevel>().is_err());
769    }
770}