use roder_api::packages::{
PackageExtensionLaunch, PackageResourceFilters, PackageResourceKind, PackageSource,
derive_package_id, glob_match, package_resource_id, parse_package_resource_id,
parse_package_spec, validate_package_id,
};
fn parse(spec: &str) -> PackageSource {
parse_package_spec(spec).unwrap_or_else(|err| panic!("spec {spec:?} should parse: {err}"))
}
#[test]
fn npm_specs_parse_and_round_trip() {
let cases = [
("npm:left-pad", "left-pad", None),
("npm:left-pad@1.3.0", "left-pad", Some("1.3.0")),
("npm:@scope/pkg", "@scope/pkg", None),
(
"npm:@scope/pkg@2.0.0-rc.1",
"@scope/pkg",
Some("2.0.0-rc.1"),
),
];
for (spec, name, version) in cases {
let source = parse(spec);
assert_eq!(
source,
PackageSource::Npm {
name: name.to_string(),
version: version.map(str::to_string),
},
"{spec}"
);
assert_eq!(source.spec(), spec, "round trip for {spec}");
assert_eq!(source.pinned(), version.is_some());
}
}
#[test]
fn git_specs_parse_and_round_trip() {
let cases = [
(
"git:github.com/user/repo",
"https://github.com/user/repo",
None,
"git:https://github.com/user/repo",
),
(
"git:github.com/user/repo@v1",
"https://github.com/user/repo",
Some("v1"),
"git:https://github.com/user/repo@v1",
),
(
"git:git@github.com:user/repo",
"git@github.com:user/repo",
None,
"git:git@github.com:user/repo",
),
(
"git:git@github.com:user/repo@v1.0.0",
"git@github.com:user/repo",
Some("v1.0.0"),
"git:git@github.com:user/repo@v1.0.0",
),
(
"https://github.com/user/repo",
"https://github.com/user/repo",
None,
"git:https://github.com/user/repo",
),
(
"https://github.com/user/repo@v2",
"https://github.com/user/repo",
Some("v2"),
"git:https://github.com/user/repo@v2",
),
(
"ssh://git@github.com/user/repo",
"ssh://git@github.com/user/repo",
None,
"git:ssh://git@github.com/user/repo",
),
(
"ssh://git@github.com/user/repo@v1",
"ssh://git@github.com/user/repo",
Some("v1"),
"git:ssh://git@github.com/user/repo@v1",
),
(
"git://host/user/repo",
"git://host/user/repo",
None,
"git:git://host/user/repo",
),
(
"git:file:///tmp/fixture-repo",
"file:///tmp/fixture-repo",
None,
"git:file:///tmp/fixture-repo",
),
(
"git:file:///tmp/fixture-repo@main",
"file:///tmp/fixture-repo",
Some("main"),
"git:file:///tmp/fixture-repo@main",
),
];
for (input, url, ref_name, canonical) in cases {
let source = parse(input);
assert_eq!(
source,
PackageSource::Git {
url: url.to_string(),
ref_name: ref_name.map(str::to_string),
},
"{input}"
);
assert_eq!(source.spec(), canonical, "canonical spec for {input}");
assert_eq!(source.pinned(), ref_name.is_some(), "{input}");
assert_eq!(parse(&source.spec()), source, "re-parse {canonical}");
}
}
#[test]
fn local_path_specs_parse() {
for spec in ["/abs/path/pkg", "./relative/pkg", "../up/pkg", "~/home/pkg"] {
let source = parse(spec);
assert_eq!(
source,
PackageSource::LocalPath {
path: spec.to_string(),
}
);
assert_eq!(source.spec(), spec);
assert!(!source.pinned());
}
}
#[test]
fn invalid_specs_fail_with_actionable_errors() {
let cases = [
("", "empty"),
("npm:", "missing a package name"),
("npm:@scope", "scoped npm names"),
("npm:pkg@", "version after @ is empty"),
("git:", "missing a repository"),
("git:just-a-word", "shorthand"),
("not-a-spec", "expected npm:"),
("github.com/user/repo", "expected npm:"),
];
for (spec, fragment) in cases {
let err = parse_package_spec(spec).expect_err(spec).to_string();
assert!(
err.contains(fragment),
"error for {spec:?} should mention {fragment:?}, got: {err}"
);
}
}
#[test]
fn identity_is_stable_across_refs_and_versions() {
assert_eq!(
parse("npm:@scope/pkg@1.0.0").identity(),
parse("npm:@scope/pkg").identity()
);
assert_eq!(
parse("git:github.com/User/Repo@v1").identity(),
parse("https://github.com/user/repo").identity()
);
assert_eq!(
parse("https://github.com/user/repo.git").identity(),
parse("https://github.com/user/repo").identity()
);
assert_ne!(
parse("npm:@scope/pkg").identity(),
parse("npm:pkg").identity()
);
}
#[test]
fn glob_match_supports_segments_and_depth() {
assert!(glob_match("skills", "skills/changelog/SKILL.md"));
assert!(glob_match("extensions/*.toml", "extensions/hello.toml"));
assert!(!glob_match(
"extensions/*.toml",
"extensions/nested/hello.toml"
));
assert!(glob_match(
"extensions/**/*.toml",
"extensions/nested/hello.toml"
));
assert!(glob_match("**/SKILL.md", "skills/a/b/SKILL.md"));
assert!(glob_match("**", "anything/at/all"));
assert!(glob_match("themes/*.css", "themes/midnight.css"));
assert!(!glob_match("themes/*.css", "commands/midnight.css"));
assert!(glob_match("commands/re*ew.md", "commands/review.md"));
assert!(!glob_match("commands/re*ew.md", "commands/reviews.md"));
}
#[test]
fn filters_follow_documented_semantics() {
let mut filters = PackageResourceFilters::default();
assert!(filters.allows(PackageResourceKind::Skill, "skills/x/SKILL.md"));
filters.skills = Some(Vec::new());
assert!(!filters.allows(PackageResourceKind::Skill, "skills/x/SKILL.md"));
filters.skills = Some(vec!["+skills/x/SKILL.md".to_string()]);
assert!(filters.allows(PackageResourceKind::Skill, "skills/x/SKILL.md"));
assert!(!filters.allows(PackageResourceKind::Skill, "skills/y/SKILL.md"));
filters.commands = Some(vec![
"commands/*.md".to_string(),
"!commands/legacy.md".to_string(),
]);
assert!(filters.allows(PackageResourceKind::Command, "commands/review.md"));
assert!(!filters.allows(PackageResourceKind::Command, "commands/legacy.md"));
assert!(!filters.allows(PackageResourceKind::Command, "other/review.md"));
filters.themes = Some(vec![
"themes/*.css".to_string(),
"-themes/banned.css".to_string(),
]);
assert!(filters.allows(PackageResourceKind::Theme, "themes/ok.css"));
assert!(!filters.allows(PackageResourceKind::Theme, "themes/banned.css"));
assert!(filters.allows(PackageResourceKind::Extension, "extensions/x.toml"));
}
#[test]
fn resource_ids_round_trip() {
let id = package_resource_id("pr-helper", PackageResourceKind::Skill, "changelog");
assert_eq!(id, "pr-helper:skill/changelog");
let (package, kind, name) = parse_package_resource_id(&id).unwrap();
assert_eq!(package, "pr-helper");
assert_eq!(kind, PackageResourceKind::Skill);
assert_eq!(name, "changelog");
assert!(parse_package_resource_id("missing-separator").is_err());
assert!(parse_package_resource_id("pkg:badkind/x").is_err());
assert!(parse_package_resource_id("pkg:skill/").is_err());
}
#[test]
fn package_id_validation_and_derivation() {
validate_package_id("pr-helper").unwrap();
validate_package_id("a1.b_c-d").unwrap();
for bad in ["", "Pr-Helper", "has space", "-leading", "über"] {
assert!(validate_package_id(bad).is_err(), "{bad:?}");
}
assert_eq!(derive_package_id(&parse("npm:@scope/My-Pkg")), "my-pkg");
assert_eq!(
derive_package_id(&parse("git:github.com/user/Repo.Name")),
"repo.name"
);
assert_eq!(derive_package_id(&parse("/tmp/some_dir/")), "some_dir");
}
#[test]
fn records_and_launch_serde_round_trip() {
let record = roder_api::packages::PackageRecord {
package_id: "pr-helper".to_string(),
identity: parse("npm:@scope/pkg").identity(),
source: parse("npm:@scope/pkg@1.0.0"),
scope: roder_api::packages::PackageScope::User,
install_path: Some("/home/u/.roder/packages/npm/@scope/pkg".to_string()),
resolved: Some("1.0.0".to_string()),
enabled: true,
allow_scripts: false,
extensions_approved: false,
installed_at: time::OffsetDateTime::UNIX_EPOCH,
content_hash: Some("abc".to_string()),
filters: PackageResourceFilters::default(),
disabled_resources: vec![],
};
let json = serde_json::to_string(&record).unwrap();
assert!(json.contains("\"packageId\":\"pr-helper\""), "{json}");
let back: roder_api::packages::PackageRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back, record);
let launch: PackageExtensionLaunch = toml::from_str(
r#"
command = "python3"
args = ["main.py"]
startup_timeout_ms = 5000
[env]
PYTHONUNBUFFERED = "1"
"#,
)
.unwrap();
assert_eq!(launch.command, "python3");
assert_eq!(launch.args, ["main.py"]);
assert_eq!(launch.startup_timeout_ms, Some(5000));
assert_eq!(launch.env.get("PYTHONUNBUFFERED").unwrap(), "1");
}
#[test]
fn manifest_spec_parses_from_toml_shape() {
let spec: roder_api::packages::PackageManifestSpec =
serde_json::from_value(serde_json::json!({
"id": "pr-helper",
"name": "PR Helper",
"version": "0.1.0",
"extensions": ["extensions/hello/roder-extension.toml"],
"skills": ["skills"],
"commands": ["commands"],
"themes": ["themes"],
}))
.unwrap();
assert_eq!(spec.id, "pr-helper");
assert_eq!(spec.extensions.len(), 1);
}