Skip to main content

luaskills/skill/
dependencies.rs

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/// Supported archive formats used by LuaSkills dependency packages.
10/// LuaSkills 依赖包支持的归档格式。
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "snake_case")]
13pub enum DependencyArchiveType {
14    /// Treat the downloaded payload as one zip archive.
15    /// 将下载载荷视为 zip 归档。
16    Zip,
17    /// Treat the downloaded payload as one tar.gz archive.
18    /// 将下载载荷视为 tar.gz 归档。
19    TarGz,
20    /// Treat the downloaded payload as one raw single file.
21    /// 将下载载荷视为单个原始文件。
22    #[default]
23    Raw,
24}
25
26/// One exported file rule used both for installation and existence detection.
27/// 同时用于安装与存在性检测的单个导出文件规则。
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct DependencyExportSpec {
30    /// Relative path inside the downloaded archive or raw file payload.
31    /// 下载归档或原始文件载荷内部的相对路径。
32    pub archive_path: String,
33    /// Relative destination path under the dependency root.
34    /// 依赖根目录下的相对目标路径。
35    pub target_path: String,
36    /// Whether the exported file should be marked executable on Unix platforms.
37    /// 是否应在 Unix 平台上把导出文件标记为可执行。
38    #[serde(default)]
39    pub executable: bool,
40}
41
42/// One platform-specific package record resolved before actual download.
43/// 在实际下载前解析得到的单个平台包记录。
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct DependencyPackageSpec {
46    /// Archive format of the package payload.
47    /// 包载荷对应的归档格式。
48    #[serde(default)]
49    pub archive_type: DependencyArchiveType,
50    /// Exact GitHub asset file name used for github_release downloads.
51    /// 用于 github_release 下载的精确 GitHub 资产文件名。
52    #[serde(default)]
53    pub asset_name: Option<String>,
54    /// Exact download URL used for direct url or resolved skilllist packages.
55    /// 用于直接 url 或已解析 skilllist 包的精确下载地址。
56    #[serde(default)]
57    pub url: Option<String>,
58    /// Exported files that must be installed and later used for existence checks.
59    /// 必须被安装、并在之后用于存在性检测的导出文件列表。
60    #[serde(default)]
61    pub exports: Vec<DependencyExportSpec>,
62}
63
64/// GitHub-release source configuration used by one dependency.
65/// 单个依赖使用的 GitHub Release 来源配置。
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct GithubReleaseSourceSpec {
68    /// GitHub repository in `owner/repo` format.
69    /// `owner
70    /// repo` 形式的 GitHub 仓库标识。
71    pub repo: String,
72    /// Optional explicit release API URL. When omitted the host-configured GitHub API base is used.
73    /// 可选的显式 release API 地址;省略时使用宿主配置的 GitHub API 基址。
74    #[serde(default)]
75    pub tag_api: Option<String>,
76}
77
78/// Direct-URL source configuration used by one dependency.
79/// 单个依赖使用的直接 URL 来源配置。
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
81pub struct UrlSourceSpec {}
82
83/// Skill-list source configuration used by one dependency.
84/// 单个依赖使用的 skilllist 来源配置。
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct SkillListSourceSpec {
87    /// URL of the remote skill/tool dependency list file.
88    /// 远程 skill/tool 依赖列表文件的下载地址。
89    pub url: String,
90    /// Package key inside the downloaded list file.
91    /// 下载后的列表文件内部使用的包键名。
92    pub package: String,
93}
94
95/// Unified source specification used by all dependency kinds.
96/// 所有依赖类型共用的统一来源描述。
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct DependencySourceSpec {
99    /// Upstream source type used for package resolution.
100    /// 包解析时使用的上游来源类型。
101    #[serde(rename = "type")]
102    pub source_type: DependencySourceType,
103    /// Optional GitHub-release source payload.
104    /// 可选的 GitHub Release 来源载荷。
105    #[serde(default)]
106    pub github: Option<GithubReleaseSourceSpec>,
107    /// Optional direct-URL source payload.
108    /// 可选的直接 URL 来源载荷。
109    #[serde(default)]
110    pub url: Option<UrlSourceSpec>,
111    /// Optional skilllist source payload.
112    /// 可选的 skilllist 来源载荷。
113    #[serde(default)]
114    pub skilllist: Option<SkillListSourceSpec>,
115}
116
117/// Tool dependency declaration loaded from one skill package.
118/// 从单个 skill 包加载的工具依赖声明。
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct ToolDependencySpec {
121    /// Stable dependency name.
122    /// 稳定依赖名称。
123    pub name: String,
124    /// Optional expected version string used for display and asset interpolation.
125    /// 用于展示和资产名插值的可选预期版本字符串。
126    #[serde(default)]
127    pub version: Option<String>,
128    /// Whether the dependency is required for the skill to load.
129    /// 当前依赖是否为技能加载所必需。
130    #[serde(default = "default_required_dependency")]
131    pub required: bool,
132    /// Install scope of the current dependency. Tool dependencies default to skill-private.
133    /// 当前依赖的安装作用域。工具依赖默认使用 skill 私有作用域。
134    #[serde(default = "default_tool_dependency_scope")]
135    pub scope: DependencyScope,
136    /// Dependency source specification.
137    /// 依赖来源描述。
138    pub source: DependencySourceSpec,
139    /// Platform-specific package descriptors.
140    /// 平台对应的包描述表。
141    #[serde(default)]
142    pub packages: BTreeMap<String, DependencyPackageSpec>,
143}
144
145/// Lua package dependency declaration loaded from one skill package.
146/// 从单个 skill 包加载的 Lua 库依赖声明。
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct LuaDependencySpec {
149    /// Stable dependency name.
150    /// 稳定依赖名称。
151    pub name: String,
152    /// Optional expected version string used for display and asset interpolation.
153    /// 用于展示和资产名插值的可选预期版本字符串。
154    #[serde(default)]
155    pub version: Option<String>,
156    /// Whether the dependency is required for the skill to load.
157    /// 当前依赖是否为技能加载所必需。
158    #[serde(default = "default_required_dependency")]
159    pub required: bool,
160    /// Install scope of the current dependency. Lua dependencies default to skill-private.
161    /// 当前依赖的安装作用域。Lua 依赖默认使用 skill 私有作用域。
162    #[serde(default = "default_runtime_library_scope")]
163    pub scope: DependencyScope,
164    /// Dependency source specification.
165    /// 依赖来源描述。
166    pub source: DependencySourceSpec,
167    /// Platform-specific package descriptors.
168    /// 平台对应的包描述表。
169    #[serde(default)]
170    pub packages: BTreeMap<String, DependencyPackageSpec>,
171}
172
173/// FFI/native library dependency declaration loaded from one skill package.
174/// 从单个 skill 包加载的 FFI/原生库依赖声明。
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct FfiDependencySpec {
177    /// Stable dependency name.
178    /// 稳定依赖名称。
179    pub name: String,
180    /// Optional expected version string used for display and asset interpolation.
181    /// 用于展示和资产名插值的可选预期版本字符串。
182    #[serde(default)]
183    pub version: Option<String>,
184    /// Whether the dependency is required for the skill to load.
185    /// 当前依赖是否为技能加载所必需。
186    #[serde(default = "default_required_dependency")]
187    pub required: bool,
188    /// Install scope of the current dependency. FFI dependencies default to skill-private.
189    /// 当前依赖的安装作用域。FFI 依赖默认使用 skill 私有作用域。
190    #[serde(default = "default_runtime_library_scope")]
191    pub scope: DependencyScope,
192    /// Dependency source specification.
193    /// 依赖来源描述。
194    pub source: DependencySourceSpec,
195    /// Platform-specific package descriptors.
196    /// 平台对应的包描述表。
197    #[serde(default)]
198    pub packages: BTreeMap<String, DependencyPackageSpec>,
199}
200
201/// Package manager used by one managed Python runtime declaration.
202/// 单个受管 Python 运行时声明使用的包管理器。
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum PythonRuntimePackageManager {
206    /// Use the uv package manager and environment synchronizer.
207    /// 使用 uv 包管理器与环境同步器。
208    Uv,
209}
210
211/// Package manager used by one managed Node.js runtime declaration.
212/// 单个受管 Node.js 运行时声明使用的包管理器。
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum NodeRuntimePackageManager {
216    /// Use pnpm with one host-managed content-addressed package store.
217    /// 使用 pnpm 与宿主管理的内容寻址包存储。
218    Pnpm,
219}
220
221/// Managed Python runtime dependency declared by one runtime package.
222/// 单个运行时包声明的受管 Python 运行时依赖。
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct PythonRuntimeDependencySpec {
225    /// Exact Python runtime version requested by the package.
226    /// 当前包请求的精确 Python 运行时版本。
227    pub version: String,
228    /// Python package manager selected for environment creation.
229    /// 用于创建环境的 Python 包管理器。
230    pub package_manager: PythonRuntimePackageManager,
231    /// Exact package-manager version requested by the skill.
232    /// 当前 skill 请求的精确包管理器版本。
233    pub package_manager_version: String,
234    /// Lockfile path under the current package root.
235    /// 当前包根目录下的锁文件路径。
236    #[serde(default)]
237    pub lockfile: String,
238    /// Whether this managed runtime is required for the package to load.
239    /// 当前受管运行时是否为包加载所必需。
240    #[serde(default = "default_required_dependency")]
241    pub required: bool,
242}
243
244/// Managed Node.js runtime dependency declared by one runtime package.
245/// 单个运行时包声明的受管 Node.js 运行时依赖。
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct NodeRuntimeDependencySpec {
248    /// Exact Node.js runtime version requested by the package.
249    /// 当前包请求的精确 Node.js 运行时版本。
250    pub version: String,
251    /// Node.js package manager selected for environment creation.
252    /// 用于创建环境的 Node.js 包管理器。
253    pub package_manager: NodeRuntimePackageManager,
254    /// Exact package-manager version requested by the skill.
255    /// 当前 skill 请求的精确包管理器版本。
256    pub package_manager_version: String,
257    /// Optional package.json path under the current package root.
258    /// 当前包根目录下的可选 package.json 路径。
259    #[serde(default)]
260    pub package_json: String,
261    /// Lockfile path under the current package root.
262    /// 当前包根目录下的锁文件路径。
263    #[serde(default)]
264    pub lockfile: String,
265    /// Whether this managed runtime is required for the package to load.
266    /// 当前受管运行时是否为包加载所必需。
267    #[serde(default = "default_required_dependency")]
268    pub required: bool,
269}
270
271/// Full dependencies.yaml payload loaded from one managed runtime package.
272/// 从单个受管运行时包加载的完整 dependencies.yaml 载荷。
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
274pub struct PackageDependencyManifest {
275    /// Tool dependencies such as rg or ast-grep.
276    /// 例如 rg 或 ast-grep 一类的工具依赖。
277    #[serde(default)]
278    pub tool_dependencies: Vec<ToolDependencySpec>,
279    /// Lua module dependencies installed under one host-managed lua root.
280    /// 安装到宿主管理 Lua 根目录下的 Lua 模块依赖。
281    #[serde(default)]
282    pub lua_dependencies: Vec<LuaDependencySpec>,
283    /// FFI/native library dependencies installed under one host-managed ffi root.
284    /// 安装到宿主管理 FFI 根目录下的原生库依赖。
285    #[serde(default)]
286    pub ffi_dependencies: Vec<FfiDependencySpec>,
287    /// Optional managed Python runtime declaration used by Lua orchestration.
288    /// 由 Lua 编排调用的可选受管 Python 运行时声明。
289    #[serde(default)]
290    pub python_runtime: Option<PythonRuntimeDependencySpec>,
291    /// Optional managed Node.js runtime declaration used by Lua orchestration.
292    /// 由 Lua 编排调用的可选受管 Node.js 运行时声明。
293    #[serde(default)]
294    pub node_runtime: Option<NodeRuntimeDependencySpec>,
295}
296
297/// One skilllist package manifest downloaded from one remote list file.
298/// 从远程列表文件下载得到的单个 skilllist 包清单。
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct SkillListPackageManifest {
301    /// Optional resolved version declared by the list provider.
302    /// 列表提供方声明的可选已解析版本号。
303    #[serde(default)]
304    pub version: Option<String>,
305    /// Platform-specific package descriptors resolved from the list provider.
306    /// 从列表提供方解析到的平台包描述表。
307    #[serde(default)]
308    pub packages: BTreeMap<String, DependencyPackageSpec>,
309}
310
311/// Remote skilllist file payload that maps package keys to package manifests.
312/// 把包键映射到包清单的远程 skilllist 文件载荷。
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
314pub struct SkillListIndexFile {
315    /// Package map resolved from the remote list file.
316    /// 从远程列表文件解析得到的包映射。
317    #[serde(default)]
318    pub packages: BTreeMap<String, SkillListPackageManifest>,
319}
320
321/// Return the default dependency required flag.
322/// 返回依赖 required 标志的默认值。
323fn default_required_dependency() -> bool {
324    true
325}
326
327/// Return the default install scope used by tool dependencies.
328/// 返回工具依赖默认使用的安装作用域。
329fn default_tool_dependency_scope() -> DependencyScope {
330    DependencyScope::Skill
331}
332
333/// Return the default install scope used by Lua and FFI runtime-library dependencies.
334/// 返回 Lua 与 FFI 运行时库依赖默认使用的安装作用域。
335fn default_runtime_library_scope() -> DependencyScope {
336    DependencyScope::Skill
337}
338
339impl PackageDependencyManifest {
340    /// Load one dependency manifest from `dependencies.yaml`.
341    /// 从 `dependencies.yaml` 加载一份依赖清单。
342    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    /// Return whether the manifest contains any declared dependency.
360    /// 返回当前清单是否声明了任何依赖。
361    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    /// Return the platform package descriptor selected for one normalized platform key.
372    /// 返回按标准平台键选中的平台包描述。
373    pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
374        self.packages.get(platform_key)
375    }
376}
377
378impl LuaDependencySpec {
379    /// Return the platform package descriptor selected for one normalized platform key.
380    /// 返回按标准平台键选中的平台包描述。
381    pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
382        self.packages.get(platform_key)
383    }
384}
385
386impl FfiDependencySpec {
387    /// Return the platform package descriptor selected for one normalized platform key.
388    /// 返回按标准平台键选中的平台包描述。
389    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    /// Build one unique temporary dependency-manifest test root.
402    /// 构造一个唯一的依赖清单测试临时根目录。
403    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    /// Verify dependency-manifest parse errors render paths through the host-visible formatter.
412    /// 验证依赖清单解析错误会通过宿主可见路径渲染器输出路径。
413    #[test]
414    fn dependency_manifest_parse_error_uses_host_visible_path() {
415        // Temporary root that isolates the invalid dependency manifest fixture.
416        // 隔离非法依赖清单夹具的临时根目录。
417        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        // Dependency manifest path used by the real load_from_path entrypoint.
420        // 真实 load_from_path 入口使用的依赖清单路径。
421        let manifest_path = temp_root.join("dependencies.yaml");
422        fs::write(&manifest_path, "tool_dependencies: [")
423            .expect("invalid dependency manifest should be written");
424        // Error returned by the real dependency manifest loader.
425        // 真实依赖清单加载器返回的错误。
426        let error = PackageDependencyManifest::load_from_path(&manifest_path)
427            .expect_err("invalid dependency manifest should fail");
428        // Expected diagnostic prefix rendered with the shared host-visible path formatter.
429        // 使用共享宿主可见路径渲染器生成的期望诊断前缀。
430        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        // Cleanup result is intentionally ignored for best-effort temporary test artifacts.
441        // 对临时测试产物的清理结果按最佳努力原则有意忽略。
442        let _ = fs::remove_dir_all(&temp_root);
443    }
444
445    /// Verify that the new dependency manifest format parses tool/lua/ffi groups correctly.
446    /// 验证新的依赖清单格式能正确解析 tool/lua/ffi 三个分组。
447    #[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    /// Verify that missing scope fields still fall back to the expected per-kind defaults.
510    /// 验证省略 scope 字段时仍会回落到按依赖类型定义的默认作用域。
511    #[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    /// Verify that managed Python and Node runtime declarations parse from dependencies.yaml.
550    /// 验证受管 Python 与 Node 运行时声明可以从 dependencies.yaml 中解析。
551    #[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}