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];
21
22/// Normalize a validated [`RawPolicy`] into the stable [`Policy`] model.
23///
24/// Applies all defaults and validates semantic constraints:
25/// valid glob patterns, valid role names, known output target IDs.
26///
27/// Returns the normalized policy and a list of diagnostic warnings. Warnings
28/// are non-fatal but indicate configuration that should be cleaned up
29/// (e.g. listing output files redundantly in `paths.generated`).
30///
31/// # Errors
32///
33/// Returns [`Error::InvalidRoleName`] for role names with disallowed characters,
34/// [`Error::Glob`] for malformed glob patterns, [`Error::UnknownTarget`] for
35/// unrecognized output target IDs, or [`Error::NoOutputs`] if the resolved
36/// outputs list is empty.
37pub fn normalize(raw: RawPolicy) -> Result<(Policy, Vec<String>)> {
38    // Validate and normalize roles
39    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    // Validate global path globs
59    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    // When `outputs` is omitted entirely, default to generating agents-md only.
71    let enabled_targets: Vec<String> = raw.outputs.unwrap_or_else(|| vec!["agents-md".to_owned()]);
72
73    // Validate all target IDs. Unknown IDs surface a clear error rather than
74    // a cryptic JSON Schema message.
75    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    // Derive auto-generated path globs from enabled output targets.
94    let auto_globs: Vec<String> = outputs
95        .enabled()
96        .iter()
97        .map(|t| t.generated_glob().to_owned())
98        .collect();
99
100    // Warn about user-specified generated paths that duplicate auto-derived ones.
101    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    // Final list = auto-derived (always first) + user extras (preserving order, deduped).
111    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}