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