agent_policy/model/
mod.rs1pub mod normalized;
4pub mod policy;
5pub mod targets;
6
7use crate::error::{Error, Result};
8use indexmap::IndexMap;
9use normalized::{Commands, Constraints, Paths, Policy, Project, Role};
10use policy::RawPolicy;
11use targets::OutputTargets;
12
13const VALID_TARGETS: &[&str] = &[
15 "agents-md",
16 "claude-md",
17 "cursor-rules",
18 "gemini-md",
19 "copilot-instructions",
20 "clinerules",
21 "windsurf-rules",
22 "copilot-instructions-scoped",
23 "junie-guidelines",
24];
25
26pub fn normalize(raw: RawPolicy) -> Result<(Policy, Vec<String>)> {
42 let mut roles: IndexMap<String, Role> = IndexMap::new();
44 if let Some(raw_roles) = raw.roles {
45 for (name, raw_role) in raw_roles {
46 validate_role_name(&name)?;
47 let editable = raw_role.editable.unwrap_or_default();
48 let forbidden = raw_role.forbidden.unwrap_or_default();
49 validate_globs(&editable)?;
50 validate_globs(&forbidden)?;
51 roles.insert(
52 name.clone(),
53 Role {
54 name,
55 editable,
56 forbidden,
57 },
58 );
59 }
60 }
61
62 let raw_paths = raw.paths.unwrap_or_default();
64 let editable = raw_paths.editable.unwrap_or_default();
65 let protected = raw_paths.protected.unwrap_or_default();
66 let generated = raw_paths.generated.unwrap_or_default();
67 validate_globs(&editable)?;
68 validate_globs(&protected)?;
69 validate_globs(&generated)?;
70
71 let raw_commands = raw.commands.unwrap_or_default();
72 let raw_constraints = raw.constraints.unwrap_or_default();
73
74 let enabled_targets: Vec<String> = raw.outputs.unwrap_or_else(|| vec!["agents-md".to_owned()]);
76
77 for id in &enabled_targets {
80 if !VALID_TARGETS.contains(&id.as_str()) {
81 return Err(Error::UnknownTarget { id: id.clone() });
82 }
83 }
84
85 let outputs = OutputTargets {
86 agents_md: enabled_targets.contains(&"agents-md".to_owned()),
87 claude_md: enabled_targets.contains(&"claude-md".to_owned()),
88 cursor_rules: enabled_targets.contains(&"cursor-rules".to_owned()),
89 gemini_md: enabled_targets.contains(&"gemini-md".to_owned()),
90 copilot_instructions: enabled_targets.contains(&"copilot-instructions".to_owned()),
91 clinerules: enabled_targets.contains(&"clinerules".to_owned()),
92 windsurf_rules: enabled_targets.contains(&"windsurf-rules".to_owned()),
93 copilot_instructions_scoped: enabled_targets
94 .contains(&"copilot-instructions-scoped".to_owned()),
95 junie_guidelines: enabled_targets.contains(&"junie-guidelines".to_owned()),
96 };
97
98 if outputs.is_empty() {
99 return Err(Error::NoOutputs);
100 }
101
102 let auto_globs: Vec<String> = outputs
104 .enabled()
105 .iter()
106 .map(|t| t.generated_glob().to_owned())
107 .collect();
108
109 let mut warnings: Vec<String> = Vec::new();
111 for entry in &generated {
112 if auto_globs.contains(entry) {
113 warnings.push(format!(
114 "paths.generated: '{entry}' is already implied by your outputs \u{2014} you can remove it"
115 ));
116 }
117 }
118
119 let mut generated_project: Vec<String> = Vec::new();
120 for entry in &generated {
121 if !auto_globs.contains(entry) {
122 generated_project.push(entry.clone());
123 }
124 }
125
126 Ok((
127 Policy {
128 project: Project {
129 name: raw.project.name,
130 summary: raw.project.summary,
131 },
132 commands: Commands {
133 install: raw_commands.install,
134 dev: raw_commands.dev,
135 lint: raw_commands.lint,
136 test: raw_commands.test,
137 build: raw_commands.build,
138 },
139 paths: Paths {
140 editable,
141 protected,
142 generated_policy: auto_globs,
143 generated_project,
144 },
145 roles,
146 constraints: Constraints {
147 require_tests_for_code_changes: raw_constraints
148 .require_tests_for_code_changes
149 .unwrap_or(false),
150 forbid_secrets: raw_constraints.forbid_secrets.unwrap_or(false),
151 require_human_review_for_protected_paths: raw_constraints
152 .require_human_review_for_protected_paths
153 .unwrap_or(false),
154 },
155 outputs,
156 },
157 warnings,
158 ))
159}
160
161fn validate_role_name(name: &str) -> Result<()> {
162 let valid = !name.is_empty()
163 && name
164 .chars()
165 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
166 if valid {
167 Ok(())
168 } else {
169 Err(Error::InvalidRoleName {
170 name: name.to_owned(),
171 })
172 }
173}
174
175fn validate_globs(patterns: &[String]) -> Result<()> {
176 for pattern in patterns {
177 globset::GlobBuilder::new(pattern)
178 .build()
179 .map_err(|e| Error::Glob {
180 pattern: pattern.clone(),
181 source: e,
182 })?;
183 }
184 Ok(())
185}