Skip to main content

pray_core/
manifest.rs

1use crate::hashing::sha256_prefixed;
2use crate::literal::{
3    find_top_level, is_balanced, parse_literal, prepare_parser_lines, split_top_level, LiteralValue,
4};
5use crate::statement_surface::{split_symbol_assignment, SurfaceStatementReader};
6use crate::{PrayError, PrayResult};
7use serde::{Deserialize, Serialize};
8use std::borrow::Cow;
9use std::collections::BTreeMap;
10use std::fs;
11use std::io;
12use std::path::Path;
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
15pub struct Manifest {
16    pub prayfile_version: String,
17    pub sources: Vec<ManifestSource>,
18    pub targets: Vec<ManifestTarget>,
19    pub packages: Vec<ManifestPackage>,
20    pub local: Vec<ManifestLocal>,
21    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
22    pub symbols: BTreeMap<String, String>,
23    pub render: RenderPolicy,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct ManifestSource {
28    pub name: String,
29    pub kind: String,
30    pub url: String,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub subdir: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub rev: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub tag: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "snake_case")]
41pub enum DestinationMode {
42    #[default]
43    Legacy,
44    Compose,
45    Tree,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum DestinationEntry {
51    Package { name: String },
52    Local { path: String },
53}
54
55#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum ExportRole {
58    Fragment,
59    Folder,
60    File,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ManifestTarget {
65    pub name: String,
66    #[serde(default)]
67    pub outputs: Vec<String>,
68    #[serde(default)]
69    pub skills: Vec<String>,
70    #[serde(default)]
71    pub commands: Vec<String>,
72    #[serde(default)]
73    pub rules: Vec<String>,
74    pub max_bytes: Option<u64>,
75    #[serde(default)]
76    pub mode: DestinationMode,
77    #[serde(default)]
78    pub scoped: bool,
79    #[serde(default)]
80    pub entries: Vec<DestinationEntry>,
81}
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
84pub struct ManifestPackage {
85    pub name: String,
86    #[serde(default = "default_constraint")]
87    pub constraint: String,
88    pub source: Option<String>,
89    #[serde(default)]
90    pub exports: Vec<String>,
91    #[serde(default)]
92    pub targets: Vec<String>,
93    #[serde(default)]
94    pub features: Vec<String>,
95    #[serde(default)]
96    pub groups: Vec<String>,
97    #[serde(default)]
98    pub optional: bool,
99    pub path: Option<String>,
100    pub git: Option<String>,
101    pub tag: Option<String>,
102    pub rev: Option<String>,
103    pub tarball: Option<String>,
104    pub oci: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub file: Option<String>,
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub roles: Vec<ExportRole>,
109    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
110    pub bound: bool,
111}
112
113fn default_constraint() -> String {
114    "*".to_string()
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
118pub struct ManifestLocal {
119    pub path: String,
120    #[serde(default = "default_local_position")]
121    pub position: String,
122    #[serde(default)]
123    pub optional: bool,
124    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
125    pub bound: bool,
126}
127
128fn default_local_position() -> String {
129    "after".to_string()
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct RenderPolicy {
134    pub mode: String,
135    pub conflict: String,
136    pub churn: String,
137    pub header: bool,
138    pub section_markers: bool,
139    pub line_endings: String,
140}
141
142impl Default for RenderPolicy {
143    fn default() -> Self {
144        Self {
145            mode: "managed".to_string(),
146            conflict: "fail".to_string(),
147            churn: "minimal".to_string(),
148            header: true,
149            section_markers: true,
150            line_endings: "lf".to_string(),
151        }
152    }
153}
154
155impl Manifest {
156    pub fn canonicalized(&self) -> Self {
157        let mut manifest = self.clone();
158        manifest
159            .sources
160            .sort_by(|left, right| left.name.cmp(&right.name));
161        manifest
162            .targets
163            .sort_by(|left, right| left.name.cmp(&right.name));
164        manifest.packages.sort_by(|left, right| {
165            left.name
166                .cmp(&right.name)
167                .then(left.source.cmp(&right.source))
168                .then(left.constraint.cmp(&right.constraint))
169        });
170        manifest
171            .local
172            .sort_by(|left, right| left.path.cmp(&right.path));
173        manifest
174    }
175
176    pub fn manifest_hash(&self) -> PrayResult<String> {
177        let canonical = self.canonicalized();
178        let bytes = serde_json::to_vec(&canonical)
179            .map_err(|error| PrayError::Manifest(error.to_string()))?;
180        Ok(sha256_prefixed(&bytes))
181    }
182}
183
184pub fn read_manifest_text(manifest_path: &Path) -> PrayResult<String> {
185    fs::read_to_string(manifest_path).map_err(|error| {
186        if error.kind() == io::ErrorKind::NotFound {
187            let manifest_label = manifest_path
188                .file_name()
189                .and_then(|name| name.to_str())
190                .unwrap_or("Prayfile");
191            PrayError::Manifest(format!(
192                "missing {manifest_label}; run pray init to create one"
193            ))
194        } else {
195            PrayError::Io(error)
196        }
197    })
198}
199
200pub fn parse_manifest(text: &str) -> PrayResult<Manifest> {
201    let lines = prepare_parser_lines(text);
202    let mut parser = BlockParser::new(&lines);
203    parser.parse_root()
204}
205
206struct BlockParser<'a> {
207    lines: &'a [Cow<'a, str>],
208    cursor: usize,
209    group_stack: Vec<Vec<String>>,
210    surface: SurfaceStatementReader,
211}
212
213impl<'a> BlockParser<'a> {
214    fn new(lines: &'a [Cow<'a, str>]) -> Self {
215        Self {
216            lines,
217            cursor: 0,
218            group_stack: Vec::new(),
219            surface: SurfaceStatementReader::default(),
220        }
221    }
222
223    fn parse_root(&mut self) -> PrayResult<Manifest> {
224        let mut manifest = Manifest::default();
225        while let Some(statement) = self.next_statement()? {
226            if statement == "end" {
227                return Err(PrayError::Parse {
228                    kind: "manifest",
229                    message: "unexpected 'end'".to_string(),
230                });
231            }
232            self.apply_statement(&mut manifest, statement, false)?;
233        }
234        if manifest.prayfile_version.is_empty() {
235            return Err(PrayError::Manifest("missing prayfile version".to_string()));
236        }
237        Ok(manifest)
238    }
239
240    fn apply_statement(
241        &mut self,
242        manifest: &mut Manifest,
243        statement: String,
244        allow_target: bool,
245    ) -> PrayResult<()> {
246        if let Some(rest) = statement.strip_prefix("prayfile ") {
247            manifest.prayfile_version = string_from_literal(rest)?;
248            return Ok(());
249        }
250        if let Some(rest) = statement.strip_prefix("source ") {
251            manifest.sources.push(parse_source(rest)?);
252            return Ok(());
253        }
254        if let Some(rest) = statement.strip_prefix("target ") {
255            if !allow_target && !statement.ends_with(" do") {
256                return Err(PrayError::Parse {
257                    kind: "manifest",
258                    message: "target must use a block".to_string(),
259                });
260            }
261            let (target, is_block) = parse_target_header(rest)?;
262            manifest.targets.push(target);
263            if is_block {
264                let index = manifest.targets.len() - 1;
265                self.parse_target_block(manifest, index)?;
266            }
267            return Ok(());
268        }
269        if let Some(rest) = statement.strip_prefix("group ") {
270            let (group_names, is_block) = parse_group_header(rest)?;
271            if !is_block {
272                return Err(PrayError::Parse {
273                    kind: "manifest",
274                    message: "group must use a block".to_string(),
275                });
276            }
277            if !self.group_stack.is_empty() {
278                return Err(PrayError::Parse {
279                    kind: "manifest",
280                    message: "nested group blocks are not supported".to_string(),
281                });
282            }
283            self.group_stack.push(group_names);
284            self.parse_group_block(manifest)?;
285            self.group_stack.pop();
286            return Ok(());
287        }
288        if let Some(rest) = statement.strip_prefix("agent ") {
289            crate::destination::upsert_package(manifest, self.parse_package_with_groups(rest)?)?;
290            return Ok(());
291        }
292        if let Some(rest) = statement.strip_prefix("package ") {
293            crate::destination::upsert_package(manifest, self.parse_package_with_groups(rest)?)?;
294            return Ok(());
295        }
296        if statement == "pray do" || statement == "template do" {
297            self.parse_symbols_block(manifest)?;
298            return Ok(());
299        }
300        if let Some(rest) = statement
301            .strip_prefix("pray ")
302            .or_else(|| statement.strip_prefix("use "))
303            .or_else(|| statement.strip_prefix("include "))
304        {
305            self.apply_pray_statement(manifest, rest, None)?;
306            return Ok(());
307        }
308        if let Some(rest) = statement
309            .strip_prefix("compose ")
310            .or_else(|| statement.strip_prefix("output "))
311        {
312            if statement.starts_with("output ") && !statement.ends_with(" do") {
313                return Err(PrayError::Parse {
314                    kind: "manifest",
315                    message:
316                        "top-level output must use a compose block (output \"path\" do ... end)"
317                            .to_string(),
318                });
319            }
320            self.parse_destination_block(manifest, rest, DestinationMode::Compose)?;
321            return Ok(());
322        }
323        if let Some(rest) = statement
324            .strip_prefix("tree ")
325            .or_else(|| statement.strip_prefix("folder "))
326            .or_else(|| statement.strip_prefix("skills "))
327        {
328            if (statement.starts_with("folder ") || statement.starts_with("skills "))
329                && !statement.ends_with(" do")
330            {
331                return Err(PrayError::Parse {
332                    kind: "manifest",
333                    message: "top-level folder/skills must use a tree block".to_string(),
334                });
335            }
336            self.parse_destination_block(manifest, rest, DestinationMode::Tree)?;
337            return Ok(());
338        }
339        if let Some(rest) = statement.strip_prefix("file ") {
340            self.parse_file_block(manifest, rest)?;
341            return Ok(());
342        }
343        if let Some(rest) = statement.strip_prefix("local ") {
344            let mut local = parse_local_decl(rest)?;
345            local.bound = false;
346            crate::destination::upsert_local(manifest, local);
347            return Ok(());
348        }
349        if let Some(rest) = statement.strip_prefix("render ") {
350            manifest.render = parse_render_policy(rest)?;
351            return Ok(());
352        }
353        Err(PrayError::Parse {
354            kind: "manifest",
355            message: format!("unrecognized statement: {statement}"),
356        })
357    }
358
359    fn parse_destination_block(
360        &mut self,
361        manifest: &mut Manifest,
362        rest: &str,
363        mode: DestinationMode,
364    ) -> PrayResult<()> {
365        let is_block = rest.trim_end().ends_with("do");
366        if !is_block {
367            return Err(PrayError::Parse {
368                kind: "manifest",
369                message: format!(
370                    "{} must use a block",
371                    match mode {
372                        DestinationMode::Compose => "compose",
373                        DestinationMode::Tree => "tree",
374                        DestinationMode::Legacy => "destination",
375                    }
376                ),
377            });
378        }
379        let header = rest.trim_end_matches("do").trim();
380        let (values, _) = parse_call(header)?;
381        let path = string_from_value(values.first().ok_or_else(|| PrayError::Parse {
382            kind: "manifest",
383            message: "destination missing path".to_string(),
384        })?)?;
385        let target = crate::destination::new_destination_target(mode, &path);
386        manifest.targets.push(target);
387        let index = manifest.targets.len() - 1;
388        while let Some(statement) = self.next_statement()? {
389            if statement == "end" {
390                return Ok(());
391            }
392            if let Some(pray_rest) = statement
393                .strip_prefix("pray ")
394                .or_else(|| statement.strip_prefix("use "))
395                .or_else(|| statement.strip_prefix("include "))
396                .or_else(|| statement.strip_prefix("agent "))
397                .or_else(|| statement.strip_prefix("package "))
398            {
399                self.apply_pray_statement(manifest, pray_rest, Some(index))?;
400                continue;
401            }
402            if mode == DestinationMode::Compose {
403                if let Some(local_rest) = statement.strip_prefix("local ") {
404                    let mut local = parse_local_decl(local_rest)?;
405                    local.bound = true;
406                    crate::destination::bind_local_entry(&mut manifest.targets[index], &local.path);
407                    crate::destination::upsert_local(manifest, local);
408                    continue;
409                }
410            }
411            return Err(PrayError::Parse {
412                kind: "manifest",
413                message: format!("unsupported statement inside destination block: {statement}"),
414            });
415        }
416        Err(PrayError::Parse {
417            kind: "manifest",
418            message: "missing 'end' for destination block".to_string(),
419        })
420    }
421
422    fn parse_symbols_block(&mut self, manifest: &mut Manifest) -> PrayResult<()> {
423        while let Some(statement) = self.next_statement()? {
424            if statement == "end" {
425                return Ok(());
426            }
427            let Some((key, value_literal)) = split_symbol_assignment(&statement) else {
428                return Err(PrayError::Parse {
429                    kind: "manifest",
430                    message: format!(
431                        "unsupported statement inside pray/template block: {statement}"
432                    ),
433                });
434            };
435            if !crate::substitute::is_pray_symbol_key(&key) {
436                return Err(PrayError::Parse {
437                    kind: "manifest",
438                    message: format!("invalid pray symbol key `{key}`"),
439                });
440            }
441            if manifest.symbols.contains_key(&key) {
442                return Err(PrayError::Parse {
443                    kind: "manifest",
444                    message: format!("duplicate pray symbol `{key}`"),
445                });
446            }
447            manifest
448                .symbols
449                .insert(key, string_from_literal(&value_literal)?);
450        }
451        Err(PrayError::Parse {
452            kind: "manifest",
453            message: "missing 'end' for pray/template block".to_string(),
454        })
455    }
456
457    fn parse_file_block(&mut self, manifest: &mut Manifest, rest: &str) -> PrayResult<()> {
458        let is_block = rest.trim_end().ends_with("do");
459        if !is_block {
460            return Err(PrayError::Parse {
461                kind: "manifest",
462                message: "file must use a block (or use pray ..., file: \"path\")".to_string(),
463            });
464        }
465        let header = rest.trim_end_matches("do").trim();
466        let (values, _) = parse_call(header)?;
467        let file_path = string_from_value(values.first().ok_or_else(|| PrayError::Parse {
468            kind: "manifest",
469            message: "file block missing path".to_string(),
470        })?)?;
471        let mut saw_package = false;
472        while let Some(statement) = self.next_statement()? {
473            if statement == "end" {
474                if !saw_package {
475                    return Err(PrayError::Parse {
476                        kind: "manifest",
477                        message: "file block requires a pray package declaration".to_string(),
478                    });
479                }
480                return Ok(());
481            }
482            if let Some(pray_rest) = statement
483                .strip_prefix("pray ")
484                .or_else(|| statement.strip_prefix("use "))
485                .or_else(|| statement.strip_prefix("include "))
486                .or_else(|| statement.strip_prefix("agent "))
487                .or_else(|| statement.strip_prefix("package "))
488            {
489                let mut package = self.parse_package_with_groups(pray_rest)?;
490                if package.file.is_some() {
491                    return Err(PrayError::Parse {
492                        kind: "manifest",
493                        message: "file: keyword is invalid inside a file block".to_string(),
494                    });
495                }
496                package.file = Some(file_path.clone());
497                package.bound = true;
498                if !package.roles.contains(&ExportRole::File) {
499                    package.roles.push(ExportRole::File);
500                }
501                crate::destination::upsert_package(manifest, package)?;
502                saw_package = true;
503                continue;
504            }
505            return Err(PrayError::Parse {
506                kind: "manifest",
507                message: format!("unsupported statement inside file block: {statement}"),
508            });
509        }
510        Err(PrayError::Parse {
511            kind: "manifest",
512            message: "missing 'end' for file block".to_string(),
513        })
514    }
515
516    fn apply_pray_statement(
517        &mut self,
518        manifest: &mut Manifest,
519        rest: &str,
520        destination_index: Option<usize>,
521    ) -> PrayResult<()> {
522        let (values, keywords) = parse_call(rest)?;
523        if values.is_empty() {
524            return Err(PrayError::Parse {
525                kind: "manifest",
526                message: "pray missing package or path".to_string(),
527            });
528        }
529        let first = string_from_value(&values[0])?;
530        let has_package_signal = values.len() > 1
531            || keywords.contains_key("source")
532            || keywords.contains_key("export")
533            || keywords.contains_key("exports")
534            || keywords.contains_key("file")
535            || keywords.contains_key("optional")
536            || keywords.contains_key("path")
537            || keywords.contains_key("git")
538            || keywords.contains_key("tag")
539            || keywords.contains_key("rev")
540            || keywords.contains_key("tarball")
541            || keywords.contains_key("oci")
542            || keywords.contains_key("targets")
543            || keywords.contains_key("features");
544
545        let in_compose = destination_index.is_some_and(|index| {
546            manifest
547                .targets
548                .get(index)
549                .is_some_and(|target| target.mode == DestinationMode::Compose)
550        });
551
552        if !has_package_signal && crate::destination::is_local_path_form(&first) {
553            if !in_compose {
554                return Err(PrayError::Parse {
555                    kind: "manifest",
556                    message: "local pray paths are only valid inside compose blocks".to_string(),
557                });
558            }
559            let local = ManifestLocal {
560                path: first,
561                position: "after".to_string(),
562                optional: false,
563                bound: true,
564            };
565            if let Some(index) = destination_index {
566                crate::destination::bind_local_entry(&mut manifest.targets[index], &local.path);
567            }
568            crate::destination::upsert_local(manifest, local);
569            return Ok(());
570        }
571
572        let mut package = parse_package_decl(rest)?;
573        package.groups = self.group_stack.last().cloned().unwrap_or_default();
574        if package.file.is_some() {
575            if destination_index.is_some() {
576                return Err(PrayError::Parse {
577                    kind: "manifest",
578                    message: "file: is mutually exclusive with compose/tree nesting".to_string(),
579                });
580            }
581            package.bound = true;
582            if !package.roles.contains(&ExportRole::File) {
583                package.roles.push(ExportRole::File);
584            }
585        }
586        if let Some(index) = destination_index {
587            let mode = manifest.targets[index].mode;
588            package.bound = true;
589            if let Some(role) = crate::destination::role_for_destination(mode) {
590                if !package.roles.contains(&role) {
591                    package.roles.push(role);
592                }
593            }
594            crate::destination::bind_package_entry(&mut manifest.targets[index], &package.name);
595        }
596        crate::destination::upsert_package(manifest, package)?;
597        Ok(())
598    }
599
600    fn parse_group_block(&mut self, manifest: &mut Manifest) -> PrayResult<()> {
601        while let Some(statement) = self.next_statement()? {
602            if statement == "end" {
603                return Ok(());
604            }
605            if let Some(rest) = statement
606                .strip_prefix("agent ")
607                .or_else(|| statement.strip_prefix("package "))
608                .or_else(|| statement.strip_prefix("pray "))
609                .or_else(|| statement.strip_prefix("use "))
610            {
611                crate::destination::upsert_package(
612                    manifest,
613                    self.parse_package_with_groups(rest)?,
614                )?;
615                continue;
616            }
617            return Err(PrayError::Parse {
618                kind: "manifest",
619                message: format!(
620                    "group blocks only support agent, package, or pray declarations: {statement}"
621                ),
622            });
623        }
624        Err(PrayError::Parse {
625            kind: "manifest",
626            message: "missing 'end' for group block".to_string(),
627        })
628    }
629
630    fn parse_package_with_groups(&self, rest: &str) -> PrayResult<ManifestPackage> {
631        let mut package = parse_package_decl(rest)?;
632        package.groups = self.group_stack.last().cloned().unwrap_or_default();
633        Ok(package)
634    }
635
636    fn parse_target_block(
637        &mut self,
638        manifest: &mut Manifest,
639        target_index: usize,
640    ) -> PrayResult<()> {
641        while let Some(statement) = self.next_statement()? {
642            if statement == "end" {
643                return Ok(());
644            }
645            let target = manifest
646                .targets
647                .get_mut(target_index)
648                .ok_or_else(|| PrayError::Manifest("target index out of range".to_string()))?;
649            apply_target_statement(target, statement)?;
650        }
651        Err(PrayError::Parse {
652            kind: "manifest",
653            message: "missing 'end' for target block".to_string(),
654        })
655    }
656
657    fn next_statement(&mut self) -> PrayResult<Option<String>> {
658        if let Some(pending) = self.surface.next() {
659            return Ok(Some(pending));
660        }
661        while self.cursor < self.lines.len() {
662            let mut statement = self.lines[self.cursor].trim().to_string();
663            self.cursor += 1;
664            if statement.is_empty() {
665                continue;
666            }
667            while !statement.ends_with(" do")
668                && statement != "end"
669                && self.cursor < self.lines.len()
670                && (statement.trim_end().ends_with(',') || !is_balanced(&statement))
671            {
672                let next = self.lines[self.cursor].trim();
673                self.cursor += 1;
674                if next.is_empty() {
675                    continue;
676                }
677                statement.push(' ');
678                statement.push_str(next);
679            }
680            self.surface.push_raw(statement);
681            if let Some(normalized) = self.surface.next() {
682                return Ok(Some(normalized));
683            }
684        }
685        Ok(None)
686    }
687}
688
689fn parse_source(rest: &str) -> PrayResult<ManifestSource> {
690    let (values, keywords) = parse_call(rest)?;
691    if values.is_empty() {
692        return Err(PrayError::Parse {
693            kind: "manifest",
694            message: "source requires a name".to_string(),
695        });
696    }
697    if values.len() < 2 && !keywords.contains_key("path") && !keywords.contains_key("git") {
698        return Err(PrayError::Parse {
699            kind: "manifest",
700            message: "source requires a name and url, path:, or git:".to_string(),
701        });
702    }
703    let name = string_from_value(values.first().ok_or_else(|| PrayError::Parse {
704        kind: "manifest",
705        message: "source missing name".to_string(),
706    })?)?;
707    let (kind, url) = if let Some(path) = keywords.get("path") {
708        ("path".to_string(), string_from_value(path)?)
709    } else if let Some(git) = keywords.get("git") {
710        let mut url = string_from_value(git)?;
711        if !url.starts_with("git+") {
712            url = format!("git+{url}");
713        }
714        (String::from("git"), url)
715    } else {
716        let url = string_from_value(values.get(1).ok_or_else(|| PrayError::Parse {
717            kind: "manifest",
718            message: "source missing url".to_string(),
719        })?)?;
720        let kind = if url.starts_with("git+") {
721            "git"
722        } else if url.starts_with("pray+ssh://") || url.starts_with("ssh+pray://") {
723            "pray_ssh"
724        } else {
725            "registry"
726        };
727        (kind.to_string(), url)
728    };
729    let subdir = keywords
730        .get("subdir")
731        .or_else(|| keywords.get("distribution"))
732        .map(string_from_value)
733        .transpose()?;
734    let rev = keywords.get("rev").map(string_from_value).transpose()?;
735    let tag = keywords.get("tag").map(string_from_value).transpose()?;
736    Ok(ManifestSource {
737        name,
738        kind,
739        url,
740        subdir,
741        rev,
742        tag,
743    })
744}
745
746fn parse_target_header(rest: &str) -> PrayResult<(ManifestTarget, bool)> {
747    let is_block = rest.trim_end().ends_with("do");
748    let header = rest.trim_end_matches("do").trim();
749    let (values, keywords) = parse_call(header)?;
750    let name = string_from_value(values.first().ok_or_else(|| PrayError::Parse {
751        kind: "manifest",
752        message: "target missing name".to_string(),
753    })?)?;
754    let outputs = keyword_array(&keywords, "output");
755    let mut folders = keyword_array(&keywords, "folder");
756    folders.extend(keyword_array(&keywords, "skills"));
757    let target = ManifestTarget {
758        name,
759        outputs,
760        skills: folders,
761        commands: keyword_array(&keywords, "commands"),
762        rules: keyword_array(&keywords, "rules"),
763        max_bytes: keywords
764            .get("max_bytes")
765            .and_then(|value| value.as_integer())
766            .map(|value| value as u64),
767        mode: DestinationMode::Legacy,
768        scoped: false,
769        entries: Vec::new(),
770    };
771    Ok((target, is_block))
772}
773
774fn parse_group_header(rest: &str) -> PrayResult<(Vec<String>, bool)> {
775    let is_block = rest.trim_end().ends_with("do");
776    let header = rest.trim_end_matches("do").trim();
777    let (values, _) = parse_call(header)?;
778    if values.is_empty() {
779        return Err(PrayError::Parse {
780            kind: "manifest",
781            message: "group missing name".to_string(),
782        });
783    }
784    let names = values
785        .iter()
786        .map(string_from_value)
787        .collect::<PrayResult<Vec<_>>>()?;
788    Ok((names, is_block))
789}
790
791fn parse_package_decl(rest: &str) -> PrayResult<ManifestPackage> {
792    let (values, keywords) = parse_call(rest)?;
793    if values.is_empty() {
794        return Err(PrayError::Parse {
795            kind: "manifest",
796            message: "agent missing name".to_string(),
797        });
798    }
799    let name = string_from_value(&values[0])?;
800    let constraint = if let Some(value) = values.get(1) {
801        crate::constraint::normalize_version_constraint(&string_from_value(value)?)
802    } else {
803        "*".to_string()
804    };
805    let mut exports = keyword_array(&keywords, "exports");
806    if let Some(export) = keywords.get("export").and_then(|value| value.as_string()) {
807        if !exports.contains(&export.to_string()) {
808            exports.push(export.to_string());
809        }
810    }
811    let mut roles = Vec::new();
812    let file = keywords
813        .get("file")
814        .and_then(|value| value.as_string())
815        .map(str::to_string);
816    if file.is_some() {
817        roles.push(ExportRole::File);
818    }
819    Ok(ManifestPackage {
820        name,
821        constraint,
822        source: keywords
823            .get("source")
824            .and_then(|value| value.as_string())
825            .map(str::to_string),
826        exports,
827        targets: keyword_array(&keywords, "targets"),
828        features: keyword_array(&keywords, "features"),
829        groups: Vec::new(),
830        optional: keywords
831            .get("optional")
832            .and_then(|value| value.as_bool())
833            .unwrap_or(false),
834        path: keywords
835            .get("path")
836            .and_then(|value| value.as_string())
837            .map(str::to_string),
838        git: keywords
839            .get("git")
840            .and_then(|value| value.as_string())
841            .map(str::to_string),
842        tag: keywords
843            .get("tag")
844            .and_then(|value| value.as_string())
845            .map(str::to_string),
846        rev: keywords
847            .get("rev")
848            .and_then(|value| value.as_string())
849            .map(str::to_string),
850        tarball: keywords
851            .get("tarball")
852            .and_then(|value| value.as_string())
853            .map(str::to_string),
854        oci: keywords
855            .get("oci")
856            .and_then(|value| value.as_string())
857            .map(str::to_string),
858        file,
859        roles,
860        bound: false,
861    })
862}
863
864fn parse_local_decl(rest: &str) -> PrayResult<ManifestLocal> {
865    let (values, keywords) = parse_call(rest)?;
866    let path = string_from_value(values.first().ok_or_else(|| PrayError::Parse {
867        kind: "manifest",
868        message: "local missing path".to_string(),
869    })?)?;
870    let position = keywords
871        .get("position")
872        .or_else(|| keywords.get("at"))
873        .and_then(|value| value.as_string())
874        .unwrap_or("after")
875        .to_string();
876    Ok(ManifestLocal {
877        path,
878        position,
879        optional: keywords
880            .get("optional")
881            .and_then(|value| value.as_bool())
882            .unwrap_or(false),
883        bound: false,
884    })
885}
886
887fn parse_render_policy(rest: &str) -> PrayResult<RenderPolicy> {
888    let (_, keywords) = parse_call(rest)?;
889    Ok(RenderPolicy {
890        mode: keywords
891            .get("mode")
892            .and_then(|value| value.as_string())
893            .unwrap_or("managed")
894            .to_string(),
895        conflict: keywords
896            .get("conflict")
897            .and_then(|value| value.as_string())
898            .unwrap_or("fail")
899            .to_string(),
900        churn: keywords
901            .get("churn")
902            .and_then(|value| value.as_string())
903            .unwrap_or("minimal")
904            .to_string(),
905        header: keywords
906            .get("header")
907            .and_then(|value| value.as_bool())
908            .unwrap_or(true),
909        section_markers: keywords
910            .get("section_markers")
911            .and_then(|value| value.as_bool())
912            .unwrap_or(true),
913        line_endings: keywords
914            .get("line_endings")
915            .and_then(|value| value.as_string())
916            .unwrap_or("lf")
917            .to_string(),
918    })
919}
920
921fn parse_call(rest: &str) -> PrayResult<(Vec<LiteralValue>, BTreeMap<String, LiteralValue>)> {
922    let mut positional = Vec::new();
923    let mut keywords = BTreeMap::new();
924    for segment in split_top_level(rest.trim().trim_end_matches(','), ',') {
925        if let Some((key, value)) = parse_keyword_segment(&segment)? {
926            keywords.insert(key, value);
927        } else if !segment.is_empty() {
928            positional.push(parse_literal(&segment)?);
929        }
930    }
931    Ok((positional, keywords))
932}
933
934fn parse_keyword_segment(segment: &str) -> PrayResult<Option<(String, LiteralValue)>> {
935    if let Some(index) = find_top_level(segment, "=>") {
936        let key = string_from_literal(segment[..index].trim())?;
937        return Ok(Some((key, parse_literal(segment[index + 2..].trim())?)));
938    }
939    if let Some(index) = find_top_level(segment, ":") {
940        let left = segment[..index].trim();
941        let right = segment[index + 1..].trim();
942        if left.is_empty() {
943            return Ok(None);
944        }
945        let key = left.trim().trim_start_matches(':').to_string();
946        return Ok(Some((key, parse_literal(right)?)));
947    }
948    Ok(None)
949}
950
951fn keyword_array(keywords: &BTreeMap<String, LiteralValue>, key: &str) -> Vec<String> {
952    keywords
953        .get(key)
954        .and_then(|value| value.as_array())
955        .map(|values| {
956            values
957                .iter()
958                .filter_map(|value| value.as_string().map(str::to_string))
959                .collect()
960        })
961        .unwrap_or_default()
962}
963
964fn string_from_value(value: &LiteralValue) -> PrayResult<String> {
965    value
966        .as_string()
967        .map(str::to_string)
968        .ok_or_else(|| PrayError::Parse {
969            kind: "manifest",
970            message: format!("expected string-like literal, found {:?}", value),
971        })
972}
973
974fn string_from_literal(input: &str) -> PrayResult<String> {
975    string_from_value(&parse_literal(input)?)
976}
977
978fn apply_target_statement(target: &mut ManifestTarget, statement: String) -> PrayResult<()> {
979    if let Some(rest) = statement.strip_prefix("output ") {
980        target.outputs.push(string_from_literal(rest)?);
981        return Ok(());
982    }
983    if let Some(rest) = statement.strip_prefix("folder ") {
984        target.skills.push(string_from_literal(rest)?);
985        return Ok(());
986    }
987    if let Some(rest) = statement.strip_prefix("skills ") {
988        target.skills.push(string_from_literal(rest)?);
989        return Ok(());
990    }
991    if let Some(rest) = statement.strip_prefix("commands ") {
992        target.commands.push(string_from_literal(rest)?);
993        return Ok(());
994    }
995    if let Some(rest) = statement.strip_prefix("rules ") {
996        target.rules.push(string_from_literal(rest)?);
997        return Ok(());
998    }
999    if let Some(rest) = statement.strip_prefix("max_bytes ") {
1000        let value = parse_literal(rest.trim())?;
1001        target.max_bytes = value.as_integer().map(|number| number as u64);
1002        return Ok(());
1003    }
1004    Err(PrayError::Parse {
1005        kind: "manifest",
1006        message: format!("unrecognized target statement: {statement}"),
1007    })
1008}
1009
1010pub fn format_package_declaration(package: &ManifestPackage) -> String {
1011    let mut parts = vec![format!("pray \"{}\"", package.name)];
1012    if package.constraint != "*" {
1013        parts.push(format!("\"{}\"", package.constraint));
1014    }
1015    if let Some(path) = &package.path {
1016        parts.push(format!("path: \"{path}\""));
1017    }
1018    if let Some(source) = &package.source {
1019        parts.push(format!("source: \"{source}\""));
1020    }
1021    if let Some(git) = &package.git {
1022        parts.push(format!("git: \"{git}\""));
1023    }
1024    if let Some(tag) = &package.tag {
1025        parts.push(format!("tag: \"{tag}\""));
1026    }
1027    if let Some(rev) = &package.rev {
1028        parts.push(format!("rev: \"{rev}\""));
1029    }
1030    if let Some(tarball) = &package.tarball {
1031        parts.push(format!("tarball: \"{tarball}\""));
1032    }
1033    if let Some(oci) = &package.oci {
1034        parts.push(format!("oci: \"{oci}\""));
1035    }
1036    if let Some(file) = &package.file {
1037        parts.push(format!("file: \"{file}\""));
1038    }
1039    if !package.exports.is_empty() {
1040        if package.exports.len() == 1 {
1041            parts.push(format!("export: \"{}\"", package.exports[0]));
1042        } else {
1043            parts.push(format!(
1044                "exports: [{}]",
1045                format_string_keyword_list(&package.exports)
1046            ));
1047        }
1048    }
1049    if !package.targets.is_empty() {
1050        parts.push(format!(
1051            "targets: [{}]",
1052            format_string_keyword_list(&package.targets)
1053        ));
1054    }
1055    if !package.features.is_empty() {
1056        parts.push(format!(
1057            "features: [{}]",
1058            format_string_keyword_list(&package.features)
1059        ));
1060    }
1061    if package.optional {
1062        parts.push("optional: true".to_string());
1063    }
1064    parts.join(", ")
1065}
1066
1067pub fn replace_package_declaration(text: &str, package: &ManifestPackage) -> PrayResult<String> {
1068    let name = &package.name;
1069    let prefixes = [
1070        format!("pray \"{name}\""),
1071        format!("pray '{name}'"),
1072        format!("use \"{name}\""),
1073        format!("include \"{name}\""),
1074        format!("agent \"{name}\""),
1075        format!("agent '{name}'"),
1076        format!("package \"{name}\""),
1077        format!("package '{name}'"),
1078    ];
1079    let mut lines: Vec<String> = text.lines().map(|line| line.to_string()).collect();
1080    let index = lines
1081        .iter()
1082        .position(|line| {
1083            let trimmed = line.trim_start();
1084            prefixes
1085                .iter()
1086                .any(|prefix| trimmed.starts_with(prefix.as_str()))
1087        })
1088        .ok_or_else(|| PrayError::Manifest(format!("package {name} not found in manifest")))?;
1089    lines[index] = format_package_declaration(package);
1090    let mut output = lines.join("\n");
1091    if text.ends_with('\n') && !output.ends_with('\n') {
1092        output.push('\n');
1093    }
1094    Ok(output)
1095}
1096
1097fn format_string_keyword_list(values: &[String]) -> String {
1098    values
1099        .iter()
1100        .map(|value| format!("\"{value}\""))
1101        .collect::<Vec<_>>()
1102        .join(", ")
1103}