Skip to main content

agent_policy/model/
mod.rs

1// Data model — implemented in Phase 1
2
3pub 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
13/// Valid output target IDs.
14const 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
26/// Normalize a validated [`RawPolicy`] into the stable [`Policy`] model.
27///
28/// Applies all defaults and validates semantic constraints:
29/// valid glob patterns, valid role names, known output target IDs.
30///
31/// Returns the normalized policy and a list of diagnostic warnings. Warnings
32/// are non-fatal but indicate configuration that should be cleaned up
33/// (e.g. listing output files redundantly in `paths.generated`).
34///
35/// # Errors
36///
37/// Returns [`Error::InvalidRoleName`] for role names with disallowed characters,
38/// [`Error::Glob`] for malformed glob patterns, [`Error::UnknownTarget`] for
39/// unrecognized output target IDs, or [`Error::NoOutputs`] if the resolved
40/// outputs list is empty.
41pub fn normalize(raw: RawPolicy) -> Result<(Policy, Vec<String>)> {
42    // Validate and normalize roles
43    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    // Validate global path globs
63    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    // When `outputs` is omitted entirely, default to generating agents-md only.
75    let enabled_targets: Vec<String> = raw.outputs.unwrap_or_else(|| vec!["agents-md".to_owned()]);
76
77    // Validate all target IDs. Unknown IDs surface a clear error rather than
78    // a cryptic JSON Schema message.
79    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    // Derive auto-generated path globs from enabled output targets.
103    let auto_globs: Vec<String> = outputs
104        .enabled()
105        .iter()
106        .map(|t| t.generated_glob().to_owned())
107        .collect();
108
109    // Warn about user-specified generated paths that duplicate auto-derived ones.
110    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}