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 for v0.1.
14const VALID_TARGETS: &[&str] = &["agents-md", "claude-md", "cursor-rules"];
15
16/// Normalize a validated [`RawPolicy`] into the stable [`Policy`] model.
17///
18/// Applies all defaults and validates semantic constraints:
19/// valid glob patterns, valid role names, known output target IDs.
20///
21/// # Errors
22///
23/// Returns [`Error::InvalidRoleName`] for role names with disallowed characters,
24/// [`Error::Glob`] for malformed glob patterns, [`Error::UnknownTarget`] for
25/// unrecognized output target IDs, or [`Error::NoOutputs`] if the resolved
26/// outputs list is empty.
27pub fn normalize(raw: RawPolicy) -> Result<Policy> {
28    // Validate and normalize roles
29    let mut roles: IndexMap<String, Role> = IndexMap::new();
30    if let Some(raw_roles) = raw.roles {
31        for (name, raw_role) in raw_roles {
32            validate_role_name(&name)?;
33            let editable = raw_role.editable.unwrap_or_default();
34            let forbidden = raw_role.forbidden.unwrap_or_default();
35            validate_globs(&editable)?;
36            validate_globs(&forbidden)?;
37            roles.insert(
38                name.clone(),
39                Role {
40                    name,
41                    editable,
42                    forbidden,
43                },
44            );
45        }
46    }
47
48    // Validate global path globs
49    let raw_paths = raw.paths.unwrap_or_default();
50    let editable = raw_paths.editable.unwrap_or_default();
51    let protected = raw_paths.protected.unwrap_or_default();
52    let generated = raw_paths.generated.unwrap_or_default();
53    validate_globs(&editable)?;
54    validate_globs(&protected)?;
55    validate_globs(&generated)?;
56
57    let raw_commands = raw.commands.unwrap_or_default();
58    let raw_constraints = raw.constraints.unwrap_or_default();
59
60    // When `outputs` is omitted entirely, default to generating agents-md only.
61    let enabled_targets: Vec<String> = raw.outputs.unwrap_or_else(|| vec!["agents-md".to_owned()]);
62
63    // Validate all target IDs. Unknown IDs surface a clear error rather than
64    // a cryptic JSON Schema message.
65    for id in &enabled_targets {
66        if !VALID_TARGETS.contains(&id.as_str()) {
67            return Err(Error::UnknownTarget { id: id.clone() });
68        }
69    }
70
71    let outputs = OutputTargets {
72        agents_md: enabled_targets.contains(&"agents-md".to_owned()),
73        claude_md: enabled_targets.contains(&"claude-md".to_owned()),
74        cursor_rules: enabled_targets.contains(&"cursor-rules".to_owned()),
75    };
76
77    if outputs.is_empty() {
78        return Err(Error::NoOutputs);
79    }
80
81    Ok(Policy {
82        project: Project {
83            name: raw.project.name,
84            summary: raw.project.summary,
85        },
86        commands: Commands {
87            install: raw_commands.install,
88            dev: raw_commands.dev,
89            lint: raw_commands.lint,
90            test: raw_commands.test,
91            build: raw_commands.build,
92        },
93        paths: Paths {
94            editable,
95            protected,
96            generated,
97        },
98        roles,
99        constraints: Constraints {
100            require_tests_for_code_changes: raw_constraints
101                .require_tests_for_code_changes
102                .unwrap_or(false),
103            forbid_secrets: raw_constraints.forbid_secrets.unwrap_or(false),
104            require_human_review_for_protected_paths: raw_constraints
105                .require_human_review_for_protected_paths
106                .unwrap_or(false),
107        },
108        outputs,
109    })
110}
111
112fn validate_role_name(name: &str) -> Result<()> {
113    let valid = !name.is_empty()
114        && name
115            .chars()
116            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
117    if valid {
118        Ok(())
119    } else {
120        Err(Error::InvalidRoleName {
121            name: name.to_owned(),
122        })
123    }
124}
125
126fn validate_globs(patterns: &[String]) -> Result<()> {
127    for pattern in patterns {
128        globset::GlobBuilder::new(pattern)
129            .build()
130            .map_err(|e| Error::Glob {
131                pattern: pattern.clone(),
132                source: e,
133            })?;
134    }
135    Ok(())
136}