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}