1use std::collections::HashMap;
6use std::fmt;
7use std::path::{Component, Path, PathBuf};
8
9use indexmap::IndexMap;
10use serde::de::{DeserializeOwned, IntoDeserializer as _};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use toml_edit::{DocumentMut, Item};
14
15use super::{ChestError, invalid};
16use crate::extensions::{EventPolicy, Grants};
17use crate::packages::source::{self, Source};
18use crate::settings::Scope;
19use crate::tools::path::{expand_home, home_dir, resolve_lexically};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Section {
24 Skills,
26 Agents,
28 Extensions,
30 Patch,
32}
33
34impl Section {
35 pub const ALL: [Section; 4] = [
37 Section::Skills,
38 Section::Agents,
39 Section::Extensions,
40 Section::Patch,
41 ];
42
43 pub fn key(self) -> &'static str {
45 match self {
46 Section::Skills => "skills",
47 Section::Agents => "agents",
48 Section::Extensions => "extensions",
49 Section::Patch => "patch",
50 }
51 }
52}
53
54#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Folders {
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub skills: Vec<String>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub agents: Vec<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub extensions: Option<String>,
68}
69
70#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct Entry {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub git: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub rev: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub npm: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub version: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub path: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub capabilities: Option<Vec<String>>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub events: Option<EventPolicy>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub skills: Option<Vec<String>>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub prompts: Option<Vec<String>>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub themes: Option<Vec<String>>,
108}
109
110#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct Package {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub name: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub version: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub description: Option<String>,
126 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub authors: Vec<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub license: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub repository: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub homepage: Option<String>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub keywords: Vec<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub capabilities: Option<Vec<String>>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub events: Option<EventPolicy>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub skills: Option<Vec<String>>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub agents: Option<Vec<String>>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub extensions: Option<Vec<String>>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub prompts: Option<Vec<String>>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub themes: Option<Vec<String>>,
162}
163
164impl Package {
165 fn list(&self, kind: &str) -> Option<&Vec<String>> {
167 match kind {
168 "extensions" => self.extensions.as_ref(),
169 "skills" => self.skills.as_ref(),
170 "prompts" => self.prompts.as_ref(),
171 "themes" => self.themes.as_ref(),
172 "agents" => self.agents.as_ref(),
173 _ => None,
174 }
175 }
176}
177
178#[derive(Clone, Debug, Default, PartialEq, Eq)]
181pub struct Manifest {
182 pub capabilities_defaults: Vec<String>,
184 pub folders: Folders,
186 pub skills: IndexMap<String, Entry>,
188 pub agents: IndexMap<String, Entry>,
190 pub extensions: IndexMap<String, Entry>,
192 pub patch: IndexMap<String, Entry>,
194 pub package: Option<Package>,
196}
197
198impl Manifest {
199 pub fn parse(text: &str, scope: Scope) -> Result<Manifest, ChestError> {
212 let document: DocumentMut = text.parse().map_err(|err| invalid(format!("{err}")))?;
213 Manifest::from_document(&document, scope)
214 }
215
216 fn from_document(document: &DocumentMut, scope: Scope) -> Result<Manifest, ChestError> {
217 let mut manifest = Manifest::default();
218 for (key, item) in document.iter() {
219 match key {
220 "capabilities-defaults" => manifest.capabilities_defaults = value(key, item)?,
221 "folders" => manifest.folders = value(key, item)?,
222 "package" => manifest.package = Some(value(key, item)?),
223 _ => {
224 let section = Section::ALL
225 .into_iter()
226 .find(|section| section.key() == key)
227 .ok_or_else(|| invalid(format!("unknown key `{key}`")))?;
228 let table = item
229 .as_table_like()
230 .ok_or_else(|| invalid(format!("{key}: expected a table")))?;
231 for (name, item) in table.iter() {
232 let entry = value(&format!("{key}.{name}"), item)?;
233 manifest.section_mut(section).insert(name.to_owned(), entry);
234 }
235 }
236 }
237 }
238 manifest.validate(scope)?;
239 Ok(manifest)
240 }
241
242 pub fn section(&self, section: Section) -> &IndexMap<String, Entry> {
244 match section {
245 Section::Skills => &self.skills,
246 Section::Agents => &self.agents,
247 Section::Extensions => &self.extensions,
248 Section::Patch => &self.patch,
249 }
250 }
251
252 pub fn entries(&self) -> impl Iterator<Item = (Section, &String, &Entry)> {
254 let sections = [Section::Skills, Section::Agents, Section::Extensions];
255 sections.into_iter().flat_map(move |section| {
256 let entries = self.section(section).iter();
257 entries.map(move |(name, entry)| (section, name, entry))
258 })
259 }
260
261 fn section_mut(&mut self, section: Section) -> &mut IndexMap<String, Entry> {
262 match section {
263 Section::Skills => &mut self.skills,
264 Section::Agents => &mut self.agents,
265 Section::Extensions => &mut self.extensions,
266 Section::Patch => &mut self.patch,
267 }
268 }
269
270 pub fn grants(&self, entry: &Entry) -> Grants {
275 let grants = |values: &[String]| Grants::parse(values).unwrap_or_else(|_| Grants::none());
276 let own = entry.capabilities.as_deref().unwrap_or_default();
277 grants(&self.capabilities_defaults).union(grants(own))
278 }
279
280 fn validate(&self, scope: Scope) -> Result<(), ChestError> {
281 Grants::parse(&self.capabilities_defaults)
282 .map_err(|err| invalid(format!("capabilities-defaults: {err}")))?;
283 let folders = &self.folders;
284 let skills = folders.skills.iter().map(|path| ("skills", path));
285 let agents = folders.agents.iter().map(|path| ("agents", path));
286 let store = folders.extensions.iter().map(|path| ("extensions", path));
287 let named: Vec<(&str, &String)> = skills.chain(agents).chain(store).collect();
288 for (key, path) in &named {
289 let expanded = expand_home(path);
290 if !Path::new(&expanded)
291 .components()
292 .any(|part| matches!(part, Component::Normal(_)))
293 {
294 return Err(invalid(format!(
295 "folders.{key}: \"{path}\" must name a folder"
296 )));
297 }
298 let parts = Path::new(path).components();
299 let inside = !path.is_empty()
300 && !path.starts_with('~')
301 && parts
302 .into_iter()
303 .all(|part| matches!(part, Component::Normal(_) | Component::CurDir));
304 if scope == Scope::Project && !inside {
305 return Err(invalid(format!(
306 "folders.{key}: \"{path}\" must be a relative path inside the project, without `..` or `~`"
307 )));
308 }
309 }
310 let default_store = match scope {
312 Scope::Project => ".yapi/chest",
313 Scope::Global => "~/.chest/store",
314 };
315 let mut paths: Vec<&str> = named.iter().map(|(_, path)| path.as_str()).collect();
316 if folders.extensions.is_none() {
317 paths.push(default_store);
318 }
319 let base = match scope {
320 Scope::Project => PathBuf::from("/"),
321 Scope::Global => home_dir().join(".chest"),
322 };
323 let normal = |path: &str| resolve_lexically(&base, Path::new(&expand_home(path)));
324 for (index, first) in paths.iter().enumerate() {
325 for second in &paths[index + 1..] {
326 let (a, b) = (normal(first), normal(second));
327 if a.starts_with(&b) || b.starts_with(&a) {
328 return Err(invalid(format!(
329 "folders: \"{first}\" and \"{second}\" overlap"
330 )));
331 }
332 }
333 }
334 let mut seen: HashMap<&str, Section> = HashMap::new();
335 for section in [Section::Skills, Section::Agents, Section::Extensions] {
336 for (name, entry) in self.section(section) {
337 let context = format!("{}.{name}", section.key());
338 if !valid_name(name) {
339 return Err(invalid(format!(
340 "{context}: names use lowercase letters, digits, `.`, `_` and `-`, and start with a letter or digit"
341 )));
342 }
343 if let Some(other) = seen.insert(name, section) {
344 return Err(invalid(format!(
345 "{context}: the name is already used in [{}]",
346 other.key()
347 )));
348 }
349 entry.validate(section, &context, scope)?;
350 }
351 }
352 for (name, entry) in &self.patch {
353 let path_only = Entry {
354 path: entry.path.clone(),
355 ..Entry::default()
356 };
357 let Some(path) = entry.path.as_deref().filter(|_| *entry == path_only) else {
358 return Err(invalid(format!("patch.{name}: a patch takes `path` only")));
359 };
360 check_source(path, scope).map_err(|err| invalid(format!("patch.{name}: {err}")))?;
361 }
362 if let Some(capabilities) = self.package.as_ref().and_then(|p| p.capabilities.as_ref()) {
363 Grants::parse(capabilities)
364 .map_err(|err| invalid(format!("package: `capabilities`: {err}")))?;
365 }
366 Ok(())
367 }
368}
369
370impl Entry {
371 fn validate(&self, section: Section, context: &str, scope: Scope) -> Result<(), ChestError> {
372 let fail = |message: String| Err(invalid(format!("{context}: {message}")));
373 match (&self.git, &self.npm, &self.path) {
374 (Some(_), Some(_), _) => return fail("`git` and `npm` cannot be combined".into()),
375 (None, None, None) => return fail("needs `git`, `npm` or `path`".into()),
376 _ => {}
377 }
378 if self.rev.is_some() && self.git.is_none() {
379 return fail("`rev` needs `git`".into());
380 }
381 if self.version.is_some() && self.npm.is_none() {
382 return fail("`version` needs `npm`".into());
383 }
384 if self.rev.as_deref().is_some_and(|rev| rev.starts_with('-')) {
385 return fail("`rev` must not start with `-`".into());
386 }
387 match (&self.path, self.git.is_some() || self.npm.is_some()) {
388 (Some(path), false)
390 if section == Section::Extensions
391 && !Path::new(path)
392 .components()
393 .any(|part| matches!(part, Component::Normal(_))) =>
394 {
395 return fail(format!(
396 "`path`: \"{path}\" must name the extension package, not a folder that holds the manifest"
397 ));
398 }
399 (Some(path), false) => check_source(path, scope).or_else(fail)?,
400 (Some(path), true) => {
401 let parts = Path::new(path).components();
402 let inside = parts
403 .into_iter()
404 .all(|part| matches!(part, Component::Normal(_) | Component::CurDir));
405 if path.is_empty() || !inside {
406 return fail(format!(
407 "`path`: \"{path}\" must be a folder inside the package"
408 ));
409 }
410 }
411 (None, _) => {}
412 }
413 if let Some(git) = &self.git {
414 match source::parse(&format!("git:{git}")) {
415 Source::Git {
416 reference: Some(reference),
417 ..
418 } => {
419 return fail(format!(
420 "`git`: \"{git}\" names the revision `{reference}`: put it in `rev`"
421 ));
422 }
423 Source::Git { .. } => {}
424 _ => return fail(format!("`git`: not a git repository: {git}")),
425 }
426 }
427 if section == Section::Extensions {
428 if self.npm.is_some() && self.path.is_some() {
429 return fail(
430 "`path` cannot be combined with `npm` for an extension, which loads its whole npm package"
431 .into(),
432 );
433 }
434 if let Some(Err(err)) = self.capabilities.as_deref().map(Grants::parse) {
435 return fail(format!("`capabilities`: {err}"));
436 }
437 return Ok(());
438 }
439 let extension_only = [
440 ("capabilities", self.capabilities.is_some()),
441 ("events", self.events.is_some()),
442 ("skills", self.skills.is_some()),
443 ("prompts", self.prompts.is_some()),
444 ("themes", self.themes.is_some()),
445 ];
446 match extension_only.iter().find(|(_, set)| *set) {
447 Some((key, _)) => fail(format!("`{key}` belongs to [extensions] entries")),
448 None => Ok(()),
449 }
450 }
451}
452
453fn check_source(path: &str, scope: Scope) -> Result<(), String> {
457 let anchored = path.starts_with('~') || Path::new(path).has_root();
458 if scope == Scope::Project && anchored {
459 return Err(format!(
460 "`path`: \"{path}\" must be relative to the manifest, not absolute or under `~`"
461 ));
462 }
463 Ok(())
464}
465
466pub const MANIFEST_ENV: &str = "CHEST_MANIFEST";
469
470pub fn from_env() -> Result<Option<String>, String> {
474 let Some(name) = std::env::var(MANIFEST_ENV)
475 .ok()
476 .filter(|name| !name.is_empty())
477 else {
478 return Ok(None);
479 };
480 let inside = !name.starts_with('~')
481 && Path::new(&name)
482 .components()
483 .all(|part| matches!(part, Component::Normal(_) | Component::CurDir));
484 if inside {
485 Ok(Some(name))
486 } else {
487 Err(format!(
488 "{MANIFEST_ENV} must name a file inside the project, not \"{name}\""
489 ))
490 }
491}
492
493pub fn valid_name(name: &str) -> bool {
496 let lower_or_digit = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit();
497 let mut chars = name.chars();
498 chars.next().is_some_and(lower_or_digit)
499 && chars.all(|c| lower_or_digit(c) || matches!(c, '.' | '_' | '-'))
500}
501
502fn value<T: DeserializeOwned>(context: &str, item: &Item) -> Result<T, ChestError> {
505 let read = |value: toml_edit::Value| T::deserialize(value.into_deserializer());
506 let value = item
507 .clone()
508 .into_value()
509 .map_err(|_| invalid(format!("{context}: expected a value")))?;
510 read(value.clone()).map_err(|err| {
511 let message = err.message();
512 let key = value.as_inline_table().and_then(|table| {
514 let fails = |(key, value): &(&str, &toml_edit::Value)| {
515 let mut alone = toml_edit::InlineTable::new();
516 alone.insert(*key, (*value).clone());
517 read(toml_edit::Value::InlineTable(alone)).is_err()
518 };
519 table.iter().find(fails).map(|(key, _)| key)
520 });
521 match key {
522 Some(key) if !message.contains(&format!("`{key}`")) => {
523 invalid(format!("{context}: `{key}`: {message}"))
524 }
525 _ => invalid(format!("{context}: {message}")),
526 }
527 })
528}
529
530#[derive(Clone, Debug)]
533pub struct ManifestDocument {
534 document: DocumentMut,
535 manifest: Manifest,
536 scope: Scope,
537}
538
539impl ManifestDocument {
540 pub fn parse(text: &str, scope: Scope) -> Result<ManifestDocument, ChestError> {
543 let document: DocumentMut = text.parse().map_err(|err| invalid(format!("{err}")))?;
544 let manifest = Manifest::from_document(&document, scope)?;
545 Ok(ManifestDocument {
546 document,
547 manifest,
548 scope,
549 })
550 }
551
552 pub fn manifest(&self) -> &Manifest {
554 &self.manifest
555 }
556
557 pub fn set_entry(
560 &mut self,
561 section: Section,
562 name: &str,
563 entry: &Entry,
564 ) -> Result<(), ChestError> {
565 let context = format!("{}.{name}", section.key());
566 let value = entry
567 .serialize(toml_edit::ser::ValueSerializer::new())
568 .map_err(|err| invalid(format!("{context}: {err}")))?;
569 self.edit(|document| {
570 if !document.contains_key(section.key()) {
571 let mut table = toml_edit::Table::new();
572 table.set_position(Some(next_position(document)));
573 document.insert(section.key(), Item::Table(table));
574 }
575 let table = document[section.key()]
576 .as_table_like_mut()
577 .ok_or_else(|| invalid(format!("{}: expected a table", section.key())))?;
578 match table.get_mut(name) {
579 Some(item) => *item = replacement(item, value),
580 None => {
581 table.insert(name, Item::Value(value));
582 }
583 }
584 Ok(())
585 })
586 }
587
588 pub fn remove_entry(&mut self, section: Section, name: &str) -> Result<bool, ChestError> {
590 let mut removed = false;
591 self.edit(|document| {
592 if let Some(table) = document
593 .get_mut(section.key())
594 .and_then(Item::as_table_like_mut)
595 {
596 removed = table.remove(name).is_some();
597 }
598 Ok(())
599 })?;
600 Ok(removed)
601 }
602
603 pub fn set_package(&mut self, package: &Package) -> Result<(), ChestError> {
606 let written = toml_edit::ser::to_document(package)
607 .map_err(|err| invalid(format!("package: {err}")))?;
608 self.edit(|document| {
609 let mut table = written.as_table().clone();
610 match document.get("package") {
611 Some(Item::Table(old)) => {
612 *table.decor_mut() = old.decor().clone();
613 table.set_position(old.position());
614 }
615 _ => table.set_position(Some(next_position(document))),
616 }
617 document.insert("package", Item::Table(table));
618 Ok(())
619 })
620 }
621
622 fn edit(
623 &mut self,
624 change: impl FnOnce(&mut DocumentMut) -> Result<(), ChestError>,
625 ) -> Result<(), ChestError> {
626 let mut document = self.document.clone();
627 change(&mut document)?;
628 self.manifest = Manifest::from_document(&document, self.scope)?;
629 self.document = document;
630 Ok(())
631 }
632}
633
634fn next_position(document: &DocumentMut) -> isize {
637 fn last(table: &toml_edit::Table) -> isize {
638 let tables = table.iter().filter_map(|(_, item)| item.as_table());
639 tables
640 .map(|table| table.position().unwrap_or_default().max(last(table)))
641 .max()
642 .unwrap_or_default()
643 }
644 last(document.as_table()) + 1
645}
646
647fn replacement(old: &Item, value: toml_edit::Value) -> Item {
649 match (old, value) {
650 (Item::Table(old), toml_edit::Value::InlineTable(inline)) => {
651 let mut table = inline.into_table();
652 *table.decor_mut() = old.decor().clone();
653 table.set_position(old.position());
654 Item::Table(table)
655 }
656 (old, mut value) => {
657 if let Some(old) = old.as_value() {
658 *value.decor_mut() = old.decor().clone();
659 }
660 Item::Value(value)
661 }
662 }
663}
664
665impl fmt::Display for ManifestDocument {
667 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
668 self.document.fmt(formatter)
669 }
670}
671
672pub const RESOURCE_KINDS: [&str; 5] = ["extensions", "skills", "prompts", "themes", "agents"];
674
675#[derive(Clone, Debug, Default, PartialEq, Eq)]
678pub struct PackageMeta {
679 pub name: Option<String>,
681 pub version: Option<String>,
683 pub description: Option<String>,
685 pub authors: Vec<String>,
687 pub capabilities: Vec<String>,
691 pub events: Option<EventPolicy>,
694 pub resources: IndexMap<&'static str, Vec<String>>,
698 pub manifest: bool,
703}
704
705pub fn package_meta(root: &Path) -> Result<PackageMeta, ChestError> {
711 match read_package_meta(root) {
712 (meta, None) => Ok(meta),
713 (_, Some(err)) => Err(err),
714 }
715}
716
717pub(crate) fn read_package_meta(root: &Path) -> (PackageMeta, Option<ChestError>) {
721 #[derive(Deserialize)]
722 struct File {
723 package: Option<Package>,
724 }
725 let path = root.join("chest.toml");
726 let (package, problem) = match std::fs::read_to_string(&path) {
727 Ok(text) => match toml_edit::de::from_str::<File>(&text) {
728 Ok(file) => (file.package, None),
729 Err(err) => (None, Some(invalid(format!("{}: {err}", path.display())))),
730 },
731 Err(_) => (None, None),
732 };
733 let json: Value = std::fs::read_to_string(root.join("package.json"))
734 .ok()
735 .and_then(|text| yapi_types::json::parse(&text).ok())
736 .unwrap_or_default();
737 let object = |key: &str| json.get(key).filter(|value| value.is_object());
738 let (yapi, pi) = (object("yapi"), object("pi"));
739 let strings = |value: Option<&Value>, key: &str| -> Option<Vec<String>> {
740 let entries = value?.get(key)?.as_array()?;
741 entries
742 .iter()
743 .map(|entry| entry.as_str().map(str::to_owned))
744 .collect()
745 };
746 let own = package.as_ref();
747 let mut resources = IndexMap::new();
748 let mut listed = false;
749 for kind in RESOURCE_KINDS {
750 let first = own
751 .and_then(|package| package.list(kind).cloned())
752 .or_else(|| strings(yapi, kind));
753 listed |= first.is_some();
754 if let Some(list) = first.or_else(|| strings(pi, kind)) {
755 resources.insert(kind, list);
756 }
757 }
758 let text = |key: &str| json.get(key).and_then(Value::as_str).map(str::to_owned);
759 let field = |pick: fn(&Package) -> &Option<String>, key: &str| {
760 own.and_then(|package| pick(package).clone())
761 .or_else(|| text(key))
762 };
763 let meta = PackageMeta {
764 name: field(|package| &package.name, "name"),
765 version: field(|package| &package.version, "version"),
766 description: field(|package| &package.description, "description"),
767 authors: own
768 .map(|package| package.authors.clone())
769 .filter(|authors| !authors.is_empty())
770 .unwrap_or_else(|| author(json.get("author").unwrap_or(&Value::Null))),
771 capabilities: own
772 .and_then(|package| package.capabilities.clone())
773 .or_else(|| strings(yapi, "capabilities"))
774 .unwrap_or_default(),
775 events: own.and_then(|package| package.events.clone()).or_else(|| {
776 let events = yapi?.get("events")?;
777 EventPolicy::deserialize(events).ok()
778 }),
779 resources,
780 manifest: pi.is_some() || listed,
781 };
782 (meta, problem)
783}
784
785fn author(value: &Value) -> Vec<String> {
787 match value {
788 Value::String(author) => vec![author.clone()],
789 Value::Object(person) => {
790 let name = person.get("name").and_then(Value::as_str);
791 let email = person.get("email").and_then(Value::as_str);
792 name.map(|name| match email {
793 Some(email) => format!("{name} <{email}>"),
794 None => name.to_owned(),
795 })
796 .into_iter()
797 .collect()
798 }
799 _ => Vec::new(),
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 const EXAMPLE: &str = r#"capabilities-defaults = ["env"]
809
810[folders] # written by `chest init`
811skills = [".agents/skills", ".claude/skills"] # the first folder holds the canonical copy
812agents = [".yapi/agents", ".claude/agents"]
813extensions = ".yapi/chest" # extension store, one folder per entry
814
815[skills]
816pdf = { git = "https://github.com/anthropics/skills", rev = "v1.2.0", path = "skills/pdf" }
817review = { npm = "@acme/agent-skills", version = "^2.1", path = "review" }
818notes = { path = "tools/skills/notes" }
819
820[agents]
821reviewer = { git = "github.com/acme/agents", rev = "main", path = "reviewer.md" }
822
823[extensions]
824subagent = { npm = "@acme/pi-subagent", version = "^1.4", capabilities = ["all"] }
825todo = { git = "https://github.com/acme/pi-todo", rev = "4f1c2e9", capabilities = ["fs"], events = { send = ["todo:*"], receive = [] },
826 skills = ["!legacy/**"], prompts = [], themes = ["themes/dark.json"] }
827shout = { path = "ext/shout.wasm" }
828
829[patch]
830todo = { path = "../pi-todo" }
831
832[package]
833name = "acme-review"
834version = "1.3.0"
835description = "Code review skills and a reviewer subagent"
836authors = ["Ada <ada@acme.dev>"]
837license = "MIT"
838repository = "https://github.com/acme/review"
839homepage = "https://acme.dev/review"
840keywords = ["review", "git"]
841capabilities = ["fs", "session"] # requested, never granted by itself
842events = { send = ["review:*"], receive = ["subagent:done"] } # requested channel links
843skills = ["skills/*"] # globs. Absent: conventional folders
844agents = ["agents/*.md"]
845extensions = ["extensions/index.ts"]
846"#;
847
848 fn strings(values: &[&str]) -> Vec<String> {
849 values.iter().map(|value| (*value).to_owned()).collect()
850 }
851
852 fn some(values: &[&str]) -> Option<Vec<String>> {
853 Some(strings(values))
854 }
855
856 #[test]
857 fn manifest_parses_example() {
858 let manifest = Manifest::parse(EXAMPLE, Scope::Project).unwrap();
859 assert_eq!(manifest.capabilities_defaults, ["env"]);
860 assert_eq!(
861 manifest.folders,
862 Folders {
863 skills: strings(&[".agents/skills", ".claude/skills"]),
864 agents: strings(&[".yapi/agents", ".claude/agents"]),
865 extensions: Some(".yapi/chest".into()),
866 }
867 );
868 let names = |section: Section| -> Vec<&str> {
869 manifest
870 .section(section)
871 .keys()
872 .map(String::as_str)
873 .collect()
874 };
875 assert_eq!(names(Section::Skills), ["pdf", "review", "notes"]);
876 assert_eq!(names(Section::Agents), ["reviewer"]);
877 assert_eq!(names(Section::Extensions), ["subagent", "todo", "shout"]);
878 assert_eq!(
879 manifest.skills["review"],
880 Entry {
881 npm: Some("@acme/agent-skills".into()),
882 version: Some("^2.1".into()),
883 path: Some("review".into()),
884 ..Entry::default()
885 }
886 );
887 assert_eq!(
888 manifest.extensions["todo"],
889 Entry {
890 git: Some("https://github.com/acme/pi-todo".into()),
891 rev: Some("4f1c2e9".into()),
892 capabilities: some(&["fs"]),
893 events: Some(EventPolicy::new(strings(&["todo:*"]), Vec::new()).unwrap()),
894 skills: some(&["!legacy/**"]),
895 prompts: some(&[]),
896 themes: some(&["themes/dark.json"]),
897 ..Entry::default()
898 }
899 );
900 assert_eq!(manifest.patch["todo"].path.as_deref(), Some("../pi-todo"));
901 let package = manifest.package.unwrap();
902 assert_eq!(package.name.as_deref(), Some("acme-review"));
903 assert_eq!(package.authors, ["Ada <ada@acme.dev>"]);
904 assert_eq!(package.keywords, ["review", "git"]);
905 assert_eq!(package.capabilities, some(&["fs", "session"]));
906 assert_eq!(
907 package.events,
908 Some(EventPolicy::new(strings(&["review:*"]), strings(&["subagent:done"])).unwrap())
909 );
910 assert_eq!(package.agents, some(&["agents/*.md"]));
911
912 let rejected = [
913 (
914 "[skills]\nPdf = { path = \"x\" }",
915 "skills.Pdf: names use lowercase letters, digits, `.`, `_` and `-`, and start with a letter or digit",
916 ),
917 (
918 "[skills]\n-x = { path = \"x\" }",
919 "skills.-x: names use lowercase letters, digits, `.`, `_` and `-`, and start with a letter or digit",
920 ),
921 (
922 "[skills]\nx = { path = \"a\" }\n[agents]\nx = { path = \"b\" }",
923 "agents.x: the name is already used in [skills]",
924 ),
925 (
926 "[skills]\nx = { git = \"https://github.com/a/b\", npm = \"c\" }",
927 "skills.x: `git` and `npm` cannot be combined",
928 ),
929 ("[skills]\nx = {}", "skills.x: needs `git`, `npm` or `path`"),
930 (
931 "[skills]\nx = { path = \"a\", rev = \"v1\" }",
932 "skills.x: `rev` needs `git`",
933 ),
934 (
935 "[skills]\nx = { git = \"https://github.com/a/b\", version = \"1\" }",
936 "skills.x: `version` needs `npm`",
937 ),
938 (
939 "[skills]\nx = { git = \"a b\" }",
940 "skills.x: `git`: not a git repository: a b",
941 ),
942 (
943 "[skills]\nx = { path = 1 }",
944 "skills.x: `path`: invalid type: integer `1`, expected a string",
945 ),
946 (
947 "[skills]\nx = { path = \"a\", capabilities = [\"fs\"] }",
948 "skills.x: `capabilities` belongs to [extensions] entries",
949 ),
950 (
951 "[agents]\nx = { path = \"a\", events = \"isolated\" }",
952 "agents.x: `events` belongs to [extensions] entries",
953 ),
954 (
955 "[extensions]\nx = { npm = \"a\", path = \"b\" }",
956 "extensions.x: `path` cannot be combined with `npm` for an extension, which loads its whole npm package",
957 ),
958 (
959 "[extensions]\nx = { path = \"a\", capabilities = [\"process\"] }",
960 "extensions.x: `capabilities`: \"process\" is not a capability: a process runs as you, outside the sandbox, so only \"all\" allows it",
961 ),
962 (
963 "[extensions]\nx = { path = \"a\", capabilities = [\"none\", \"fs\"] }",
964 "extensions.x: `capabilities`: \"none\" cannot be combined with other capabilities",
965 ),
966 (
967 "[extensions]\nx = { path = \"a\", events = \"open\" }",
968 "extensions.x: `events`: invalid value: string \"open\", expected \"shared\", \"isolated\" or { send, receive }",
969 ),
970 (
971 "[extensions]\nx = { path = \"a\", events = { send = [\"a*b\"] } }",
972 "extensions.x: `events`: invalid event channel pattern \"a*b\": use a channel name, a prefix ending in `*`, or `*`",
973 ),
974 (
975 "[patch]\nx = { git = \"https://github.com/a/b\" }",
976 "patch.x: a patch takes `path` only",
977 ),
978 (
979 "capabilities-defaults = [\"exec\"]",
980 "capabilities-defaults: unknown capability \"exec\"",
981 ),
982 (
983 "[package]\ncapabilities = [\"none\", \"net\"]",
984 "package: `capabilities`: \"none\" cannot be combined with other capabilities",
985 ),
986 ("colour = 1", "unknown key `colour`"),
987 (
988 "[folders]\nskills = [\".agents/skills\", \"../x\"]",
989 "folders.skills: \"../x\" must be a relative path inside the project, without `..` or `~`",
990 ),
991 (
992 "[folders]\nagents = [\"/abs\"]",
993 "folders.agents: \"/abs\" must be a relative path inside the project, without `..` or `~`",
994 ),
995 (
996 "[folders]\nextensions = \"~/x\"",
997 "folders.extensions: \"~/x\" must be a relative path inside the project, without `..` or `~`",
998 ),
999 (
1000 "[skills]\nx = { path = \"~/x\" }",
1001 "skills.x: `path`: \"~/x\" must be relative to the manifest, not absolute or under `~`",
1002 ),
1003 (
1004 "[patch]\nx = { path = \"/abs\" }",
1005 "patch.x: `path`: \"/abs\" must be relative to the manifest, not absolute or under `~`",
1006 ),
1007 (
1008 "[skills]\nx = { git = \"https://github.com/a/b\", path = \"../x\" }",
1009 "skills.x: `path`: \"../x\" must be a folder inside the package",
1010 ),
1011 (
1012 "[extensions]\nx = { path = \".\" }",
1013 "extensions.x: `path`: \".\" must name the extension package, not a folder that holds the manifest",
1014 ),
1015 (
1016 "[extensions]\nx = { path = \"../..\" }",
1017 "extensions.x: `path`: \"../..\" must name the extension package, not a folder that holds the manifest",
1018 ),
1019 (
1020 "[skills]\nx = { git = \"https://github.com/a/b\", rev = \"--upload-pack=x\" }",
1021 "skills.x: `rev` must not start with `-`",
1022 ),
1023 ("skills = 1", "skills: expected a table"),
1024 (
1025 "[folders]\nskills = [\".\"]",
1026 "folders.skills: \".\" must name a folder",
1027 ),
1028 (
1029 "[folders]\nskills = [\".agents\", \".agents/skills\"]",
1030 "folders: \".agents\" and \".agents/skills\" overlap",
1031 ),
1032 (
1033 "[folders]\nagents = [\".yapi/chest/agents\"]",
1034 "folders: \".yapi/chest/agents\" and \".yapi/chest\" overlap",
1035 ),
1036 (
1037 "[skills]\nx = { git = \"github.com/a/b@v1\" }",
1038 "skills.x: `git`: \"github.com/a/b@v1\" names the revision `v1`: put it in `rev`",
1039 ),
1040 ];
1041 for (text, message) in rejected {
1042 let error = Manifest::parse(text, Scope::Project)
1043 .unwrap_err()
1044 .to_string();
1045 assert_eq!(error, message, "{text}");
1046 }
1047 let global = "[folders]\nskills = [\"~/x\", \"/abs\"]\n[skills]\nx = { path = \"~/x\" }";
1049 let global = Manifest::parse(global, Scope::Global).unwrap();
1050 assert_eq!(global.folders.skills, ["~/x", "/abs"]);
1051 for (text, start) in [
1053 (
1054 "[skills]\nx = { path = \"a\", foo = 1 }",
1055 "skills.x: unknown field `foo`",
1056 ),
1057 ("[folders]\nskill = []", "folders: unknown field `skill`"),
1058 (
1059 "[package]\nauthor = \"Ada\"",
1060 "package: unknown field `author`",
1061 ),
1062 (
1063 "[extensions]\nx = { path = \"a\", events = { sent = [] } }",
1064 "extensions.x: `events`: unknown field `sent`",
1065 ),
1066 ] {
1067 let error = Manifest::parse(text, Scope::Project)
1068 .unwrap_err()
1069 .to_string();
1070 assert!(error.starts_with(start), "{error}");
1071 }
1072 }
1073
1074 #[test]
1075 fn capabilities_union_defaults() {
1076 let manifest = Manifest::parse(
1077 "capabilities-defaults = [\"env\"]\n[extensions]\nfs = { path = \"a\", capabilities = [\"fs\"] }\nplain = { path = \"b\" }\nall = { path = \"c\", capabilities = [\"all\"] }\n",
1078 Scope::Project,
1079 )
1080 .unwrap();
1081 let grants = |name: &str| manifest.grants(&manifest.extensions[name]);
1082 let env = Grants {
1083 environment: true,
1084 ..Grants::none()
1085 };
1086 assert_eq!(
1087 grants("fs"),
1088 Grants {
1089 filesystem: true,
1090 ..env
1091 }
1092 );
1093 assert_eq!(grants("plain"), env);
1094 assert_eq!(grants("all"), Grants::default());
1095 let bare =
1096 Manifest::parse("[extensions]\nplain = { path = \"b\" }\n", Scope::Project).unwrap();
1097 assert_eq!(bare.grants(&bare.extensions["plain"]), Grants::none());
1098 }
1099
1100 #[test]
1101 fn edit_preserves_format() {
1102 let text = r#"# Project packages.
1103capabilities-defaults = ["env"]
1104
1105[skills]
1106# The PDF skill.
1107pdf = { git = "https://github.com/anthropics/skills", rev = "v1.2.0", path = "skills/pdf" } # pinned
1108notes = { path = "tools/skills/notes" }
1109
1110# Long entries can be tables.
1111[extensions.todo]
1112git = "https://github.com/acme/pi-todo"
1113capabilities = ["fs"]
1114
1115[extensions]
1116shout = { path = "ext/shout.wasm" }
1117"#;
1118 let mut document = ManifestDocument::parse(text, Scope::Project).unwrap();
1119 let pdf = Entry {
1120 rev: Some("v1.3.0".into()),
1121 ..document.manifest().skills["pdf"].clone()
1122 };
1123 document.set_entry(Section::Skills, "pdf", &pdf).unwrap();
1124 let todo = Entry {
1125 capabilities: Some(strings(&["fs", "net"])),
1126 ..document.manifest().extensions["todo"].clone()
1127 };
1128 document
1129 .set_entry(Section::Extensions, "todo", &todo)
1130 .unwrap();
1131 let reviewer = Entry {
1132 git: Some("github.com/acme/agents".into()),
1133 path: Some("reviewer.md".into()),
1134 ..Entry::default()
1135 };
1136 document
1137 .set_entry(Section::Agents, "reviewer", &reviewer)
1138 .unwrap();
1139 assert!(document.remove_entry(Section::Skills, "notes").unwrap());
1140 assert!(!document.remove_entry(Section::Skills, "gone").unwrap());
1141 let before = document.to_string();
1143 assert!(
1144 document
1145 .set_entry(Section::Agents, "Bad", &reviewer)
1146 .is_err()
1147 );
1148 assert!(
1149 document
1150 .set_entry(Section::Agents, "pdf", &reviewer)
1151 .is_err()
1152 );
1153 assert_eq!(document.to_string(), before);
1154 document
1155 .set_package(&Package {
1156 name: Some("acme".into()),
1157 capabilities: Some(Vec::new()),
1158 ..Package::default()
1159 })
1160 .unwrap();
1161 assert_eq!(
1162 document.to_string(),
1163 r#"# Project packages.
1164capabilities-defaults = ["env"]
1165
1166[skills]
1167# The PDF skill.
1168pdf = { git = "https://github.com/anthropics/skills", rev = "v1.3.0", path = "skills/pdf" } # pinned
1169
1170# Long entries can be tables.
1171[extensions.todo]
1172git = "https://github.com/acme/pi-todo"
1173capabilities = ["fs", "net"]
1174
1175[extensions]
1176shout = { path = "ext/shout.wasm" }
1177
1178[agents]
1179reviewer = { git = "github.com/acme/agents", path = "reviewer.md" }
1180
1181[package]
1182name = "acme"
1183capabilities = []
1184"#
1185 );
1186 let manifest = document.manifest();
1187 assert_eq!(manifest.skills["pdf"].rev.as_deref(), Some("v1.3.0"));
1188 assert_eq!(manifest.agents["reviewer"], reviewer);
1189 assert_eq!(
1190 manifest.package.as_ref().and_then(|p| p.name.as_deref()),
1191 Some("acme")
1192 );
1193 assert_eq!(
1194 Manifest::parse(&document.to_string(), Scope::Project)
1195 .as_ref()
1196 .ok(),
1197 Some(manifest)
1198 );
1199 }
1200
1201 #[test]
1202 fn metadata_precedence() {
1203 let dir = std::env::temp_dir().join(format!("yapi-chest-meta-{}", std::process::id()));
1204 let _ = std::fs::remove_dir_all(&dir);
1205 std::fs::create_dir_all(&dir).unwrap();
1206 let write = |name: &str, text: &str| std::fs::write(dir.join(name), text).unwrap();
1207
1208 write(
1210 "package.json",
1211 r#"{"name": "pkg", "version": "1.0.0", "description": "From npm", "author": {"name": "Ada", "email": "ada@acme.dev"},
1212 "pi": {"extensions": ["./pi.ts"], "skills": ["pi-skills"], "capabilities": ["all"]}}"#,
1213 );
1214 let meta = package_meta(&dir).unwrap();
1215 assert_eq!(meta.name.as_deref(), Some("pkg"));
1216 assert_eq!(meta.authors, ["Ada <ada@acme.dev>"]);
1217 assert!(meta.capabilities.is_empty());
1218 assert_eq!(meta.resources["extensions"], ["./pi.ts"]);
1219 assert!(meta.manifest);
1220
1221 write(
1223 "package.json",
1224 r#"{"name": "pkg", "version": "1.0.0", "description": "From npm", "author": "Ada",
1225 "yapi": {"capabilities": ["net"], "skills": ["yapi-skills"], "events": {"send": ["pkg:*"]}},
1226 "pi": {"extensions": ["./pi.ts"], "skills": ["pi-skills"], "prompts": ["pi-prompts"]}}"#,
1227 );
1228 write(
1229 "chest.toml",
1230 "[skills]\nignored = { path = \"x\" }\n[package]\ndescription = \"From chest\"\nprompts = [\"chest-prompts\"]\nagents = [\"agents/*.md\"]\n",
1231 );
1232 let meta = package_meta(&dir).unwrap();
1233 assert_eq!(meta.name.as_deref(), Some("pkg"));
1234 assert_eq!(meta.version.as_deref(), Some("1.0.0"));
1235 assert_eq!(meta.description.as_deref(), Some("From chest"));
1236 assert_eq!(meta.authors, ["Ada"]);
1237 assert_eq!(meta.capabilities, ["net"]);
1238 assert_eq!(
1239 meta.events,
1240 Some(EventPolicy::new(strings(&["pkg:*"]), Vec::new()).unwrap())
1241 );
1242 let lists: Vec<(&str, Vec<String>)> = meta.resources.clone().into_iter().collect();
1243 assert_eq!(
1244 lists,
1245 [
1246 ("extensions", strings(&["./pi.ts"])),
1247 ("skills", strings(&["yapi-skills"])),
1248 ("prompts", strings(&["chest-prompts"])),
1249 ("agents", strings(&["agents/*.md"])),
1250 ]
1251 );
1252 write(
1253 "chest.toml",
1254 "[package]\ncapabilities = []\nauthors = [\"Bo\"]\n",
1255 );
1256 let meta = package_meta(&dir).unwrap();
1257 assert!(meta.capabilities.is_empty());
1258 assert_eq!(meta.authors, ["Bo"]);
1259
1260 std::fs::remove_file(dir.join("chest.toml")).unwrap();
1262 write("package.json", r#"{"yapi": {"capabilities": ["fs"]}}"#);
1263 let meta = package_meta(&dir).unwrap();
1264 assert_eq!(meta.capabilities, ["fs"]);
1265 assert!(meta.resources.is_empty() && !meta.manifest);
1266 write(
1268 "chest.toml",
1269 "[package]\nauthor = \"Ada\"\nskills = [\"s\"]\n",
1270 );
1271 let broken = package_meta(&dir).unwrap_err().to_string();
1272 let file = dir.join("chest.toml").display().to_string();
1273 assert!(broken.starts_with(&file), "{broken}");
1274 assert!(broken.contains("unknown field `author`"), "{broken}");
1275 std::fs::remove_dir_all(&dir).unwrap();
1276 assert_eq!(package_meta(&dir).unwrap(), PackageMeta::default());
1277 }
1278}