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}