luaskills 0.5.2

LuaSkills core runtime library for loading, invoking, and managing Lua skill packages.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use crate::dependency::types::{DependencyScope, DependencySourceType};
use crate::runtime::path::render_host_visible_path;

/// Supported archive formats used by LuaSkills dependency packages.
/// LuaSkills 依赖包支持的归档格式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DependencyArchiveType {
    /// Treat the downloaded payload as one zip archive.
    /// 将下载载荷视为 zip 归档。
    Zip,
    /// Treat the downloaded payload as one tar.gz archive.
    /// 将下载载荷视为 tar.gz 归档。
    TarGz,
    /// Treat the downloaded payload as one raw single file.
    /// 将下载载荷视为单个原始文件。
    #[default]
    Raw,
}

/// One exported file rule used both for installation and existence detection.
/// 同时用于安装与存在性检测的单个导出文件规则。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DependencyExportSpec {
    /// Relative path inside the downloaded archive or raw file payload.
    /// 下载归档或原始文件载荷内部的相对路径。
    pub archive_path: String,
    /// Relative destination path under the dependency root.
    /// 依赖根目录下的相对目标路径。
    pub target_path: String,
    /// Whether the exported file should be marked executable on Unix platforms.
    /// 是否应在 Unix 平台上把导出文件标记为可执行。
    #[serde(default)]
    pub executable: bool,
}

/// One platform-specific package record resolved before actual download.
/// 在实际下载前解析得到的单个平台包记录。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DependencyPackageSpec {
    /// Archive format of the package payload.
    /// 包载荷对应的归档格式。
    #[serde(default)]
    pub archive_type: DependencyArchiveType,
    /// Exact GitHub asset file name used for github_release downloads.
    /// 用于 github_release 下载的精确 GitHub 资产文件名。
    #[serde(default)]
    pub asset_name: Option<String>,
    /// Exact download URL used for direct url or resolved skilllist packages.
    /// 用于直接 url 或已解析 skilllist 包的精确下载地址。
    #[serde(default)]
    pub url: Option<String>,
    /// Exported files that must be installed and later used for existence checks.
    /// 必须被安装、并在之后用于存在性检测的导出文件列表。
    #[serde(default)]
    pub exports: Vec<DependencyExportSpec>,
}

/// GitHub-release source configuration used by one dependency.
/// 单个依赖使用的 GitHub Release 来源配置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GithubReleaseSourceSpec {
    /// GitHub repository in `owner/repo` format.
    /// `owner
    /// repo` 形式的 GitHub 仓库标识。
    pub repo: String,
    /// Optional explicit release API URL. When omitted the host-configured GitHub API base is used.
    /// 可选的显式 release API 地址;省略时使用宿主配置的 GitHub API 基址。
    #[serde(default)]
    pub tag_api: Option<String>,
}

/// Direct-URL source configuration used by one dependency.
/// 单个依赖使用的直接 URL 来源配置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct UrlSourceSpec {}

/// Skill-list source configuration used by one dependency.
/// 单个依赖使用的 skilllist 来源配置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillListSourceSpec {
    /// URL of the remote skill/tool dependency list file.
    /// 远程 skill/tool 依赖列表文件的下载地址。
    pub url: String,
    /// Package key inside the downloaded list file.
    /// 下载后的列表文件内部使用的包键名。
    pub package: String,
}

/// Unified source specification used by all dependency kinds.
/// 所有依赖类型共用的统一来源描述。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DependencySourceSpec {
    /// Upstream source type used for package resolution.
    /// 包解析时使用的上游来源类型。
    #[serde(rename = "type")]
    pub source_type: DependencySourceType,
    /// Optional GitHub-release source payload.
    /// 可选的 GitHub Release 来源载荷。
    #[serde(default)]
    pub github: Option<GithubReleaseSourceSpec>,
    /// Optional direct-URL source payload.
    /// 可选的直接 URL 来源载荷。
    #[serde(default)]
    pub url: Option<UrlSourceSpec>,
    /// Optional skilllist source payload.
    /// 可选的 skilllist 来源载荷。
    #[serde(default)]
    pub skilllist: Option<SkillListSourceSpec>,
}

