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];
21
22pub fn normalize(raw: RawPolicy) -> Result<(Policy, Vec<String>)> {
38 let mut roles: IndexMap<String, Role> = IndexMap::new();
40 if let Some(raw_roles) = raw.roles {
41 for (name, raw_role) in raw_roles {
42 validate_role_name(&name)?;
43 let editable = raw_role.editable.unwrap_or_default();
44 let forbidden = raw_role.forbidden.unwrap_or_default();
45 validate_globs(&editable)?;
46 validate_globs(&forbidden)?;
47 roles.insert(
48 name.clone(),
49 Role {
50 name,
51 editable,
52 forbidden,
53 },
54 );
55 }
56 }
57
58 let raw_paths = raw.paths.unwrap_or_default();
60 let editable = raw_paths.editable.unwrap_or_default();
61 let protected = raw_paths.protected.unwrap_or_default();
62 let generated = raw_paths.generated.unwrap_or_default();
63 validate_globs(&editable)?;
64 validate_globs(&protected)?;
65 validate_globs(&generated)?;
66
67 let raw_commands = raw.commands.unwrap_or_default();
68 let raw_constraints = raw.constraints.unwrap_or_default();
69
70 let enabled_targets: Vec<String> = raw.outputs.unwrap_or_else(|| vec!["agents-md".to_owned()]);
72
73 for id in &enabled_targets {
76 if !VALID_TARGETS.contains(&id.as_str()) {
77 return Err(Error::UnknownTarget { id: id.clone() });
78 }
79 }
80
81 let outputs = OutputTargets {
82 agents_md: enabled_targets.contains(&"agents-md".to_owned()),
83 claude_md: enabled_targets.contains(&"claude-md".to_owned()),
84 cursor_rules: enabled_targets.contains(&"cursor-rules".to_owned()),
85 gemini_md: enabled_targets.contains(&"gemini-md".to_owned()),
86 copilot_instructions: enabled_targets.contains(&"copilot-instructions".to_owned()),
87 };
88
89 if outputs.is_empty() {
90 return Err(Error::NoOutputs);
91 }
92
93 let auto_globs: Vec<String> = outputs
95 .enabled()
96 .iter()
97 .map(|t| t.generated_glob().to_owned())
98 .collect();
99
100 let mut warnings: Vec<String> = Vec::new();
102 for entry in &generated {
103 if auto_globs.contains(entry) {
104 warnings.push(format!(
105 "paths.generated: '{entry}' is already implied by your outputs \u{2014} you can remove it"
106 ));
107 }
108 }
109
110 let mut final_generated: Vec<String> = auto_globs.clone();
112 for entry in &generated {
113 if !auto_globs.contains(entry) {
114 final_generated.push(entry.clone());
115 }
116 }
117
118 Ok((Policy {
119 project: Project {
120 name: raw.project.name,
121 summary: raw.project.summary,
122 },
123 commands: Commands {
124 install: raw_commands.install,
125 dev: raw_commands.dev,
126 lint: raw_commands.lint,
127 test: raw_commands.test,
128 build: raw_commands.build,
129 },
130 paths: Paths {
131 editable,
132 protected,
133 generated: final_generated,
134 },
135 roles,
136 constraints: Constraints {
137 require_tests_for_code_changes: raw_constraints
138 .require_tests_for_code_changes
139 .unwrap_or(false),
140 forbid_secrets: raw_constraints.forbid_secrets.unwrap_or(false),
141 require_human_review_for_protected_paths: raw_constraints
142 .require_human_review_for_protected_paths
143 .unwrap_or(false),
144 },
145 outputs,
146 }, warnings))
147}
148
149fn validate_role_name(name: &str) -> Result<()> {
150 let valid = !name.is_empty()
151 && name
152 .chars()
153 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
154 if valid {
155 Ok(())
156 } else {
157 Err(Error::InvalidRoleName {
158 name: name.to_owned(),
159 })
160 }
161}
162
163fn validate_globs(patterns: &[String]) -> Result<()> {
164 for pattern in patterns {
165 globset::GlobBuilder::new(pattern)
166 .build()
167 .map_err(|e| Error::Glob {
168 pattern: pattern.clone(),
169 source: e,
170 })?;
171 }
172 Ok(())
173}