1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::fs;
4use std::path::Path;
5
6use crate::dependency::types::{DependencyScope, DependencySourceType};
7use crate::runtime::path::render_host_visible_path;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "snake_case")]
13pub enum DependencyArchiveType {
14 Zip,
17 TarGz,
20 #[default]
23 Raw,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct DependencyExportSpec {
30 pub archive_path: String,
33 pub target_path: String,
36 #[serde(default)]
39 pub executable: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct DependencyPackageSpec {
46 #[serde(default)]
49 pub archive_type: DependencyArchiveType,
50 #[serde(default)]
53 pub asset_name: Option<String>,
54 #[serde(default)]
57 pub url: Option<String>,
58 #[serde(default)]
61 pub exports: Vec<DependencyExportSpec>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct GithubReleaseSourceSpec {
68 pub repo: String,
72 #[serde(default)]
75 pub tag_api: Option<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
81pub struct UrlSourceSpec {}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct SkillListSourceSpec {
87 pub url: String,
90 pub package: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct DependencySourceSpec {
99 #[serde(rename = "type")]
102 pub source_type: DependencySourceType,
103 #[serde(default)]
106 pub github: Option<GithubReleaseSourceSpec>,
107 #[serde(default)]
110 pub url: Option<UrlSourceSpec>,
111 #[serde(default)]
114 pub skilllist: Option<SkillListSourceSpec>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct ToolDependencySpec {
121 pub name: String,
124 #[serde(default)]
127 pub version: Option<String>,
128 #[serde(default = "default_required_dependency")]
131 pub required: bool,
132 #[serde(default = "default_tool_dependency_scope")]
135 pub scope: DependencyScope,
136 pub source: DependencySourceSpec,
139 #[serde(default)]
142 pub packages: BTreeMap<String, DependencyPackageSpec>,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct LuaDependencySpec {
149 pub name: String,
152 #[serde(default)]
155 pub version: Option<String>,
156 #[serde(default = "default_required_dependency")]
159 pub required: bool,
160 #[serde(default = "default_runtime_library_scope")]
163 pub scope: DependencyScope,
164 pub source: DependencySourceSpec,
167 #[serde(default)]
170 pub packages: BTreeMap<String, DependencyPackageSpec>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct FfiDependencySpec {
177 pub name: String,
180 #[serde(default)]
183 pub version: Option<String>,
184 #[serde(default = "default_required_dependency")]
187 pub required: bool,
188 #[serde(default = "default_runtime_library_scope")]
191 pub scope: DependencyScope,
192 pub source: DependencySourceSpec,
195 #[serde(default)]
198 pub packages: BTreeMap<String, DependencyPackageSpec>,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum PythonRuntimePackageManager {
206 Uv,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum NodeRuntimePackageManager {
216 Pnpm,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct PythonRuntimeDependencySpec {
225 pub version: String,
228 pub package_manager: PythonRuntimePackageManager,
231 pub package_manager_version: String,
234 #[serde(default)]
237 pub lockfile: String,
238 #[serde(default = "default_required_dependency")]
241 pub required: bool,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct NodeRuntimeDependencySpec {
248 pub version: String,
251 pub package_manager: NodeRuntimePackageManager,
254 pub package_manager_version: String,
257 #[serde(default)]
260 pub package_json: String,
261 #[serde(default)]
264 pub lockfile: String,
265 #[serde(default = "default_required_dependency")]
268 pub required: bool,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
274pub struct PackageDependencyManifest {
275 #[serde(default)]
278 pub tool_dependencies: Vec<ToolDependencySpec>,
279 #[serde(default)]
282 pub lua_dependencies: Vec<LuaDependencySpec>,
283 #[serde(default)]
286 pub ffi_dependencies: Vec<FfiDependencySpec>,
287 #[serde(default)]
290 pub python_runtime: Option<PythonRuntimeDependencySpec>,
291 #[serde(default)]
294 pub node_runtime: Option<NodeRuntimeDependencySpec>,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct SkillListPackageManifest {
301 #[serde(default)]
304 pub version: Option<String>,
305 #[serde(default)]
308 pub packages: BTreeMap<String, DependencyPackageSpec>,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
314pub struct SkillListIndexFile {
315 #[serde(default)]
318 pub packages: BTreeMap<String, SkillListPackageManifest>,
319}
320
321fn default_required_dependency() -> bool {
324 true
325}
326
327fn default_tool_dependency_scope() -> DependencyScope {
330 DependencyScope::Skill
331}
332
333fn default_runtime_library_scope() -> DependencyScope {
336 DependencyScope::Skill
337}
338
339impl PackageDependencyManifest {
340 pub fn load_from_path(path: &Path) -> Result<Self, String> {
343 let yaml_text = fs::read_to_string(path).map_err(|error| {
344 format!(
345 "Failed to read {}: {}",
346 render_host_visible_path(path),
347 error
348 )
349 })?;
350 serde_yaml::from_str(&yaml_text).map_err(|error| {
351 format!(
352 "Failed to parse {}: {}",
353 render_host_visible_path(path),
354 error
355 )
356 })
357 }
358
359 pub fn is_empty(&self) -> bool {
362 self.tool_dependencies.is_empty()
363 && self.lua_dependencies.is_empty()
364 && self.ffi_dependencies.is_empty()
365 && self.python_runtime.is_none()
366 && self.node_runtime.is_none()
367 }
368}
369
370impl ToolDependencySpec {
371 pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
374 self.packages.get(platform_key)
375 }
376}
377
378impl LuaDependencySpec {
379 pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
382 self.packages.get(platform_key)
383 }
384}
385
386impl FfiDependencySpec {
387 pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
390 self.packages.get(platform_key)
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::PackageDependencyManifest;
397 use crate::runtime::path::render_host_visible_path;
398 use std::fs;
399 use std::time::{SystemTime, UNIX_EPOCH};
400
401 fn make_dependency_manifest_test_dir(label: &str) -> std::path::PathBuf {
404 let nonce = SystemTime::now()
405 .duration_since(UNIX_EPOCH)
406 .unwrap_or_default()
407 .as_nanos();
408 std::env::temp_dir().join(format!("luaskills_dependency_manifest_{label}_{nonce}"))
409 }
410
411 #[test]
414 fn dependency_manifest_parse_error_uses_host_visible_path() {
415 let temp_root = make_dependency_manifest_test_dir("parse_error_path");
418 fs::create_dir_all(&temp_root).expect("temp root should be created");
419 let manifest_path = temp_root.join("dependencies.yaml");
422 fs::write(&manifest_path, "tool_dependencies: [")
423 .expect("invalid dependency manifest should be written");
424 let error = PackageDependencyManifest::load_from_path(&manifest_path)
427 .expect_err("invalid dependency manifest should fail");
428 let expected_prefix = format!(
431 "Failed to parse {}:",
432 render_host_visible_path(&manifest_path)
433 );
434
435 assert!(
436 error.starts_with(&expected_prefix),
437 "unexpected error: {}",
438 error
439 );
440 let _ = fs::remove_dir_all(&temp_root);
443 }
444
445 #[test]
448 fn parse_dependency_manifest_groups() {
449 let yaml_text = r#"
450tool_dependencies:
451 - name: ast-grep
452 scope: skill
453 source:
454 type: github_release
455 github:
456 repo: ast-grep/ast-grep
457 packages:
458 windows-x64:
459 archive_type: zip
460 asset_name: app-x86_64-pc-windows-msvc.zip
461 exports:
462 - archive_path: ast-grep.exe
463 target_path: bin/ast-grep.exe
464lua_dependencies:
465 - name: lua-cjson
466 required: false
467 scope: skill
468 source:
469 type: url
470 url: {}
471 packages:
472 windows-x64:
473 archive_type: raw
474 url: https://example.com/cjson.lua
475 exports:
476 - archive_path: cjson.lua
477 target_path: share/lua/cjson.lua
478ffi_dependencies:
479 - name: example-lib
480 source:
481 type: skilllist
482 skilllist:
483 url: https://example.com/index.yaml
484 package: example-lib
485"#;
486 let manifest: PackageDependencyManifest =
487 serde_yaml::from_str(yaml_text).expect("manifest should parse");
488 assert_eq!(manifest.tool_dependencies.len(), 1);
489 assert_eq!(manifest.lua_dependencies.len(), 1);
490 assert_eq!(manifest.ffi_dependencies.len(), 1);
491 assert_eq!(
492 manifest.tool_dependencies[0].scope,
493 crate::dependency::types::DependencyScope::Skill
494 );
495 assert_eq!(
496 manifest.lua_dependencies[0].scope,
497 crate::dependency::types::DependencyScope::Skill
498 );
499 assert_eq!(
500 manifest.tool_dependencies[0]
501 .package_for_platform("windows-x64")
502 .expect("windows package should exist")
503 .exports[0]
504 .target_path,
505 "bin/ast-grep.exe"
506 );
507 }
508
509 #[test]
512 fn dependency_scope_defaults_match_kind_policy() {
513 let yaml_text = r#"
514tool_dependencies:
515 - name: rg
516 source:
517 type: url
518 url: {}
519 packages: {}
520lua_dependencies:
521 - name: demo-lua
522 source:
523 type: url
524 url: {}
525 packages: {}
526ffi_dependencies:
527 - name: demo-ffi
528 source:
529 type: url
530 url: {}
531 packages: {}
532"#;
533 let manifest: PackageDependencyManifest =
534 serde_yaml::from_str(yaml_text).expect("manifest should parse");
535 assert_eq!(
536 manifest.tool_dependencies[0].scope,
537 crate::dependency::types::DependencyScope::Skill
538 );
539 assert_eq!(
540 manifest.lua_dependencies[0].scope,
541 crate::dependency::types::DependencyScope::Skill
542 );
543 assert_eq!(
544 manifest.ffi_dependencies[0].scope,
545 crate::dependency::types::DependencyScope::Skill
546 );
547 }
548
549 #[test]
552 fn parse_managed_runtime_dependency_declarations() {
553 let yaml_text = r#"
554python_runtime:
555 version: "3.12.8"
556 package_manager: uv
557 package_manager_version: "0.11.28"
558 lockfile: python/requirements.lock
559node_runtime:
560 version: "24.18.0"
561 package_manager: pnpm
562 package_manager_version: "11.11.0"
563 package_json: node/package.json
564 lockfile: node/pnpm-lock.yaml
565"#;
566 let manifest: PackageDependencyManifest =
567 serde_yaml::from_str(yaml_text).expect("manifest should parse");
568 let python_runtime = manifest
569 .python_runtime
570 .expect("python runtime should parse");
571 let node_runtime = manifest.node_runtime.expect("node runtime should parse");
572
573 assert_eq!(python_runtime.version, "3.12.8");
574 assert_eq!(
575 python_runtime.package_manager,
576 super::PythonRuntimePackageManager::Uv
577 );
578 assert_eq!(python_runtime.lockfile, "python/requirements.lock");
579 assert!(python_runtime.required);
580 assert_eq!(node_runtime.version, "24.18.0");
581 assert_eq!(
582 node_runtime.package_manager,
583 super::NodeRuntimePackageManager::Pnpm
584 );
585 assert_eq!(node_runtime.package_json, "node/package.json");
586 assert_eq!(node_runtime.lockfile, "node/pnpm-lock.yaml");
587 assert!(node_runtime.required);
588 assert!(
589 !PackageDependencyManifest {
590 python_runtime: Some(python_runtime),
591 node_runtime: Some(node_runtime),
592 ..PackageDependencyManifest::default()
593 }
594 .is_empty()
595 );
596 }
597}