Skip to main content

pray_core/
render_provisioned.rs

1use crate::destination::package_bound_to_tree;
2use crate::environment::package_matches_environment;
3use crate::paths::validate_project_relative_path;
4use crate::resolve::ResolvedProject;
5use crate::substitute::substitute_pray_symbols;
6use crate::{PrayError, PrayResult};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone)]
11pub struct PlannedProvisionedFile {
12    pub path: PathBuf,
13    pub source: PathBuf,
14}
15
16pub fn planned_provisioned_files(
17    project: &ResolvedProject,
18) -> PrayResult<Vec<PlannedProvisionedFile>> {
19    let mut planned = Vec::new();
20    collect_exact_file_bindings(project, &mut planned)?;
21    for target in &project.manifest.targets {
22        for folder_root in &target.skills {
23            let destination_root = project.project_root.join(folder_root);
24            for package in &project.packages {
25                if !package.explicit {
26                    continue;
27                }
28                if !package_matches_environment(
29                    &package.declaration.groups,
30                    project.environment.as_deref(),
31                ) {
32                    continue;
33                }
34                if !package_bound_to_tree(&package.declaration, target) {
35                    continue;
36                }
37                collect_legacy_skill_files(project, package, &destination_root, &mut planned)?;
38                collect_selected_export_files(project, package, &destination_root, &mut planned)?;
39            }
40        }
41    }
42    planned.sort_by(|left, right| left.path.cmp(&right.path));
43    planned.dedup_by(|left, right| left.path == right.path);
44    Ok(planned)
45}
46
47pub fn materialize_provisioned_exports(project: &ResolvedProject) -> PrayResult<()> {
48    for file in planned_provisioned_files(project)? {
49        let relative = validate_project_relative_path(&file.path.to_string_lossy())?;
50        let destination = relative.join_root(&project.project_root);
51        if let Some(parent) = destination.parent() {
52            fs::create_dir_all(parent)?;
53        }
54        write_provisioned_file(&file.source, &destination, &project.manifest.symbols)?;
55    }
56    Ok(())
57}
58
59pub fn expected_provisioned_bytes(
60    source: &Path,
61    symbols: &std::collections::BTreeMap<String, String>,
62) -> PrayResult<Vec<u8>> {
63    let bytes = fs::read(source)?;
64    match String::from_utf8(bytes) {
65        Ok(text) => Ok(substitute_pray_symbols(&text, symbols)?.into_bytes()),
66        Err(error) => Ok(error.into_bytes()),
67    }
68}
69
70fn write_provisioned_file(
71    source: &Path,
72    destination: &Path,
73    symbols: &std::collections::BTreeMap<String, String>,
74) -> PrayResult<()> {
75    fs::write(destination, expected_provisioned_bytes(source, symbols)?)?;
76    Ok(())
77}
78
79fn collect_exact_file_bindings(
80    project: &ResolvedProject,
81    planned: &mut Vec<PlannedProvisionedFile>,
82) -> PrayResult<()> {
83    for package in &project.packages {
84        if !package.explicit {
85            continue;
86        }
87        let Some(destination) = &package.declaration.file else {
88            continue;
89        };
90        if !package_matches_environment(&package.declaration.groups, project.environment.as_deref())
91        {
92            continue;
93        }
94        let mut matched = false;
95        for export_name in &package.selected_exports {
96            let Some(export) = package.spec.exports.get(export_name) else {
97                continue;
98            };
99            if export.kind != "file" {
100                continue;
101            }
102            let source = package.root.join(&export.path);
103            if !source.is_file() {
104                return Err(PrayError::Render(format!(
105                    "file export source missing: {}",
106                    source.display()
107                )));
108            }
109            let relative = validate_project_relative_path(destination)?;
110            planned.push(PlannedProvisionedFile {
111                path: relative.as_path().to_path_buf(),
112                source,
113            });
114            matched = true;
115            break;
116        }
117        if !matched {
118            return Err(PrayError::Render(format!(
119                "package {} has file: \"{}\" but no selected file export",
120                package.declaration.name, destination
121            )));
122        }
123    }
124    Ok(())
125}
126
127fn relative_project_path(project: &ResolvedProject, absolute: &Path) -> PathBuf {
128    absolute
129        .strip_prefix(&project.project_root)
130        .map(Path::to_path_buf)
131        .unwrap_or_else(|_| absolute.to_path_buf())
132}
133
134fn collect_legacy_skill_files(
135    project: &ResolvedProject,
136    package: &crate::resolve::ResolvedPackage,
137    destination_root: &Path,
138    planned: &mut Vec<PlannedProvisionedFile>,
139) -> PrayResult<()> {
140    for (skill_name, skill) in &package.spec.skills {
141        if legacy_skill_covered_by_export(package, skill) {
142            continue;
143        }
144        let skill_files = package.skill_files.get(skill_name).ok_or_else(|| {
145            PrayError::Render(format!(
146                "package {} has no indexed files for legacy skill {}",
147                package.declaration.name, skill_name
148            ))
149        })?;
150        collect_tree_files(
151            project,
152            &package.root.join(&skill.path),
153            &destination_root.join(skill_name),
154            skill_files,
155            &[],
156            &[],
157            planned,
158        )?;
159    }
160    Ok(())
161}
162
163fn legacy_skill_covered_by_export(
164    package: &crate::resolve::ResolvedPackage,
165    skill: &crate::package_spec::PackageSkill,
166) -> bool {
167    package.spec.exports.iter().any(|(export_name, export)| {
168        package.selected_exports.contains(export_name)
169            && matches!(export.kind.as_str(), "folder" | "skill")
170            && export.path.trim_end_matches('/') == skill.path.trim_end_matches('/')
171    })
172}
173
174fn collect_selected_export_files(
175    project: &ResolvedProject,
176    package: &crate::resolve::ResolvedPackage,
177    destination_root: &Path,
178    planned: &mut Vec<PlannedProvisionedFile>,
179) -> PrayResult<()> {
180    for export_name in &package.selected_exports {
181        let Some(export) = package.spec.exports.get(export_name) else {
182            continue;
183        };
184        match export.kind.as_str() {
185            "folder" | "skill" => {
186                let indexed_files = package.skill_files.get(export_name).ok_or_else(|| {
187                    PrayError::Render(format!(
188                        "package {} has no indexed files for folder export {}",
189                        package.declaration.name, export_name
190                    ))
191                })?;
192                let destination_name = folder_destination_name(export_name, &export.path);
193                collect_tree_files(
194                    project,
195                    &package.root.join(&export.path),
196                    &destination_root.join(destination_name),
197                    indexed_files,
198                    &export.only,
199                    &export.except,
200                    planned,
201                )?;
202            }
203            "file" => {
204                if package.declaration.file.is_some() {
205                    continue;
206                }
207                let source = package.root.join(&export.path);
208                if !source.is_file() {
209                    return Err(PrayError::Render(format!(
210                        "file export source missing: {}",
211                        source.display()
212                    )));
213                }
214                let file_name =
215                    source
216                        .file_name()
217                        .map(|name| name.to_owned())
218                        .ok_or_else(|| {
219                            PrayError::Render(format!(
220                                "file export path has no file name: {}",
221                                export.path
222                            ))
223                        })?;
224                let destination = destination_root.join(export_name).join(file_name);
225                planned.push(PlannedProvisionedFile {
226                    path: relative_project_path(project, &destination),
227                    source,
228                });
229            }
230            _ => {}
231        }
232    }
233    Ok(())
234}
235
236fn folder_destination_name(export_name: &str, export_path: &str) -> String {
237    Path::new(export_path.trim_end_matches('/'))
238        .file_name()
239        .map(|name| name.to_string_lossy().to_string())
240        .unwrap_or_else(|| export_name.to_string())
241}
242
243fn collect_tree_files(
244    project: &ResolvedProject,
245    source_root: &Path,
246    destination_root: &Path,
247    relative_files: &[String],
248    only: &[String],
249    except: &[String],
250    planned: &mut Vec<PlannedProvisionedFile>,
251) -> PrayResult<()> {
252    if !source_root.is_dir() {
253        return Err(PrayError::Render(format!(
254            "folder source directory missing: {}",
255            source_root.display()
256        )));
257    }
258
259    if relative_files.is_empty() {
260        return Err(PrayError::Render(format!(
261            "no files listed in package manifest for {}",
262            source_root.display()
263        )));
264    }
265
266    let mut matched = false;
267    for relative in relative_files {
268        if !only.is_empty() && !only.iter().any(|entry| entry == relative) {
269            continue;
270        }
271        if except.iter().any(|entry| entry == relative) {
272            continue;
273        }
274        let source = source_root.join(relative);
275        if !source.is_file() {
276            return Err(PrayError::Render(format!(
277                "provisioned file missing: {}",
278                source.display()
279            )));
280        }
281        let destination = destination_root.join(relative);
282        planned.push(PlannedProvisionedFile {
283            path: relative_project_path(project, &destination),
284            source,
285        });
286        matched = true;
287    }
288
289    if !matched && only.is_empty() && except.is_empty() {
290        return Err(PrayError::Render(format!(
291            "no files listed in package manifest for {}",
292            source_root.display()
293        )));
294    }
295
296    Ok(())
297}