Skip to main content

majordomus_cli/
providers.rs

1//! The provider projections: `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` and whatever else the
2//! policy's `projections[]` declares, rendered from the provider templates and the
3//! canonical policy. They are one-way: the policy and the templates are the sources, the
4//! targets are caches that `generate --check` compares and `generate` rewrites.
5//!
6//! The rendering is byte-identical to the shell tool's `majordomus update`, so both agree
7//! on every stamp: a file-mode target starts with a stamp naming the policy hash and the
8//! hash of the content below it; a region-mode target carries both in its begin marker and
9//! the rest of the host document is left alone.
10
11use std::path::Path;
12
13use crate::error::{Error, Result};
14use crate::generate::Artifact;
15use crate::policy::{
16    is_safe_relative, repository_providers_dir, sha256_hex, LoadedPolicy, ProjectionMode,
17};
18use crate::repository::Repository;
19use crate::share::Share;
20
21/// The region markers of a region-mode target.
22pub const REGION_BEGIN: &str = "<!-- majordomus:begin";
23/// The closing marker of a region-mode target.
24pub const REGION_END: &str = "<!-- majordomus:end -->";
25
26/// The template of a provider: the repository's own under `.ai/repo/providers/`, else the
27/// distribution's under `share/providers/`.
28pub fn template_path(
29    repository: &Repository,
30    share: &Share,
31    provider: &str,
32) -> Option<std::path::PathBuf> {
33    let name = format!("{provider}.tmpl");
34    let own = repository_providers_dir(repository).join(&name);
35    if own.is_file() {
36        return Some(own);
37    }
38    let shipped = share.dir().join("providers").join(&name);
39    shipped.is_file().then_some(shipped)
40}
41
42/// Every provider projection the policy declares, rendered.
43///
44/// A target outside the repository, a provider without a template, a token the policy
45/// cannot fill, and an `always_loaded` target over the budget are errors: a projection
46/// that cannot be produced is a defect of the declaration, not something to skip.
47pub fn artifacts(
48    repository: &Repository,
49    share: &Share,
50    policy: &LoadedPolicy,
51) -> Result<Vec<Artifact>> {
52    let mut out = Vec::with_capacity(policy.policy.projections.len());
53    for projection in &policy.policy.projections {
54        let target = &projection.target;
55        if !is_safe_relative(target) {
56            return Err(Error::InvalidProjection {
57                target: target.clone(),
58                reason: "the target must be a relative path inside the repository".into(),
59            });
60        }
61        let template = template_path(repository, share, &projection.provider).ok_or_else(|| {
62            Error::InvalidProjection {
63                target: target.clone(),
64                reason: format!(
65                    "provider '{}' has no template: neither {}/{}.tmpl nor {}/providers/{}.tmpl exists",
66                    projection.provider,
67                    repository_providers_dir(repository)
68                        .strip_prefix(repository.root())
69                        .unwrap_or(&repository_providers_dir(repository))
70                        .display(),
71                    projection.provider,
72                    share.dir().display(),
73                    projection.provider
74                ),
75            }
76        })?;
77        let text = std::fs::read_to_string(&template).map_err(|e| Error::io(&template, e))?;
78        let body = render(&text, policy).map_err(|reason| Error::InvalidProjection {
79            target: target.clone(),
80            reason: format!("{}: {reason}", template.display()),
81        })?;
82        let content_sha = sha256_hex(&body);
83        let content = match projection.mode {
84            ProjectionMode::File => {
85                format!(
86                    "{}{body}",
87                    stamp_line(&policy.path, &policy.sha256, &content_sha)
88                )
89            }
90            ProjectionMode::Region => {
91                let host_path = repository.root().join(target);
92                let host = match std::fs::read_to_string(&host_path) {
93                    Ok(h) => Some(h),
94                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
95                    Err(e) => return Err(Error::io(&host_path, e)),
96                };
97                splice_region(
98                    host.as_deref(),
99                    &body,
100                    &format!("{} {}", &policy.sha256[..12], &content_sha[..16]),
101                )
102                .map_err(|reason| Error::InvalidProjection {
103                    target: target.clone(),
104                    reason,
105                })?
106            }
107        };
108        if projection.always_loaded {
109            if let Some(budget) = policy.policy.context.always_loaded_budget_lines {
110                let owned = match projection.mode {
111                    ProjectionMode::File => content.as_str(),
112                    ProjectionMode::Region => body.as_str(),
113                };
114                let lines = owned.lines().count() as u64;
115                if lines > budget {
116                    return Err(Error::InvalidProjection {
117                        target: target.clone(),
118                        reason: format!(
119                            "would be {lines} lines, over the always_loaded budget of {budget}"
120                        ),
121                    });
122                }
123            }
124        }
125        out.push(Artifact::verbatim(
126            target.clone(),
127            format!("providers/{}", projection.provider),
128            crate::generate::ArtifactFormat::of_path(target),
129            None,
130            format!(
131                "the policy `{}` and the `{}` template, stamped by the projection itself",
132                policy.path, projection.provider
133            ),
134            content,
135        ));
136    }
137    Ok(out)
138}
139
140/// The stamp a file-mode target starts with.
141pub fn stamp_line(policy_path: &str, policy_sha: &str, content_sha: &str) -> String {
142    format!(
143        "<!-- generated by `majordomus update` from {policy_path} (policy {}, content {}) — do not edit; edit the policy and regenerate -->\n",
144        &policy_sha[..12.min(policy_sha.len())],
145        &content_sha[..16.min(content_sha.len())]
146    )
147}
148
149/// Fill a template: `{{DEFAULT_PROFILE}}`, `{{CHECKPOINT_DEFAULT}}` and `{{POLICY_SHA}}`
150/// come from the policy; any other `{{TOKEN}}` is an error, because a template that asks
151/// for something the policy does not carry cannot be rendered deterministically.
152///
153/// ```
154/// use majordomus_cli::providers::render;
155/// use majordomus_cli::policy::{LoadedPolicy, Policy, ProfilesPolicy};
156/// let policy = LoadedPolicy {
157///     policy: Policy {
158///         profiles: ProfilesPolicy { default: Some("implementation".into()), checkpoint_interval_default: Some("15m".into()) },
159///         ..Policy::default()
160///     },
161///     path: ".ai/repo/policy.yaml".into(),
162///     sha256: "0123456789abcdef0123456789abcdef".into(),
163/// };
164/// assert_eq!(
165///     render("profile {{DEFAULT_PROFILE}}, every {{CHECKPOINT_DEFAULT}}, policy {{POLICY_SHA}}\n", &policy).unwrap(),
166///     "profile implementation, every 15m, policy 0123456789ab\n"
167/// );
168/// assert!(render("{{PROFILE_TABLE}}\n", &policy).unwrap_err().contains("PROFILE_TABLE"));
169/// ```
170pub fn render(template: &str, policy: &LoadedPolicy) -> std::result::Result<String, String> {
171    let mut out = String::with_capacity(template.len());
172    let mut rest = template;
173    while let Some(start) = rest.find("{{") {
174        out.push_str(&rest[..start]);
175        let after = &rest[start + 2..];
176        let Some(end) = after.find("}}") else {
177            return Err("unterminated '{{' token".into());
178        };
179        let token = &after[..end];
180        let value = match token {
181            "DEFAULT_PROFILE" => policy.policy.profiles.default.clone(),
182            "CHECKPOINT_DEFAULT" => policy.policy.profiles.checkpoint_interval_default.clone(),
183            "POLICY_SHA" => Some(policy.sha256[..12.min(policy.sha256.len())].to_string()),
184            _ => {
185                return Err(format!(
186                    "token {{{{{token}}}}} is not one the policy can fill (DEFAULT_PROFILE, CHECKPOINT_DEFAULT, POLICY_SHA)"
187                ))
188            }
189        };
190        match value {
191            Some(v) => out.push_str(&v),
192            None => {
193                return Err(format!(
194                    "token {{{{{token}}}}} needs a value the policy does not set"
195                ))
196            }
197        }
198        rest = &after[end + 2..];
199    }
200    out.push_str(rest);
201    Ok(out)
202}
203
204/// Splice a rendered body into a host document between the region markers, the begin
205/// marker carrying `stamp`. With no host, or a host without a region, the region is
206/// appended (after a blank line when the host exists). Malformed markers (a begin without
207/// an end, out of order, or repeated) are an error.
208///
209/// ```
210/// use majordomus_cli::providers::splice_region;
211/// let host = "# Mine\n\n<!-- majordomus:begin abc0 def1 -->\nstale\n<!-- majordomus:end -->\n\ntail\n";
212/// let out = splice_region(Some(host), "fresh\n", "p c").unwrap();
213/// assert_eq!(out, "# Mine\n\n<!-- majordomus:begin p c -->\nfresh\n<!-- majordomus:end -->\n\ntail\n");
214/// assert_eq!(splice_region(None, "fresh\n", "p c").unwrap(), "<!-- majordomus:begin p c -->\nfresh\n<!-- majordomus:end -->\n");
215/// assert!(splice_region(Some("<!-- majordomus:begin -->\nno end\n"), "x\n", "p c").is_err());
216/// ```
217pub fn splice_region(
218    host: Option<&str>,
219    body: &str,
220    stamp: &str,
221) -> std::result::Result<String, String> {
222    let region = format!("{REGION_BEGIN} {stamp} -->\n{body}{REGION_END}\n");
223    let Some(host) = host else {
224        return Ok(region);
225    };
226    let begins = host.lines().filter(|l| is_begin_marker(l)).count();
227    let ends = host.lines().filter(|l| *l == REGION_END).count();
228    if begins == 0 && ends == 0 {
229        let sep = if host.ends_with('\n') { "\n" } else { "\n\n" };
230        return Ok(format!("{host}{sep}{region}"));
231    }
232    if begins != 1 || ends != 1 {
233        return Err("region markers are malformed (unclosed, out of order, or repeated)".into());
234    }
235    let mut out = String::with_capacity(host.len() + region.len());
236    let mut inside = false;
237    let mut seen_end = false;
238    for line in host.lines() {
239        if is_begin_marker(line) {
240            if seen_end {
241                return Err("region markers are out of order".into());
242            }
243            out.push_str(&region);
244            inside = true;
245            continue;
246        }
247        if line == REGION_END {
248            if !inside {
249                return Err("region markers are out of order".into());
250            }
251            inside = false;
252            seen_end = true;
253            continue;
254        }
255        if !inside {
256            out.push_str(line);
257            out.push('\n');
258        }
259    }
260    if inside {
261        return Err("region is unclosed".into());
262    }
263    Ok(out)
264}
265
266fn is_begin_marker(line: &str) -> bool {
267    line.starts_with(REGION_BEGIN)
268        && line.ends_with(" -->")
269        && line[REGION_BEGIN.len()..line.len() - 4]
270            .split(' ')
271            .all(|w| w.is_empty() || w.chars().all(|c| c.is_ascii_hexdigit()))
272}
273
274/// The state of one committed target against what the policy renders now. `generate
275/// --check` only needs stale or not; `doctor` wants the word.
276#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
277#[serde(rename_all = "snake_case")]
278pub enum TargetState {
279    /// The file matches the rendering byte for byte.
280    Current,
281    /// The file is not there.
282    Absent,
283    /// The file differs from the rendering.
284    Stale,
285}
286
287/// Classify an artifact against the file under `root`.
288pub fn state_of(root: &Path, artifact: &Artifact) -> TargetState {
289    match std::fs::read_to_string(root.join(&artifact.path)) {
290        Ok(on_disk) if on_disk == artifact.content => TargetState::Current,
291        Ok(_) => TargetState::Stale,
292        Err(_) => TargetState::Absent,
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn stamp_matches_the_shell_tools_format() {
302        let s = stamp_line(".ai/repo/policy.yaml", &"a".repeat(64), &"b".repeat(64));
303        assert_eq!(
304            s,
305            format!(
306                "<!-- generated by `majordomus update` from .ai/repo/policy.yaml (policy {}, content {}) — do not edit; edit the policy and regenerate -->\n",
307                "a".repeat(12),
308                "b".repeat(16)
309            )
310        );
311    }
312
313    #[test]
314    fn begin_marker_recognition() {
315        assert!(is_begin_marker("<!-- majordomus:begin -->"));
316        assert!(is_begin_marker("<!-- majordomus:begin abc123 -->"));
317        assert!(is_begin_marker("<!-- majordomus:begin abc123 def456 -->"));
318        assert!(!is_begin_marker("<!-- majordomus:begin xyz -->"));
319        assert!(!is_begin_marker("<!-- majordomus:beginning -->"));
320    }
321
322    #[test]
323    fn appending_a_region_to_a_host_without_trailing_newline() {
324        let out = splice_region(Some("# Mine"), "b\n", "p c").unwrap();
325        assert_eq!(
326            out,
327            "# Mine\n\n<!-- majordomus:begin p c -->\nb\n<!-- majordomus:end -->\n"
328        );
329    }
330}