1use 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
21pub const REGION_BEGIN: &str = "<!-- majordomus:begin";
23pub const REGION_END: &str = "<!-- majordomus:end -->";
25
26pub 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
42pub 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
140pub 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
149pub 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
204pub 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(®ion);
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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
277#[serde(rename_all = "snake_case")]
278pub enum TargetState {
279 Current,
281 Absent,
283 Stale,
285}
286
287pub 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}