1use std::collections::HashSet;
75use std::path::{Path, PathBuf};
76
77use serde::Deserialize;
78
79#[derive(Debug, thiserror::Error)]
85pub enum PackageDiscoveryError {
86 #[error("invalid package manifest at {path}: {reason}")]
88 InvalidManifest { path: PathBuf, reason: String },
89 #[error("missing required field '{field}' in package at {path}")]
91 MissingField { field: String, path: PathBuf },
92 #[error("duplicate package name '{name}' in discovery layer at {path}")]
94 DuplicateName { name: String, path: PathBuf },
95 #[error("invalid package name in {path}: {reason}")]
97 InvalidName { path: PathBuf, reason: String },
98 #[error("invalid description in package at {path}: {reason}")]
100 InvalidDescription { path: PathBuf, reason: String },
101 #[error("missing {kind} '{name}' in package '{package_name}'")]
103 MissingAsset {
104 package_name: String,
105 kind: String,
106 name: String,
107 },
108 #[error(
110 "security: resource path escapes package directory for {package_name}: {path} ({reason})"
111 )]
112 SecurityDiagnostic {
113 package_name: String,
114 path: PathBuf,
115 reason: String,
116 },
117 #[error("I/O error discovering packages: {0}")]
119 Io(#[from] std::io::Error),
120}
121
122const MAX_NAME_LEN: usize = 64;
128
129const MAX_DESCRIPTION_LEN: usize = 1024;
131
132#[derive(Debug, Clone, Deserialize)]
138struct TomlPackageFile {
139 name: Option<String>,
140 description: Option<String>,
141 version: Option<String>,
142 extensions: Option<Vec<String>>,
143 skills: Option<Vec<String>>,
144 fragments: Option<Vec<String>>,
145 themes: Option<Vec<String>>,
146 disabled: Option<Vec<String>>,
147}
148
149#[derive(Debug, Clone, PartialEq)]
155pub struct PackageManifest {
156 pub name: String,
159 pub description: String,
162 pub version: Option<String>,
164 pub extensions: Option<Vec<String>>,
167 pub skills: Option<Vec<String>>,
170 pub fragments: Option<Vec<String>>,
173 pub themes: Option<Vec<String>>,
176 pub disabled: Vec<String>,
178}
179
180impl PackageManifest {
181 pub fn from_toml(content: &str, path: &Path) -> Result<Self, PackageDiscoveryError> {
183 let file: TomlPackageFile =
184 toml::from_str(content).map_err(|e| PackageDiscoveryError::InvalidManifest {
185 path: path.to_path_buf(),
186 reason: e.to_string(),
187 })?;
188
189 let name = file.name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
190 PackageDiscoveryError::MissingField {
191 field: "name".into(),
192 path: path.to_path_buf(),
193 }
194 })?;
195
196 validate_package_name(&name, path)?;
197
198 let description = file
199 .description
200 .filter(|d| !d.trim().is_empty())
201 .ok_or_else(|| PackageDiscoveryError::MissingField {
202 field: "description".into(),
203 path: path.to_path_buf(),
204 })?;
205
206 validate_description(&description, path)?;
207
208 Ok(Self {
209 name,
210 description,
211 version: file.version,
212 extensions: file.extensions,
213 skills: file.skills,
214 fragments: file.fragments,
215 themes: file.themes,
216 disabled: file.disabled.unwrap_or_default(),
217 })
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum ResourceKind {
228 Extension,
230 Skill,
232 Fragment,
234 Theme,
236}
237
238impl std::fmt::Display for ResourceKind {
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 match self {
241 Self::Extension => write!(f, "extension"),
242 Self::Skill => write!(f, "skill"),
243 Self::Fragment => write!(f, "fragment"),
244 Self::Theme => write!(f, "theme"),
245 }
246 }
247}
248
249struct ResourceTypeSpec {
251 kind: ResourceKind,
252 subdir: &'static str,
253 marker: &'static str,
254}
255
256const RESOURCE_TYPES: &[ResourceTypeSpec] = &[
257 ResourceTypeSpec {
258 kind: ResourceKind::Extension,
259 subdir: "extensions",
260 marker: "extension.toml",
261 },
262 ResourceTypeSpec {
263 kind: ResourceKind::Skill,
264 subdir: "skills",
265 marker: "SKILL.md",
266 },
267 ResourceTypeSpec {
268 kind: ResourceKind::Fragment,
269 subdir: "fragments",
270 marker: "FRAGMENT.md",
271 },
272 ResourceTypeSpec {
273 kind: ResourceKind::Theme,
274 subdir: "themes",
275 marker: "theme.toml",
276 },
277];
278
279#[derive(Debug, Clone)]
289pub struct ComposedResource {
290 pub kind: ResourceKind,
292 pub name: String,
294 pub path: PathBuf,
296}
297
298#[derive(Debug, Clone, Default)]
300pub struct PackageComposedResourceLayers {
301 pub extensions: Vec<crate::resource::DiscoveryLayer>,
302 pub skills: Vec<crate::resource::DiscoveryLayer>,
303 pub fragments: Vec<crate::resource::DiscoveryLayer>,
304 pub themes: Vec<crate::resource::DiscoveryLayer>,
305 pub diagnostics: Vec<String>,
306}
307
308#[derive(Debug, Clone)]
318pub struct PackageResource {
319 pub manifest: PackageManifest,
321 pub path: PathBuf,
323 pub package_toml_path: PathBuf,
325 pub layer_precedence: u32,
327}
328
329impl PackageResource {
330 pub fn compose(&self) -> Result<Vec<ComposedResource>, PackageDiscoveryError> {
339 let mut resources = Vec::new();
340 let disabled: HashSet<&str> = self.manifest.disabled.iter().map(|s| s.as_str()).collect();
341
342 let include_lists: [(ResourceKind, &Option<Vec<String>>); 4] = [
343 (ResourceKind::Extension, &self.manifest.extensions),
344 (ResourceKind::Skill, &self.manifest.skills),
345 (ResourceKind::Fragment, &self.manifest.fragments),
346 (ResourceKind::Theme, &self.manifest.themes),
347 ];
348
349 for spec in RESOURCE_TYPES {
350 let include_list = include_lists
351 .iter()
352 .find(|(k, _)| *k == spec.kind)
353 .map(|(_, l)| *l)
354 .unwrap_or(&None);
355
356 self.compose_type(spec, include_list, &disabled, &mut resources)?;
357 }
358
359 Ok(resources)
360 }
361
362 fn compose_type(
364 &self,
365 spec: &ResourceTypeSpec,
366 include_list: &Option<Vec<String>>,
367 disabled: &HashSet<&str>,
368 resources: &mut Vec<ComposedResource>,
369 ) -> Result<(), PackageDiscoveryError> {
370 let type_dir = self.path.join(spec.subdir);
371
372 if !type_dir.is_dir() {
373 if let Some(includes) = include_list {
375 for name in includes {
376 if !disabled.contains(name.as_str()) {
377 return Err(PackageDiscoveryError::MissingAsset {
378 package_name: self.manifest.name.clone(),
379 kind: spec.kind.to_string(),
380 name: name.clone(),
381 });
382 }
383 }
384 }
385 return Ok(());
386 }
387
388 let canonical_package = self.path.canonicalize()?;
389
390 if let Some(includes) = include_list {
391 for name in includes {
393 if disabled.contains(name.as_str()) {
394 continue;
395 }
396
397 let resource_dir = type_dir.join(name);
398 if !resource_dir.is_dir() {
399 return Err(PackageDiscoveryError::MissingAsset {
400 package_name: self.manifest.name.clone(),
401 kind: spec.kind.to_string(),
402 name: name.clone(),
403 });
404 }
405
406 let marker = resource_dir.join(spec.marker);
407 if !marker.exists() {
408 return Err(PackageDiscoveryError::MissingAsset {
409 package_name: self.manifest.name.clone(),
410 kind: spec.kind.to_string(),
411 name: name.clone(),
412 });
413 }
414
415 let canonical_resource = resource_dir.canonicalize()?;
417 if !canonical_resource.starts_with(&canonical_package) {
418 return Err(PackageDiscoveryError::SecurityDiagnostic {
419 package_name: self.manifest.name.clone(),
420 path: canonical_resource,
421 reason: format!(
422 "resource path escapes package directory for {} '{}'",
423 spec.kind, name
424 ),
425 });
426 }
427
428 resources.push(ComposedResource {
429 kind: spec.kind,
430 name: name.clone(),
431 path: resource_dir,
432 });
433 }
434 } else {
435 let entries = std::fs::read_dir(&type_dir)?;
437 for entry in entries {
438 let entry = entry?;
439 let resource_dir = entry.path();
440
441 if !resource_dir.is_dir() {
442 continue;
443 }
444
445 let resource_name = match resource_dir.file_name().and_then(|n| n.to_str()) {
446 Some(n) => n.to_string(),
447 None => continue,
448 };
449
450 if disabled.contains(resource_name.as_str()) {
452 continue;
453 }
454
455 let marker = resource_dir.join(spec.marker);
457 if !marker.exists() {
458 continue;
459 }
460
461 let canonical_resource = resource_dir.canonicalize()?;
463 if !canonical_resource.starts_with(&canonical_package) {
464 return Err(PackageDiscoveryError::SecurityDiagnostic {
465 package_name: self.manifest.name.clone(),
466 path: canonical_resource,
467 reason: format!(
468 "resource path escapes package directory for {} '{}'",
469 spec.kind, resource_name
470 ),
471 });
472 }
473
474 resources.push(ComposedResource {
475 kind: spec.kind,
476 name: resource_name,
477 path: resource_dir,
478 });
479 }
480 }
481
482 Ok(())
483 }
484}
485
486pub fn discover_packages(
501 layers: &[crate::resource::DiscoveryLayer],
502) -> Result<Vec<PackageResource>, PackageDiscoveryError> {
503 let mut seen: std::collections::HashMap<String, PackageResource> =
504 std::collections::HashMap::new();
505
506 for layer in layers {
507 let scan_dir = layer.scan_dir();
508 if !scan_dir.is_dir() {
509 continue;
510 }
511
512 if scan_dir.join("package.toml").exists() {
513 discover_package_dir(&scan_dir, layer, &mut seen)?;
514 continue;
515 }
516
517 let entries = match std::fs::read_dir(&scan_dir) {
518 Ok(entries) => entries,
519 Err(e) => return Err(PackageDiscoveryError::Io(e)),
520 };
521
522 for entry in entries {
523 let entry = entry?;
524 let path = entry.path();
525
526 if !path.is_dir() {
527 continue;
528 }
529
530 let pkg_toml = path.join("package.toml");
531 if !pkg_toml.exists() {
532 continue;
533 }
534
535 discover_package_dir(&path, layer, &mut seen)?;
536 }
537 }
538
539 let mut resources: Vec<PackageResource> = seen.into_values().collect();
540 resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
541 Ok(resources)
542}
543
544fn discover_package_dir(
545 path: &Path,
546 layer: &crate::resource::DiscoveryLayer,
547 seen: &mut std::collections::HashMap<String, PackageResource>,
548) -> Result<(), PackageDiscoveryError> {
549 let pkg_toml = path.join("package.toml");
550 let content = std::fs::read_to_string(&pkg_toml)?;
551 let manifest = PackageManifest::from_toml(&content, &pkg_toml)?;
552
553 let canonical = path.canonicalize()?;
554
555 match seen.get(&manifest.name) {
556 Some(existing) if layer.precedence == existing.layer_precedence => {
557 return Err(PackageDiscoveryError::DuplicateName {
558 name: manifest.name,
559 path: canonical,
560 });
561 }
562 Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
563 Some(_) | None => {
564 seen.insert(
565 manifest.name.clone(),
566 PackageResource {
567 manifest,
568 path: canonical,
569 package_toml_path: pkg_toml,
570 layer_precedence: layer.precedence,
571 },
572 );
573 }
574 }
575
576 Ok(())
577}
578
579pub fn package_composed_resource_layers(
584 packages: &[PackageResource],
585) -> PackageComposedResourceLayers {
586 let mut result = PackageComposedResourceLayers::default();
587 let mut ordered: Vec<&PackageResource> = packages.iter().collect();
588 ordered.sort_by(|a, b| {
589 a.layer_precedence
590 .cmp(&b.layer_precedence)
591 .then_with(|| a.manifest.name.cmp(&b.manifest.name))
592 });
593
594 for package in ordered {
595 let mut resources = match package.compose() {
596 Ok(resources) => resources,
597 Err(e) => {
598 result
599 .diagnostics
600 .push(format!("package '{}': {e}", package.manifest.name));
601 continue;
602 }
603 };
604 resources.sort_by(|a, b| {
605 resource_kind_order(a.kind)
606 .cmp(&resource_kind_order(b.kind))
607 .then_with(|| a.name.cmp(&b.name))
608 });
609 for resource in resources {
610 let layer = crate::resource::DiscoveryLayer {
611 root: resource.path,
612 subdirectory: None,
613 precedence: package.layer_precedence,
614 };
615 match resource.kind {
616 ResourceKind::Extension => result.extensions.push(layer),
617 ResourceKind::Skill => result.skills.push(layer),
618 ResourceKind::Fragment => result.fragments.push(layer),
619 ResourceKind::Theme => result.themes.push(layer),
620 }
621 }
622 }
623
624 result
625}
626
627fn resource_kind_order(kind: ResourceKind) -> u8 {
628 match kind {
629 ResourceKind::Extension => 0,
630 ResourceKind::Skill => 1,
631 ResourceKind::Fragment => 2,
632 ResourceKind::Theme => 3,
633 }
634}
635
636pub struct PackageRegistry {
643 packages: Vec<PackageResource>,
644}
645
646impl PackageRegistry {
647 pub fn from_resources(packages: Vec<PackageResource>) -> Self {
649 Self { packages }
650 }
651
652 pub fn names(&self) -> Vec<&str> {
654 self.packages
655 .iter()
656 .map(|p| p.manifest.name.as_str())
657 .collect()
658 }
659
660 pub fn get(&self, name: &str) -> Option<&PackageResource> {
662 self.packages.iter().find(|p| p.manifest.name == name)
663 }
664
665 pub fn format_for_prompt(&self) -> String {
668 if self.packages.is_empty() {
669 return String::new();
670 }
671
672 let parts: Vec<String> = self
673 .packages
674 .iter()
675 .map(|p| {
676 let version = p
677 .manifest
678 .version
679 .as_deref()
680 .map(|v| format!(" v{v}"))
681 .unwrap_or_default();
682 format!(
683 "- {}: {}{}",
684 p.manifest.name, p.manifest.description, version
685 )
686 })
687 .collect();
688 parts.join("\n")
689 }
690}
691
692fn validate_package_name(name: &str, path: &Path) -> Result<(), PackageDiscoveryError> {
699 if name.len() > MAX_NAME_LEN {
700 return Err(PackageDiscoveryError::InvalidName {
701 path: path.to_path_buf(),
702 reason: format!(
703 "name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
704 name.len()
705 ),
706 });
707 }
708
709 for ch in name.chars() {
710 let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
711 if !valid {
712 return Err(PackageDiscoveryError::InvalidName {
713 path: path.to_path_buf(),
714 reason: format!(
715 "name contains invalid character '{ch}': \
716 only lowercase a-z, 0-9, and hyphens are allowed"
717 ),
718 });
719 }
720 }
721
722 Ok(())
723}
724
725fn validate_description(desc: &str, path: &Path) -> Result<(), PackageDiscoveryError> {
727 if desc.len() > MAX_DESCRIPTION_LEN {
728 return Err(PackageDiscoveryError::InvalidDescription {
729 path: path.to_path_buf(),
730 reason: format!(
731 "description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
732 ({} found)",
733 desc.len()
734 ),
735 });
736 }
737 Ok(())
738}