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> {
34 let mut roles: IndexMap<String, Role> = IndexMap::new();
36 if let Some(raw_roles) = raw.roles {
37 for (name, raw_role) in raw_roles {
38 validate_role_name(&name)?;
39 let editable = raw_role.editable.unwrap_or_default();
40 let forbidden = raw_role.forbidden.unwrap_or_default();
41 validate_globs(&editable)?;
42 validate_globs(&forbidden)?;
43 roles.insert(
44 name.clone(),
45 Role {
46 name,
47 editable,
48 forbidden,
49 },
50 );
51 }
52 }
53
54 let raw_paths = raw.paths.unwrap_or_default();
56 let editable = raw_paths.editable.unwrap_or_default();
57 let protected = raw_paths.protected.unwrap_or_default();
58 let generated = raw_paths.generated.unwrap_or_default();
59 validate_globs(&editable)?;
60 validate_globs(&protected)?;
61 validate_globs(&generated)?;
62
63 let raw_commands = raw.commands.unwrap_or_default();
64 let raw_constraints = raw.constraints.unwrap_or_default();
65
66 let enabled_targets: Vec<String> = raw.outputs.unwrap_or_else(|| vec!["agents-md".to_owned()]);
68
69 for id in &enabled_targets {
72 if !VALID_TARGETS.contains(&id.as_str()) {
73 return Err(Error::UnknownTarget { id: id.clone() });
74 }
75 }
76
77 let outputs = OutputTargets {
78 agents_md: enabled_targets.contains(&"agents-md".to_owned()),
79 claude_md: enabled_targets.contains(&"claude-md".to_owned()),
80 cursor_rules: enabled_targets.contains(&"cursor-rules".to_owned()),
81 gemini_md: enabled_targets.contains(&"gemini-md".to_owned()),
82 copilot_instructions: enabled_targets.contains(&"copilot-instructions".to_owned()),
83 };
84
85 if outputs.is_empty() {
86 return Err(Error::NoOutputs);
87 }
88
89 Ok(Policy {
90 project: Project {
91 name: raw.project.name,
92 summary: raw.project.summary,
93 },
94 commands: Commands {
95 install: raw_commands.install,
96 dev: raw_commands.dev,
97 lint: raw_commands.lint,
98 test: raw_commands.test,
99 build: raw_commands.build,
100 },
101 paths: Paths {
102 editable,
103 protected,
104 generated,
105 },
106 roles,
107 constraints: Constraints {
108 require_tests_for_code_changes: raw_constraints
109 .require_tests_for_code_changes
110 .unwrap_or(false),
111 forbid_secrets: raw_constraints.forbid_secrets.unwrap_or(false),
112 require_human_review_for_protected_paths: raw_constraints
113 .require_human_review_for_protected_paths
114 .unwrap_or(false),
115 },
116 outputs,
117 })
118}
119
120fn validate_role_name(name: &str) -> Result<()> {
121 let valid = !name.is_empty()
122 && name
123 .chars()
124 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
125 if valid {
126 Ok(())
127 } else {
128 Err(Error::InvalidRoleName {
129 name: name.to_owned(),
130 })
131 }
132}
133
134fn validate_globs(patterns: &[String]) -> Result<()> {
135 for pattern in patterns {
136 globset::GlobBuilder::new(pattern)
137 .build()
138 .map_err(|e| Error::Glob {
139 pattern: pattern.clone(),
140 source: e,
141 })?;
142 }
143 Ok(())
144}