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;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DependencyArchiveType {
Zip,
TarGz,
#[default]
Raw,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DependencyExportSpec {
pub archive_path: String,
pub target_path: String,
#[serde(default)]
pub executable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DependencyPackageSpec {
#[serde(default)]
pub archive_type: DependencyArchiveType,
#[serde(default)]
pub asset_name: Option<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub exports: Vec<DependencyExportSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GithubReleaseSourceSpec {
pub repo: String,
#[serde(default)]
pub tag_api: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct UrlSourceSpec {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillListSourceSpec {
pub url: String,
pub package: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DependencySourceSpec {
#[serde(rename = "type")]
pub source_type: DependencySourceType,
#[serde(default)]
pub github: Option<GithubReleaseSourceSpec>,
#[serde(default)]
pub url: Option<UrlSourceSpec>,
#[serde(default)]
pub skilllist: Option<SkillListSourceSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolDependencySpec {
pub name: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default = "default_required_dependency")]
pub required: bool,
#[serde(default = "default_tool_dependency_scope")]
pub scope: DependencyScope,
pub source: DependencySourceSpec,
#[serde(default)]
pub packages: BTreeMap<String, DependencyPackageSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LuaDependencySpec {
pub name: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default = "default_required_dependency")]
pub required: bool,
#[serde(default = "default_runtime_library_scope")]
pub scope: DependencyScope,
pub source: DependencySourceSpec,
#[serde(default)]
pub packages: BTreeMap<String, DependencyPackageSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FfiDependencySpec {
pub name: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default = "default_required_dependency")]
pub required: bool,
#[serde(default = "default_runtime_library_scope")]
pub scope: DependencyScope,
pub source: DependencySourceSpec,
#[serde(default)]
pub packages: BTreeMap<String, DependencyPackageSpec>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PythonRuntimePackageManager {
Uv,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeRuntimePackageManager {
Pnpm,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PythonRuntimeDependencySpec {
pub version: String,
pub package_manager: PythonRuntimePackageManager,
pub package_manager_version: String,
#[serde(default)]
pub lockfile: String,
#[serde(default = "default_required_dependency")]
pub required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeRuntimeDependencySpec {
pub version: String,
pub package_manager: NodeRuntimePackageManager,
pub package_manager_version: String,
#[serde(default)]
pub package_json: String,
#[serde(default)]
pub lockfile: String,
#[serde(default = "default_required_dependency")]
pub required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PackageDependencyManifest {
#[serde(default)]
pub tool_dependencies: Vec<ToolDependencySpec>,
#[serde(default)]
pub lua_dependencies: Vec<LuaDependencySpec>,
#[serde(default)]
pub ffi_dependencies: Vec<FfiDependencySpec>,
#[serde(default)]
pub python_runtime: Option<PythonRuntimeDependencySpec>,
#[serde(default)]
pub node_runtime: Option<NodeRuntimeDependencySpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillListPackageManifest {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub packages: BTreeMap<String, DependencyPackageSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SkillListIndexFile {
#[serde(default)]
pub packages: BTreeMap<String, SkillListPackageManifest>,
}
fn default_required_dependency() -> bool {
true
}
fn default_tool_dependency_scope() -> DependencyScope {
DependencyScope::Skill
}
fn default_runtime_library_scope() -> DependencyScope {
DependencyScope::Skill
}
impl PackageDependencyManifest {
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
)
})
}
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 {
pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
self.packages.get(platform_key)
}
}
impl LuaDependencySpec {
pub fn package_for_platform(&self, platform_key: &str) -> Option<&DependencyPackageSpec> {
self.packages.get(platform_key)
}
}
impl FfiDependencySpec {
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};
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}"))
}
#[test]
fn dependency_manifest_parse_error_uses_host_visible_path() {
let temp_root = make_dependency_manifest_test_dir("parse_error_path");
fs::create_dir_all(&temp_root).expect("temp root should be created");
let manifest_path = temp_root.join("dependencies.yaml");
fs::write(&manifest_path, "tool_dependencies: [")
.expect("invalid dependency manifest should be written");
let error = PackageDependencyManifest::load_from_path(&manifest_path)
.expect_err("invalid dependency manifest should fail");
let expected_prefix = format!(
"Failed to parse {}:",
render_host_visible_path(&manifest_path)
);
assert!(
error.starts_with(&expected_prefix),
"unexpected error: {}",
error
);
let _ = fs::remove_dir_all(&temp_root);
}
#[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"
);
}
#[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
);
}
#[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()
);
}
}