1use crate::eval::stable_hash_hex;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct ProjectPolicy {
14 pub schema_version: String,
15 pub path: String,
16 pub hash: String,
17 pub rules: Vec<PolicyRule>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
21pub struct PolicyRule {
22 pub id: String,
23 pub line: usize,
24 pub category: PolicyCategory,
25 pub severity: PolicySeverity,
26 pub text: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
30pub enum PolicyCategory {
31 PanicSafety,
32 ErrorContext,
33 InputValidation,
34 ProcessExecution,
35 Environment,
36 UnsafeCode,
37 General,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
41pub enum PolicySeverity {
42 Low,
43 Medium,
44 High,
45}
46
47pub fn load_project_policy(
48 root: &Path,
49 policy_path: Option<&Path>,
50) -> anyhow::Result<Option<ProjectPolicy>> {
51 let Some(path) = resolve_policy_path(root, policy_path) else {
52 return Ok(None);
53 };
54 let content = std::fs::read(&path)?;
55 let text = String::from_utf8_lossy(&content);
56 Ok(Some(ProjectPolicy {
57 schema_version: "0.4".to_string(),
58 path: path.display().to_string(),
59 hash: stable_hash_hex(&content),
60 rules: parse_policy_rules(&text),
61 }))
62}
63
64fn resolve_policy_path(root: &Path, policy_path: Option<&Path>) -> Option<PathBuf> {
65 if let Some(policy_path) = policy_path {
66 return Some(if policy_path.is_absolute() {
67 policy_path.to_path_buf()
68 } else {
69 root.join(policy_path)
70 });
71 }
72
73 let default = root.join(".mdx-rust/policies.md");
74 default.exists().then_some(default)
75}
76
77fn parse_policy_rules(content: &str) -> Vec<PolicyRule> {
78 content
79 .lines()
80 .enumerate()
81 .filter_map(|(index, line)| {
82 let text = extract_rule_text(line)?;
83 let category = categorize_rule(&text);
84 let severity = severity_for_category(&category);
85 Some(PolicyRule {
86 id: format!("policy-rule-{}", index + 1),
87 line: index + 1,
88 category,
89 severity,
90 text,
91 })
92 })
93 .collect()
94}
95
96fn extract_rule_text(line: &str) -> Option<String> {
97 let trimmed = line.trim();
98 if trimmed.starts_with("- ") {
99 return normalize_rule_text(trimmed.trim_start_matches("- "));
100 }
101
102 let (prefix, rest) = trimmed.split_once(['.', ')'])?;
103 if prefix.chars().all(|ch| ch.is_ascii_digit()) {
104 let text = rest.trim();
105 if !text.is_empty() {
106 return normalize_rule_text(text);
107 }
108 }
109
110 None
111}
112
113fn normalize_rule_text(text: &str) -> Option<String> {
114 let text = text.trim();
115 if text.is_empty() || text.contains("...") {
116 return None;
117 }
118 Some(text.to_string())
119}
120
121fn categorize_rule(text: &str) -> PolicyCategory {
122 let lower = text.to_ascii_lowercase();
123 if lower.contains("unwrap") || lower.contains("expect") || lower.contains("panic") {
124 PolicyCategory::PanicSafety
125 } else if lower.contains("context") || lower.contains("error") || lower.contains("failure") {
126 PolicyCategory::ErrorContext
127 } else if lower.contains("input")
128 || lower.contains("validate")
129 || lower.contains("validation")
130 || lower.contains("request")
131 {
132 PolicyCategory::InputValidation
133 } else if lower.contains("command") || lower.contains("process") || lower.contains("shell") {
134 PolicyCategory::ProcessExecution
135 } else if lower.contains("environment") || lower.contains("env var") || lower.contains("env") {
136 PolicyCategory::Environment
137 } else if lower.contains("unsafe") {
138 PolicyCategory::UnsafeCode
139 } else {
140 PolicyCategory::General
141 }
142}
143
144fn severity_for_category(category: &PolicyCategory) -> PolicySeverity {
145 match category {
146 PolicyCategory::ProcessExecution | PolicyCategory::UnsafeCode => PolicySeverity::High,
147 PolicyCategory::PanicSafety
148 | PolicyCategory::ErrorContext
149 | PolicyCategory::InputValidation
150 | PolicyCategory::Environment => PolicySeverity::Medium,
151 PolicyCategory::General => PolicySeverity::Low,
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use tempfile::tempdir;
159
160 #[test]
161 fn policy_parser_extracts_structured_rules() {
162 let policy = parse_policy_rules(
163 r#"# Policy
164
1651. Avoid unwrap in request handlers.
166- Validate external input before use.
167- Command execution must be allowlisted.
168"#,
169 );
170
171 assert_eq!(policy.len(), 3);
172 assert_eq!(policy[0].category, PolicyCategory::PanicSafety);
173 assert_eq!(policy[1].category, PolicyCategory::InputValidation);
174 assert_eq!(policy[2].severity, PolicySeverity::High);
175 }
176
177 #[test]
178 fn load_project_policy_uses_default_policy_path() {
179 let dir = tempdir().unwrap();
180 let policy_dir = dir.path().join(".mdx-rust");
181 std::fs::create_dir_all(&policy_dir).unwrap();
182 std::fs::write(policy_dir.join("policies.md"), "- Preserve error context.").unwrap();
183
184 let policy = load_project_policy(dir.path(), None).unwrap().unwrap();
185
186 assert_eq!(policy.schema_version, "0.4");
187 assert_eq!(policy.rules[0].category, PolicyCategory::ErrorContext);
188 }
189
190 #[test]
191 fn policy_parser_ignores_placeholders() {
192 let policy = parse_policy_rules(
193 r#"- ...
1941. Never ...
1952. Validate external inputs.
196"#,
197 );
198
199 assert_eq!(policy.len(), 1);
200 assert_eq!(policy[0].category, PolicyCategory::InputValidation);
201 }
202}