/// Tool dependency declaration loaded from one skill package.
/// 从单个 skill 包加载的工具依赖声明。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolDependencySpec {
    /// Stable dependency name.
    /// 稳定依赖名称。
    pub name: String,
    /// Optional expected version string used for display and asset interpolation.
    /// 用于展示和资产名插值的可选预期版本字符串。
    #[serde(default)]
    pub version: Option<String>,
    /// Whether the dependency is required for the skill to load.
    /// 当前依赖是否为技能加载所必需。
    #[serde(default = "default_required_dependency")]
    pub required: bool,
    /// Install scope of the current dependency. Tool dependencies default to skill-private.
    /// 当前依赖的安装作用域。工具依赖默认使用 skill 私有作用域。
    #[serde(default = "default_tool_dependency_scope")]
    pub scope: DependencyScope,
    /// Dependency source specification.
    /// 依赖来源描述。
    pub source: DependencySourceSpec,
    /// Platform-specific package descriptors.
    /// 平台对应的包描述表。
    #[serde(default)]
    pub packages: BTreeMap<String, DependencyPackageSpec>,
}

/// Lua package dependency declaration loaded from one skill package.
/// 从单个 skill 包加载的 Lua 库依赖声明。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LuaDependencySpec {
    /// Stable dependency name.
    /// 稳定依赖名称。
    pub name: String,
    /// Optional expected version string used for display and asset interpolation.
    /// 用于展示和资产名插值的可选预期版本字符串。
    #[serde(default)]
    pub version: Option<String>,
    /// Whether the dependency is required for the skill to load.
    /// 当前依赖是否为技能加载所必需。
    #[serde(default = "default_required_dependency")]
    pub required: bool,
    /// Install scope of the current dependency. Lua dependencies default to skill-private.
    /// 当前依赖的安装作用域。Lua 依赖默认使用 skill 私有作用域。
    #[serde(default = "default_runtime_library_scope")]
    pub scope: DependencyScope,
    /// Dependency source specification.
    /// 依赖来源描述。
    pub source: DependencySourceSpec,
    /// Platform-specific package descriptors.
    /// 平台对应的包描述表。
    #[serde(default)]
    pub packages: BTreeMap<String, DependencyPackageSpec>,
}

/// FFI/native library dependency declaration loaded from one skill package.
/// 从单个 skill 包加载的 FFI/原生库依赖声明。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FfiDependencySpec {
    /// Stable dependency name.
    /// 稳定依赖名称。
    pub name: String,
    /// Optional expected version string used for display and asset interpolation.
    /// 用于展示和资产名插值的可选预期版本字符串。
    #[serde(default)]
    pub version: Option<String>,
    /// Whether the dependency is required for the skill to load.
    /// 当前依赖是否为技能加载所必需。
    #[serde(default = "default_required_dependency")]
    pub required: bool,
    /// Install scope of the current dependency. FFI dependencies default to skill-private.
    /// 当前依赖的安装作用域。FFI 依赖默认使用 skill 私有作用域。
    #[serde(default = "default_runtime_library_scope")]
    pub scope: DependencyScope,
    /// Dependency source specification.
    /// 依赖来源描述。
    pub source: DependencySourceSpec,
    /// Platform-specific package descriptors.
    /// 平台对应的包描述表。
    #[serde(default)]
    pub packages: BTreeMap<String, DependencyPackageSpec>,
}

/// Package manager used by one managed Python runtime declaration.
/// 单个受管 Python 运行时声明使用的包管理器。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PythonRuntimePackageManager {
    /// Use the uv package manager and environment synchronizer.
    /// 使用 uv 包管理器与环境同步器。
    Uv,
}

/// Package manager used by one managed Node.js runtime declaration.
/// 单个受管 Node.js 运行时声明使用的包管理器。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeRuntimePackageManager {
    /// Use pnpm with one host-managed content-addressed package store.
    /// 使用 pnpm 与宿主管理的内容寻址包存储。
    Pnpm,
}

/// Managed Python runtime dependency declared by one runtime package.
/// 单个运行时包声明的受管 Python 运行时依赖。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PythonRuntimeDependencySpec {
    /// Exact Python runtime version requested by the package.
    /// 当前包请求的精确 Python 运行时版本。
    pub version: String,
    /// Python package manager selected for environment creation.
    /// 用于创建环境的 Python 包管理器。
    pub package_manager: PythonRuntimePackageManager,
    /// Exact package-manager version requested by the skill.
    /// 当前 skill 请求的精确包管理器版本。
    pub package_manager_version: String,
    /// Lockfile path under the current package root.
    /// 当前包根目录下的锁文件路径。
    #[serde(default)]
    pub lockfile: String,
    /// Whether this managed runtime is required for the package to load.
    /// 当前受管运行时是否为包加载所必需。
    #[serde(default = "default_required_dependency")]
    pub required: bool,
}

