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}