Skip to main content

pray_core/
render.rs

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}