/// Managed Node.js runtime dependency declared by one runtime package.
/// 单个运行时包声明的受管 Node.js 运行时依赖。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeRuntimeDependencySpec {
    /// Exact Node.js runtime version requested by the package.
    /// 当前包请求的精确 Node.js 运行时版本。
    pub version: String,
    /// Node.js package manager selected for environment creation.
    /// 用于创建环境的 Node.js 包管理器。
    pub package_manager: NodeRuntimePackageManager,
    /// Exact package-manager version requested by the skill.
    /// 当前 skill 请求的精确包管理器版本。
    pub package_manager_version: String,
    /// Optional package.json path under the current package root.
    /// 当前包根目录下的可选 package.json 路径。
    #[serde(default)]
    pub package_json: String,
    /// Lockfile path under the current package root.
    /// 当前包根目录下的锁文件路径。
    #[serde(default)]
    pub lockfile: String,
    /// Whether this managed runtime is required for the package to load.
    /// 当前受管运行时是否为包加载所必需。
    #[serde(default = "default_required_dependency")]
    pub required: bool,
}

/// Full dependencies.yaml payload loaded from one managed runtime package.
/// 从单个受管运行时包加载的完整 dependencies.yaml 载荷。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PackageDependencyManifest {
    /// Tool dependencies such as rg or ast-grep.
    /// 例如 rg 或 ast-grep 一类的工具依赖。
    #[serde(default)]
    pub tool_dependencies: Vec<ToolDependencySpec>,
    /// Lua module dependencies installed under one host-managed lua root.
    /// 安装到宿主管理 Lua 根目录下的 Lua 模块依赖。
    #[serde(default)]
    pub lua_dependencies: Vec<LuaDependencySpec>,
    /// FFI/native library dependencies installed under one host-managed ffi root.
    /// 安装到宿主管理 FFI 根目录下的原生库依赖。
    #[serde(default)]
    pub ffi_dependencies: Vec<FfiDependencySpec>,
    /// Optional managed Python runtime declaration used by Lua orchestration.
    /// 由 Lua 编排调用的可选受管 Python 运行时声明。
    #[serde(default)]
    pub python_runtime: Option<PythonRuntimeDependencySpec>,
    /// Optional managed Node.js runtime declaration used by Lua orchestration.
    /// 由 Lua 编排调用的可选受管 Node.js 运行时声明。
    #[serde(default)]
    pub node_runtime: Option<NodeRuntimeDependencySpec>,
}

/// One skilllist package manifest downloaded from one remote list file.
/// 从远程列表文件下载得到的单个 skilllist 包清单。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillListPackageManifest {
    /// Optional resolved version declared by the list provider.
    /// 列表提供方声明的可选已解析版本号。
    #[serde(default)]
    pub version: Option<String>,
    /// Platform-specific package descriptors resolved from the list provider.
    /// 从列表提供方解析到的平台包描述表。
    #[serde(default)]
    pub packages: BTreeMap<String, DependencyPackageSpec>,
}

/// Remote skilllist file payload that maps package keys to package manifests.
/// 把包键映射到包清单的远程 skilllist 文件载荷。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SkillListIndexFile {
    /// Package map resolved from the remote list file.
    /// 从远程列表文件解析得到的包映射。
    #[serde(default)]
    pub packages: BTreeMap<String, SkillListPackageManifest>,
}

/// Return the default dependency required flag.
/// 返回依赖 required 标志的默认值。
fn default_required_dependency() -> bool {
    true
}

/// Return the default install scope used by tool dependencies.
/// 返回工具依赖默认使用的安装作用域。
fn default_tool_dependency_scope() -> DependencyScope {
    DependencyScope::Skill
}

/// Return the default install scope used by Lua and FFI runtime-library dependencies.
/// 返回 Lua 与 FFI 运行时库依赖默认使用的安装作用域。
fn default_runtime_library_scope() -> DependencyScope {
    DependencyScope::Skill
}

