Skip to main content

pray_core/
format_manifest.rs

1use crate::destination::{
2    bind_local_entry, bind_package_entry, export_kind_matches_role, new_destination_target,
3};
4use crate::format_serialize::{serialize_recommended, target_has_extras};
5use crate::manifest::{
6    DestinationMode, ExportRole, Manifest, ManifestLocal, ManifestPackage, ManifestTarget,
7};
8use crate::resolve::ResolvedProject;
9use crate::{PrayError, PrayResult};
10use std::collections::{BTreeMap, BTreeSet};
11
12#[derive(Debug, Clone, Default)]
13pub struct PackageFormatHint {
14    pub roles: Vec<ExportRole>,
15    pub file_path: Option<String>,
16    /// Export names that must stay explicit after migration when a role is ambiguous.
17    pub exports: Vec<String>,
18}
19
20pub fn uses_destination_dsl(manifest: &Manifest) -> bool {
21    manifest
22        .targets
23        .iter()
24        .any(|target| target.scoped || target.mode != DestinationMode::Legacy)
25        || manifest
26            .packages
27            .iter()
28            .any(|package| package.bound || package.file.is_some())
29        || manifest.local.iter().any(|local| local.bound)
30}
31
32fn has_migratable_legacy_targets(manifest: &Manifest) -> bool {
33    manifest.targets.iter().any(|target| {
34        !target.scoped
35            && target.mode == DestinationMode::Legacy
36            && (!target.outputs.is_empty() || !target.skills.is_empty())
37    })
38}
39
40pub fn classify_format_hints(project: &ResolvedProject) -> BTreeMap<String, PackageFormatHint> {
41    let mut hints = BTreeMap::new();
42    for package in &project.packages {
43        let mut roles = Vec::new();
44        let mut file_path = package.declaration.file.clone();
45        for export_name in &package.selected_exports {
46            let Some(export) = package.spec.exports.get(export_name) else {
47                continue;
48            };
49            for role in [ExportRole::Fragment, ExportRole::Folder, ExportRole::File] {
50                if export_kind_matches_role(&export.kind, role) && !roles.contains(&role) {
51                    roles.push(role);
52                }
53            }
54            if file_path.is_none() && export.kind == "file" {
55                file_path = export
56                    .default_path
57                    .clone()
58                    .or_else(|| Some(export_name.clone()));
59            }
60        }
61        let exports =
62            ambiguous_exports_for_roles(&package.selected_exports, &package.spec.exports, &roles);
63        hints.insert(
64            package.declaration.name.clone(),
65            PackageFormatHint {
66                roles,
67                file_path,
68                exports,
69            },
70        );
71    }
72    hints
73}
74
75fn ambiguous_exports_for_roles(
76    selected_exports: &[String],
77    exports: &BTreeMap<String, crate::package_spec::PackageExport>,
78    roles: &[ExportRole],
79) -> Vec<String> {
80    let mut ambiguous = Vec::new();
81    for role in roles {
82        let matching: Vec<String> = selected_exports
83            .iter()
84            .filter(|export_name| {
85                exports
86                    .get(*export_name)
87                    .is_some_and(|export| export_kind_matches_role(&export.kind, *role))
88            })
89            .cloned()
90            .collect();
91        if matching.len() > 1 {
92            for export_name in matching {
93                if !ambiguous.contains(&export_name) {
94                    ambiguous.push(export_name);
95                }
96            }
97        }
98    }
99    ambiguous
100}
101
102pub fn recommend_manifest(
103    manifest: &Manifest,
104    hints: &BTreeMap<String, PackageFormatHint>,
105) -> Manifest {
106    // Top-level file: bindings alone must not skip migration of legacy target blocks.
107    let mut recommended = if has_migratable_legacy_targets(manifest) {
108        migrate_legacy_manifest(manifest, hints)
109    } else {
110        manifest.clone()
111    };
112    omit_context_resolved_exports(&mut recommended);
113    omit_default_sources(&mut recommended);
114    recommended.deprecated_keywords.clear();
115    recommended
116}
117
118fn omit_context_resolved_exports(manifest: &mut Manifest) {
119    for package in &mut manifest.packages {
120        if package.bound && package.exports.len() <= 1 {
121            package.exports.clear();
122        }
123    }
124}
125
126fn package_namespace(name: &str) -> Option<&str> {
127    name.split_once('/').map(|(namespace, _)| namespace)
128}
129
130fn omit_default_sources(manifest: &mut Manifest) {
131    let sole_source = (manifest.sources.len() == 1).then(|| manifest.sources[0].name.clone());
132    let source_names: BTreeSet<&str> = manifest
133        .sources
134        .iter()
135        .map(|source| source.name.as_str())
136        .collect();
137    for package in &mut manifest.packages {
138        let Some(source) = package.source.as_deref() else {
139            continue;
140        };
141        let matches_sole = sole_source.as_deref() == Some(source);
142        let matches_namespace =
143            package_namespace(&package.name) == Some(source) && source_names.contains(source);
144        if matches_sole || matches_namespace {
145            package.source = None;
146        }
147    }
148}
149
150pub fn format_recommended(
151    manifest: &Manifest,
152    hints: &BTreeMap<String, PackageFormatHint>,
153) -> PrayResult<String> {
154    let recommended = recommend_manifest(manifest, hints);
155    let text = serialize_recommended(&recommended);
156    let reparsed = crate::manifest::parse_manifest(&text)?;
157    if reparsed.canonicalized() != recommended.canonicalized() {
158        return Err(PrayError::Manifest(
159            "formatted Prayfile did not round-trip to an equivalent manifest".to_string(),
160        ));
161    }
162    Ok(text)
163}
164
165fn migrate_legacy_manifest(
166    manifest: &Manifest,
167    hints: &BTreeMap<String, PackageFormatHint>,
168) -> Manifest {
169    let mut next = Manifest {
170        prayfile_version: manifest.prayfile_version.clone(),
171        sources: manifest.sources.clone(),
172        targets: Vec::new(),
173        packages: manifest.packages.clone(),
174        local: manifest.local.clone(),
175        symbols: manifest.symbols.clone(),
176        render: manifest.render.clone(),
177        deprecated_keywords: Vec::new(),
178    };
179
180    apply_format_hints(&mut next.packages, hints);
181
182    let compose_paths = unique_paths(manifest.targets.iter().flat_map(|target| {
183        target
184            .outputs
185            .iter()
186            .map(move |path| (path.clone(), target.name.clone()))
187    }));
188    let tree_paths = unique_paths(manifest.targets.iter().flat_map(|target| {
189        target
190            .skills
191            .iter()
192            .map(move |path| (path.clone(), target.name.clone()))
193    }));
194
195    for (path, target_names) in &compose_paths {
196        let mut target = new_destination_target(DestinationMode::Compose, path);
197        for local in locals_for_compose(&next.local) {
198            bind_local_entry(&mut target, &local.path);
199            if let Some(entry) = next
200                .local
201                .iter_mut()
202                .find(|candidate| candidate.path == local.path)
203            {
204                entry.bound = true;
205            }
206        }
207        for package in packages_for_role(&next.packages, ExportRole::Fragment, target_names) {
208            bind_package_entry(&mut target, &package.name);
209            mark_package_bound(&mut next.packages, &package.name, ExportRole::Fragment);
210        }
211        next.targets.push(target);
212    }
213
214    for (path, target_names) in &tree_paths {
215        let mut target = new_destination_target(DestinationMode::Tree, path);
216        for package in packages_for_role(&next.packages, ExportRole::Folder, target_names) {
217            bind_package_entry(&mut target, &package.name);
218            mark_package_bound(&mut next.packages, &package.name, ExportRole::Folder);
219        }
220        next.targets.push(target);
221    }
222
223    for package in &mut next.packages {
224        if package.file.is_some() {
225            package.bound = true;
226            if !package.roles.contains(&ExportRole::File) {
227                package.roles.push(ExportRole::File);
228            }
229        }
230    }
231    for local in &mut next.local {
232        if local.bound {
233            // Compose entry order replaces legacy position keywords.
234            local.position = "after".to_string();
235        }
236    }
237
238    for target in &manifest.targets {
239        if target_has_extras(target) {
240            next.targets.push(ManifestTarget {
241                name: target.name.clone(),
242                commands: target.commands.clone(),
243                rules: target.rules.clone(),
244                max_bytes: target.max_bytes,
245                ..ManifestTarget::default()
246            });
247        }
248    }
249
250    next
251}
252
253fn apply_format_hints(
254    packages: &mut [ManifestPackage],
255    hints: &BTreeMap<String, PackageFormatHint>,
256) {
257    for package in packages {
258        if let Some(hint) = hints.get(&package.name) {
259            for role in &hint.roles {
260                if !package.roles.contains(role) {
261                    package.roles.push(*role);
262                }
263            }
264            if package.file.is_none() {
265                package.file = hint.file_path.clone();
266            }
267            if package.exports.is_empty() && !hint.exports.is_empty() {
268                package.exports = hint.exports.clone();
269            }
270        }
271        if package.file.is_some() && !package.roles.contains(&ExportRole::File) {
272            package.roles.push(ExportRole::File);
273        }
274    }
275}
276
277fn unique_paths(items: impl Iterator<Item = (String, String)>) -> Vec<(String, BTreeSet<String>)> {
278    let mut map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
279    for (path, target_name) in items {
280        map.entry(path).or_default().insert(target_name);
281    }
282    map.into_iter().collect()
283}
284
285fn locals_for_compose(locals: &[ManifestLocal]) -> Vec<ManifestLocal> {
286    let mut before = Vec::new();
287    let mut after = Vec::new();
288    for local in locals {
289        if local.bound {
290            continue;
291        }
292        match local.position.as_str() {
293            "start" | "before" => before.push(local.clone()),
294            _ => after.push(local.clone()),
295        }
296    }
297    before.extend(after);
298    before
299}
300
301fn packages_for_role(
302    packages: &[ManifestPackage],
303    role: ExportRole,
304    target_names: &BTreeSet<String>,
305) -> Vec<ManifestPackage> {
306    packages
307        .iter()
308        .filter(|package| {
309            if package.file.is_some() {
310                return false;
311            }
312            if !package.targets.is_empty()
313                && !package
314                    .targets
315                    .iter()
316                    .any(|name| target_names.contains(name))
317            {
318                return false;
319            }
320            package.roles.contains(&role)
321        })
322        .cloned()
323        .collect()
324}
325
326fn mark_package_bound(packages: &mut [ManifestPackage], name: &str, role: ExportRole) {
327    if let Some(package) = packages.iter_mut().find(|package| package.name == name) {
328        package.bound = true;
329        if !package.roles.contains(&role) {
330            package.roles.push(role);
331        }
332    }
333}