Skip to main content

agent_policy/render/
cursor_rules.rs

1//! Renderer for `.cursor/rules/` — global default plus one file per agent role.
2
3use camino::Utf8PathBuf;
4use minijinja::Environment;
5
6use crate::{
7    error::{Error, Result},
8    model::normalized::Policy,
9    render::RenderedOutput,
10};
11
12const DEFAULT_TARGET: &str = ".cursor/rules/default.mdc";
13const DEFAULT_TEMPLATE: &str = include_str!("../../templates/cursor_rule.mdc.j2");
14const ROLE_TEMPLATE: &str = include_str!("../../templates/cursor_role.mdc.j2");
15
16/// Render all `.cursor/rules/` outputs for the given policy.
17///
18/// Always produces `default.mdc` (global, `alwaysApply: true`). Additionally
19/// produces one `.mdc` per role that has at least one editable path, with
20/// `globs` set to those patterns so Cursor activates the rule automatically.
21///
22/// # Errors
23///
24/// Returns [`Error::Render`] if any template fails to compile or render.
25pub fn render(policy: &Policy) -> Result<Vec<RenderedOutput>> {
26    let mut outputs = Vec::new();
27
28    // Global default rule
29    let mut env = Environment::new();
30    env.add_template("default.mdc", DEFAULT_TEMPLATE)
31        .map_err(|e| Error::Render {
32            target: DEFAULT_TARGET.to_owned(),
33            source: e,
34        })?;
35    let tmpl = env.get_template("default.mdc").map_err(|e| Error::Render {
36        target: DEFAULT_TARGET.to_owned(),
37        source: e,
38    })?;
39    let commands_defined = !policy.commands.is_empty();
40    let content = tmpl
41        .render(minijinja::context! {
42            project => &policy.project,
43            commands => &policy.commands,
44            commands_defined => commands_defined,
45            paths => &policy.paths,
46            roles => &policy.roles,
47            constraints => &policy.constraints,
48        })
49        .map_err(|e| Error::Render {
50            target: DEFAULT_TARGET.to_owned(),
51            source: e,
52        })?;
53    outputs.push(RenderedOutput {
54        path: Utf8PathBuf::from(DEFAULT_TARGET),
55        content,
56    });
57
58    // Per-role rules
59    if !policy.roles.is_empty() {
60        let mut role_env = Environment::new();
61        role_env
62            .add_template("role.mdc", ROLE_TEMPLATE)
63            .map_err(|e| Error::Render {
64                target: "cursor role".to_owned(),
65                source: e,
66            })?;
67        let role_tmpl = role_env
68            .get_template("role.mdc")
69            .map_err(|e| Error::Render {
70                target: "cursor role".to_owned(),
71                source: e,
72            })?;
73
74        for (name, role) in &policy.roles {
75            if role.editable.is_empty() {
76                continue;
77            }
78            // Cursor globs field: comma-separated list of patterns
79            let globs_pattern = role.editable.join(",");
80            let target = format!(".cursor/rules/{name}.mdc");
81            let role_content = role_tmpl
82                .render(minijinja::context! {
83                    project => &policy.project,
84                    role_name => name,
85                    role => role,
86                    globs_pattern => &globs_pattern,
87                })
88                .map_err(|e| Error::Render {
89                    target: target.clone(),
90                    source: e,
91                })?;
92            outputs.push(RenderedOutput {
93                path: Utf8PathBuf::from(&target),
94                content: role_content,
95            });
96        }
97    }
98
99    Ok(outputs)
100}