impl PackageDependencyManifest {
    /// Load one dependency manifest from `dependencies.yaml`.
    /// 从 `dependencies.yaml` 加载一份依赖清单。
    pub fn load_from_path(path: &Path) -> Result<Self, String> {
        let yaml_text = fs::read_to_string(path).map_err(|error| {
            format!(
                "Failed to read {}: {}",
                render_host_visible_path(path),
                error
            )
        })?;
        serde_yaml::from_str(&yaml_text).map_err(|error| {
            format!(
                "Failed to parse {}: {}",
                render_host_visible_path(path),
                error
            )
        })
    }

    /// Return whether the manifest contains any declared dependency.
    /// 返回当前清单是否声明了任何依赖。
    pub fn is_empty(&self) -> bool {
        self.tool_dependencies.is_empty()
            && self.lua_dependencies.is_empty()
            && self.ffi_dependencies.is_empty()
            && self.python_runtime.is_none()
            && self.node_runtime.is_none()
    }
}

impl ToolDependencySpec {
    /// Return the platform package descriptor selected for one normalized platform key.
    /// 返回按标准平台键选中的平台包描述。
    pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
        self.packages.get(platform_key)
    }
}

impl LuaDependencySpec {
    /// Return the platform package descriptor selected for one normalized platform key.
    /// 返回按标准平台键选中的平台包描述。
    pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
        self.packages.get(platform_key)
    }
}

impl FfiDependencySpec {
    /// Return the platform package descriptor selected for one normalized platform key.
    /// 返回按标准平台键选中的平台包描述。
    pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
        self.packages.get(platform_key)
    }
}

