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/// # Errors
28///
29/// Returns [`Error::InvalidRoleName`] for role names with disallowed characters,
30/// [`Error::Glob`] for malformed glob patterns, [`Error::UnknownTarget`] for
31/// unrecognized output target IDs, or [`Error::NoOutputs`] if the resolved
32/// outputs list is empty.
33pub fn normalize(raw: RawPolicy) -> Result<Policy> {
34    // Validate and normalize roles
35    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    // Validate global path globs
55    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    // When `outputs` is omitted entirely, default to generating agents-md only.
67    let enabled_targets: Vec<String> = raw.outputs.unwrap_or_else(|| vec!["agents-md".to_owned()]);
68
69    // Validate all target IDs. Unknown IDs surface a clear error rather than
70    // a cryptic JSON Schema message.
71    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}