1use crate::destination::{package_bound_to_compose, package_bound_to_tree};
2use crate::environment::package_matches_environment;
3use crate::hashing::{checksum_managed_span_content, marker_id};
4use crate::lockfile::ManagedSpanRecord;
5use crate::manifest::{DestinationEntry, DestinationMode};
6use crate::resolve::ResolvedProject;
7use crate::substitute::substitute_pray_symbols;
8use crate::{PrayError, PrayResult};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone)]
13pub struct RenderedTarget {
14 pub path: PathBuf,
15 pub content: String,
16 pub managed_spans: Vec<ManagedSpanRecord>,
17}
18
19pub fn render_project(project: &ResolvedProject) -> PrayResult<Vec<RenderedTarget>> {
20 let mut rendered = Vec::new();
21 for target in &project.manifest.targets {
22 let Some(output) = target.outputs.first() else {
23 continue;
24 };
25 rendered.push(render_target(project, target, Path::new(output))?);
26 }
27 Ok(rendered)
28}
29
30pub fn write_rendered_targets(
31 project: &ResolvedProject,
32 rendered: &[RenderedTarget],
33) -> PrayResult<()> {
34 for target in rendered {
35 let path = project.project_root.join(&target.path);
36 if let Some(parent) = path.parent() {
37 fs::create_dir_all(parent)?;
38 }
39 fs::write(path, &target.content)?;
40 }
41 materialize_provisioned_exports(project)?;
42 Ok(())
43}
44
45#[derive(Debug, Clone)]
46pub struct PlannedProvisionedFile {
47 pub path: PathBuf,
48 pub source: PathBuf,
49}
50
51pub fn planned_provisioned_files(
52 project: &ResolvedProject,
53) -> PrayResult<Vec<PlannedProvisionedFile>> {
54 let mut planned = Vec::new();
55 collect_exact_file_bindings(project, &mut planned)?;
56 for target in &project.manifest.targets {
57 for folder_root in &target.skills {
58 let destination_root = project.project_root.join(folder_root);
59 for package in &project.packages {
60 if !package_matches_environment(
61 &package.declaration.groups,
62 project.environment.as_deref(),
63 ) {
64 continue;
65 }
66 if !package_bound_to_tree(&package.declaration, target) {
67 continue;
68 }
69 collect_legacy_skill_files(project, package, &destination_root, &mut planned)?;
70 collect_selected_export_files(project, package, &destination_root, &mut planned)?;
71 }
72 }
73 }
74 planned.sort_by(|left, right| left.path.cmp(&right.path));
75 planned.dedup_by(|left, right| left.path == right.path);
76 Ok(planned)
77}
78
79pub fn materialize_provisioned_exports(project: &ResolvedProject) -> PrayResult<()> {
80 for file in planned_provisioned_files(project)? {
81 let destination = project.project_root.join(&file.path);
82 if let Some(parent) = destination.parent() {
83 fs::create_dir_all(parent)?;
84 }
85 write_provisioned_file(&file.source, &destination, &project.manifest.symbols)?;
86 }
87 Ok(())
88}
89
90pub fn expected_provisioned_bytes(
91 source: &Path,
92 symbols: &std::collections::BTreeMap<String, String>,
93) -> PrayResult<Vec<u8>> {
94 let bytes = fs::read(source)?;
95 match String::from_utf8(bytes) {
96 Ok(text) => Ok(substitute_pray_symbols(&text, symbols)?.into_bytes()),
97 Err(error) => Ok(error.into_bytes()),
98 }
99}
100
101fn write_provisioned_file(
102 source: &Path,
103 destination: &Path,
104 symbols: &std::collections::BTreeMap<String, String>,
105) -> PrayResult<()> {
106 fs::write(destination, expected_provisioned_bytes(source, symbols)?)?;
107 Ok(())
108}
109
110fn collect_exact_file_bindings(
111 project: &ResolvedProject,
112 planned: &mut Vec<PlannedProvisionedFile>,
113) -> PrayResult<()> {
114 for package in &project.packages {
115 let Some(destination) = &package.declaration.file else {
116 continue;
117 };
118 if !package_matches_environment(&package.declaration.groups, project.environment.as_deref())
119 {
120 continue;
121 }
122 let mut matched = false;
123 for export_name in &package.selected_exports {
124 let Some(export) = package.spec.exports.get(export_name) else {
125 continue;
126 };
127 if export.kind != "file" {
128 continue;
129 }
130 let source = package.root.join(&export.path);
131 if !source.is_file() {
132 return Err(PrayError::Render(format!(
133 "file export source missing: {}",
134 source.display()
135 )));
136 }
137 planned.push(PlannedProvisionedFile {
138 path: PathBuf::from(destination),
139 source,
140 });
141 matched = true;
142 break;
143 }
144 if !matched {
145 return Err(PrayError::Render(format!(
146 "package {} has file: \"{}\" but no selected file export",
147 package.declaration.name, destination
148 )));
149 }
150 }
151 Ok(())
152}
153
154fn relative_project_path(project: &ResolvedProject, absolute: &Path) -> PathBuf {
155 absolute
156 .strip_prefix(&project.project_root)
157 .map(Path::to_path_buf)
158 .unwrap_or_else(|_| absolute.to_path_buf())
159}
160
161fn collect_legacy_skill_files(
162 project: &ResolvedProject,
163 package: &crate::resolve::ResolvedPackage,
164 destination_root: &Path,
165 planned: &mut Vec<PlannedProvisionedFile>,
166) -> PrayResult<()> {
167 for (skill_name, skill) in &package.spec.skills {
168 if legacy_skill_covered_by_export(package, skill) {
169 continue;
170 }
171 let skill_files = package.skill_files.get(skill_name).ok_or_else(|| {
172 PrayError::Render(format!(
173 "package {} has no indexed files for legacy skill {}",
174 package.declaration.name, skill_name
175 ))
176 })?;
177 collect_tree_files(
178 project,
179 &package.root.join(&skill.path),
180 &destination_root.join(skill_name),
181 skill_files,
182 &[],
183 &[],
184 planned,
185 )?;
186 }
187 Ok(())
188}
189
190fn legacy_skill_covered_by_export(
191 package: &crate::resolve::ResolvedPackage,
192 skill: &crate::package_spec::PackageSkill,
193) -> bool {
194 package.spec.exports.iter().any(|(export_name, export)| {
195 package.selected_exports.contains(export_name)
196 && is_folder_export_kind(&export.kind)
197 && export.path.trim_end_matches('/') == skill.path.trim_end_matches('/')
198 })
199}
200
201fn collect_selected_export_files(
202 project: &ResolvedProject,
203 package: &crate::resolve::ResolvedPackage,
204 destination_root: &Path,
205 planned: &mut Vec<PlannedProvisionedFile>,
206) -> PrayResult<()> {
207 for export_name in &package.selected_exports {
208 let Some(export) = package.spec.exports.get(export_name) else {
209 continue;
210 };
211 match export.kind.as_str() {
212 "folder" | "skill" => {
213 let indexed_files = package.skill_files.get(export_name).ok_or_else(|| {
214 PrayError::Render(format!(
215 "package {} has no indexed files for folder export {}",
216 package.declaration.name, export_name
217 ))
218 })?;
219 let destination_name = folder_destination_name(export_name, &export.path);
220 collect_tree_files(
221 project,
222 &package.root.join(&export.path),
223 &destination_root.join(destination_name),
224 indexed_files,
225 &export.only,
226 &export.except,
227 planned,
228 )?;
229 }
230 "file" => {
231 if package.declaration.file.is_some() {
232 continue;
233 }
234 let source = package.root.join(&export.path);
235 if !source.is_file() {
236 return Err(PrayError::Render(format!(
237 "file export source missing: {}",
238 source.display()
239 )));
240 }
241 let file_name =
242 source
243 .file_name()
244 .map(|name| name.to_owned())
245 .ok_or_else(|| {
246 PrayError::Render(format!(
247 "file export path has no file name: {}",
248 export.path
249 ))
250 })?;
251 let destination = destination_root.join(export_name).join(file_name);
252 planned.push(PlannedProvisionedFile {
253 path: relative_project_path(project, &destination),
254 source,
255 });
256 }
257 _ => {}
258 }
259 }
260 Ok(())
261}
262
263fn is_folder_export_kind(kind: &str) -> bool {
264 matches!(kind, "folder" | "skill")
265}
266
267fn folder_destination_name(export_name: &str, export_path: &str) -> String {
268 Path::new(export_path.trim_end_matches('/'))
269 .file_name()
270 .map(|name| name.to_string_lossy().to_string())
271 .unwrap_or_else(|| export_name.to_string())
272}
273
274fn collect_tree_files(
275 project: &ResolvedProject,
276 source_root: &Path,
277 destination_root: &Path,
278 relative_files: &[String],
279 only: &[String],
280 except: &[String],
281 planned: &mut Vec<PlannedProvisionedFile>,
282) -> PrayResult<()> {
283 if !source_root.is_dir() {
284 return Err(PrayError::Render(format!(
285 "folder source directory missing: {}",
286 source_root.display()
287 )));
288 }
289
290 if relative_files.is_empty() {
291 return Err(PrayError::Render(format!(
292 "no files listed in package manifest for {}",
293 source_root.display()
294 )));
295 }
296
297 let mut matched = false;
298 for relative in relative_files {
299 if !only.is_empty() && !only.iter().any(|entry| entry == relative) {
300 continue;
301 }
302 if except.iter().any(|entry| entry == relative) {
303 continue;
304 }
305 let source = source_root.join(relative);
306 if !source.is_file() {
307 return Err(PrayError::Render(format!(
308 "provisioned file missing: {}",
309 source.display()
310 )));
311 }
312 let destination = destination_root.join(relative);
313 planned.push(PlannedProvisionedFile {
314 path: relative_project_path(project, &destination),
315 source,
316 });
317 matched = true;
318 }
319
320 if !matched && only.is_empty() && except.is_empty() {
321 return Err(PrayError::Render(format!(
322 "no files listed in package manifest for {}",
323 source_root.display()
324 )));
325 }
326
327 Ok(())
328}
329
330struct ContentBuilder {
331 content: String,
332}
333
334impl ContentBuilder {
335 fn with_capacity(capacity: usize) -> Self {
336 Self {
337 content: String::with_capacity(capacity),
338 }
339 }
340
341 fn next_line_number(&self) -> usize {
342 self.content.matches('\n').count() + 1
343 }
344
345 fn append_line(&mut self, line: &str) {
346 self.content.push_str(line);
347 self.content.push('\n');
348 }
349
350 fn append_empty_line(&mut self) {
351 self.content.push('\n');
352 }
353
354 fn append_body(&mut self, body: &str) {
355 let trimmed = body.trim_end_matches('\n');
356 if trimmed.is_empty() {
357 return;
358 }
359 for line in trimmed.split('\n') {
360 self.append_line(line);
361 }
362 }
363
364 fn finish(mut self) -> String {
365 while self.content.ends_with("\n\n") {
366 self.content.pop();
367 }
368 if !self.content.ends_with('\n') {
369 self.content.push('\n');
370 }
371 self.content
372 }
373}
374
375fn should_inline_export(package: &crate::resolve::ResolvedPackage, export_name: &str) -> bool {
376 package
377 .spec
378 .exports
379 .get(export_name)
380 .is_none_or(|export| export.kind == "fragment")
381}
382
383fn render_target(
384 project: &ResolvedProject,
385 target: &crate::manifest::ManifestTarget,
386 output: &Path,
387) -> PrayResult<RenderedTarget> {
388 if target.scoped && target.mode == DestinationMode::Compose {
389 return render_scoped_compose(project, target, output);
390 }
391 render_legacy_compose(project, target, output)
392}
393
394fn render_scoped_compose(
395 project: &ResolvedProject,
396 target: &crate::manifest::ManifestTarget,
397 output: &Path,
398) -> PrayResult<RenderedTarget> {
399 let mut builder = ContentBuilder::with_capacity(8_192);
400 if project.manifest.render.header {
401 let output_name = output
402 .file_name()
403 .map(|name| name.to_string_lossy().to_string())
404 .unwrap_or_else(|| output.to_string_lossy().to_string());
405 builder.append_line("<!-- pray:0 ignore-comments -->");
406 builder.append_empty_line();
407 builder.append_line("# Agent context");
408 builder.append_empty_line();
409 builder.append_line(&format!(
410 "Do not edit managed blocks in `{output_name}` or provisioned files under `.agents/`."
411 ));
412 builder.append_line("To change shared guidance, update `Prayfile` and run `pray install`.");
413 builder.append_empty_line();
414 }
415
416 let mut managed_spans = Vec::new();
417 for entry in &target.entries {
418 match entry {
419 DestinationEntry::Local { path } => {
420 let Some(local) = project
421 .local_files
422 .iter()
423 .find(|local| local.manifest_path == *path)
424 else {
425 continue;
426 };
427 if local.content.is_empty() && local.optional {
428 continue;
429 }
430 let content =
431 substitute_pray_symbols(&local.content, &project.manifest.symbols)?;
432 builder.append_body(&content);
433 builder.append_empty_line();
434 }
435 DestinationEntry::Package { name } => {
436 let Some(package) = project
437 .packages
438 .iter()
439 .find(|package| package.declaration.name == *name)
440 else {
441 continue;
442 };
443 if !package_matches_environment(
444 &package.declaration.groups,
445 project.environment.as_deref(),
446 ) {
447 continue;
448 }
449 for export in &package.selected_exports {
450 if !should_inline_export(package, export) {
451 continue;
452 }
453 append_managed_export(
454 &mut builder,
455 &mut managed_spans,
456 package,
457 export,
458 target,
459 output,
460 &project.manifest.symbols,
461 )?;
462 }
463 }
464 }
465 }
466
467 Ok(RenderedTarget {
468 path: output.to_path_buf(),
469 content: builder.finish(),
470 managed_spans,
471 })
472}
473
474fn render_legacy_compose(
475 project: &ResolvedProject,
476 target: &crate::manifest::ManifestTarget,
477 output: &Path,
478) -> PrayResult<RenderedTarget> {
479 let mut builder = ContentBuilder::with_capacity(8_192);
480 if project.manifest.render.header {
481 let output_name = output
482 .file_name()
483 .map(|name| name.to_string_lossy().to_string())
484 .unwrap_or_else(|| output.to_string_lossy().to_string());
485 builder.append_line("<!-- pray:0 ignore-comments -->");
486 builder.append_empty_line();
487 builder.append_line("# Agent context");
488 builder.append_empty_line();
489 builder.append_line(&format!(
490 "Do not edit managed blocks in `{output_name}` or provisioned files under `.agents/`."
491 ));
492 builder.append_line("To change shared guidance, update `Prayfile` and run `pray install`.");
493 builder.append_empty_line();
494 }
495
496 let unbound_locals: Vec<_> = project
497 .local_files
498 .iter()
499 .filter(|local| {
500 project
501 .manifest
502 .local
503 .iter()
504 .find(|entry| entry.path == local.manifest_path)
505 .is_none_or(|entry| !entry.bound)
506 })
507 .collect();
508
509 if !unbound_locals.is_empty() {
510 builder.append_line("## Additional instructions");
511 builder.append_empty_line();
512 }
513 for local in unbound_locals {
514 if local.content.is_empty() && local.optional {
515 continue;
516 }
517 builder.append_line(&format!("### {}", local.manifest_path));
518 let content = substitute_pray_symbols(&local.content, &project.manifest.symbols)?;
519 builder.append_body(&content);
520 builder.append_empty_line();
521 }
522
523 builder.append_line("## Shared instructions");
524 builder.append_empty_line();
525
526 let mut managed_spans = Vec::new();
527 for package in &project.packages {
528 if !package_matches_environment(&package.declaration.groups, project.environment.as_deref())
529 {
530 continue;
531 }
532 if !package_bound_to_compose(&package.declaration, target) {
533 continue;
534 }
535 for export in &package.selected_exports {
536 if !should_inline_export(package, export) {
537 continue;
538 }
539 append_managed_export(
540 &mut builder,
541 &mut managed_spans,
542 package,
543 export,
544 target,
545 output,
546 &project.manifest.symbols,
547 )?;
548 }
549 }
550
551 Ok(RenderedTarget {
552 path: output.to_path_buf(),
553 content: builder.finish(),
554 managed_spans,
555 })
556}
557
558fn append_managed_export(
559 builder: &mut ContentBuilder,
560 managed_spans: &mut Vec<ManagedSpanRecord>,
561 package: &crate::resolve::ResolvedPackage,
562 export: &str,
563 target: &crate::manifest::ManifestTarget,
564 output: &Path,
565 symbols: &std::collections::BTreeMap<String, String>,
566) -> PrayResult<()> {
567 let raw = package.export_bodies.get(export).ok_or_else(|| {
568 PrayError::Render(format!(
569 "package {} is missing cached export {}",
570 package.declaration.name, export
571 ))
572 })?;
573 let body = substitute_pray_symbols(raw, symbols)?;
574 let id = marker_id(&format!(
575 "{}:{}:{}",
576 package.declaration.name, export, target.name
577 ));
578 let open_line = builder.next_line_number();
579 builder.append_line(&format!("<!-- pray:{id} -->"));
580 builder.append_body(&body);
581 let close_line = builder.next_line_number();
582 builder.append_line(&format!("<!-- pray:{id} -->"));
583 managed_spans.push(ManagedSpanRecord {
584 id,
585 target: output.to_string_lossy().to_string(),
586 open_line,
587 close_line,
588 ideal_checksum: checksum_managed_span_content(&body),
589 package: package.declaration.name.clone(),
590 export: export.to_string(),
591 source_checksum: package.source_checksum.clone(),
592 silenced: false,
593 });
594 builder.append_empty_line();
595 Ok(())
596}