#[cfg(test)]
mod tests {
    use super::PackageDependencyManifest;
    use crate::runtime::path::render_host_visible_path;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    /// Build one unique temporary dependency-manifest test root.
    /// 构造一个唯一的依赖清单测试临时根目录。
    fn make_dependency_manifest_test_dir(label: &str) -> std::path::PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        std::env::temp_dir().join(format!("luaskills_dependency_manifest_{label}_{nonce}"))
    }

    /// Verify dependency-manifest parse errors render paths through the host-visible formatter.
    /// 验证依赖清单解析错误会通过宿主可见路径渲染器输出路径。
    #[test]
    fn dependency_manifest_parse_error_uses_host_visible_path() {
        // Temporary root that isolates the invalid dependency manifest fixture.
        // 隔离非法依赖清单夹具的临时根目录。
        let temp_root = make_dependency_manifest_test_dir("parse_error_path");
        fs::create_dir_all(&temp_root).expect("temp root should be created");
        // Dependency manifest path used by the real load_from_path entrypoint.
        // 真实 load_from_path 入口使用的依赖清单路径。
        let manifest_path = temp_root.join("dependencies.yaml");
        fs::write(&manifest_path, "tool_dependencies: [")
            .expect("invalid dependency manifest should be written");
        // Error returned by the real dependency manifest loader.
        // 真实依赖清单加载器返回的错误。
        let error = PackageDependencyManifest::load_from_path(&manifest_path)
            .expect_err("invalid dependency manifest should fail");
        // Expected diagnostic prefix rendered with the shared host-visible path formatter.
        // 使用共享宿主可见路径渲染器生成的期望诊断前缀。
        let expected_prefix = format!(
            "Failed to parse {}:",
            render_host_visible_path(&manifest_path)
        );

        assert!(
            error.starts_with(&expected_prefix),
            "unexpected error: {}",
            error
        );
        // Cleanup result is intentionally ignored for best-effort temporary test artifacts.
        // 对临时测试产物的清理结果按最佳努力原则有意忽略。
        let _ = fs::remove_dir_all(&temp_root);
    }

    /// Verify that the new dependency manifest format parses tool/lua/ffi groups correctly.
    /// 验证新的依赖清单格式能正确解析 tool/lua/ffi 三个分组。
    #[test]
    fn parse_dependency_manifest_groups() {
        let yaml_text = r#"
tool_dependencies:
  - name: ast-grep
    scope: skill
    source:
      type: github_release
      github:
        repo: ast-grep/ast-grep
    packages:
      windows-x64:
        archive_type: zip
        asset_name: app-x86_64-pc-windows-msvc.zip
        exports:
          - archive_path: ast-grep.exe
            target_path: bin/ast-grep.exe
lua_dependencies:
  - name: lua-cjson
    required: false
    scope: skill
    source:
      type: url
      url: {}
    packages:
      windows-x64:
        archive_type: raw
        url: https://example.com/cjson.lua
        exports:
          - archive_path: cjson.lua
            target_path: share/lua/cjson.lua
ffi_dependencies:
  - name: example-lib
    source:
      type: skilllist
      skilllist:
        url: https://example.com/index.yaml
        package: example-lib
"#;
        let manifest: PackageDependencyManifest =
            serde_yaml::from_str(yaml_text).expect("manifest should parse");
        assert_eq!(manifest.tool_dependencies.len(), 1);
        assert_eq!(manifest.lua_dependencies.len(), 1);
        assert_eq!(manifest.ffi_dependencies.len(), 1);
        assert_eq!(
            manifest.tool_dependencies[0].scope,
            crate::dependency::types::DependencyScope::Skill
        );
        assert_eq!(
            manifest.lua_dependencies[0].scope,
            crate::dependency::types::DependencyScope::Skill
        );
        assert_eq!(
            manifest.tool_dependencies[0]
                .package_for_platform("windows-x64")
                .expect("windows package should exist")
                .exports[0]
                .target_path,
            "bin/ast-grep.exe"
        );
    }

    /// Verify that missing scope fields still fall back to the expected per-kind defaults.
    /// 验证省略 scope 字段时仍会回落到按依赖类型定义的默认作用域。
    #[test]
    fn dependency_scope_defaults_match_kind_policy() {
        let yaml_text = r#"
tool_dependencies:
  - name: rg
    source:
      type: url
      url: {}
    packages: {}
lua_dependencies:
  - name: demo-lua
    source:
      type: url
      url: {}
    packages: {}
ffi_dependencies:
  - name: demo-ffi
    source:
      type: url
      url: {}
    packages: {}
"#;
        let manifest: PackageDependencyManifest =
            serde_yaml::from_str(yaml_text).expect("manifest should parse");
        assert_eq!(
            manifest.tool_dependencies[0].scope,
            crate::dependency::types::DependencyScope::Skill
        );
        assert_eq!(
            manifest.lua_dependencies[0].scope,
            crate::dependency::types::DependencyScope::Skill
        );
        assert_eq!(
            manifest.ffi_dependencies[0].scope,
            crate::dependency::types::DependencyScope::Skill
        );
    }

    /// Verify that managed Python and Node runtime declarations parse from dependencies.yaml.
    /// 验证受管 Python 与 Node 运行时声明可以从 dependencies.yaml 中解析。
    #[test]
    fn parse_managed_runtime_dependency_declarations() {
        let yaml_text = r#"
python_runtime:
  version: "3.12.8"
  package_manager: uv
  package_manager_version: "0.11.28"
  lockfile: python/requirements.lock
node_runtime:
  version: "24.18.0"
  package_manager: pnpm
  package_manager_version: "11.11.0"
  package_json: node/package.json
  lockfile: node/pnpm-lock.yaml
"#;
        let manifest: PackageDependencyManifest =
            serde_yaml::from_str(yaml_text).expect("manifest should parse");
        let python_runtime = manifest
            .python_runtime
            .expect("python runtime should parse");
        let node_runtime = manifest.node_runtime.expect("node runtime should parse");

        assert_eq!(python_runtime.version, "3.12.8");
        assert_eq!(
            python_runtime.package_manager,
            super::PythonRuntimePackageManager::Uv
        );
        assert_eq!(python_runtime.lockfile, "python/requirements.lock");
        assert!(python_runtime.required);
        assert_eq!(node_runtime.version, "24.18.0");
        assert_eq!(
            node_runtime.package_manager,
            super::NodeRuntimePackageManager::Pnpm
        );
        assert_eq!(node_runtime.package_json, "node/package.json");
        assert_eq!(node_runtime.lockfile, "node/pnpm-lock.yaml");
        assert!(node_runtime.required);
        assert!(
            !PackageDependencyManifest {
                python_runtime: Some(python_runtime),
                node_runtime: Some(node_runtime),
                ..PackageDependencyManifest::default()
            }
            .is_empty()
        );
    }
}