use kitup::{
classify_install_workflow_exit, compute_bundle_content_hash, detect_hosts, directory_bundle,
files_bundle, github_bundle, install_bundled_skill, load_host_spec, parse_install_flags,
plan_bundled_skill, resolve_hosts, resolve_install_selection, resolve_install_targets,
run_bundled_skill_install_with_io, status_bundled_skill, uninstall_bundled_skill,
update_bundled_skill, validate_skill_bundle, with_bundle_metadata, AgentSelector, BaseOptions,
BundledSkillMetadata, GitHubBundleOptions, InstallFlagValues, InstallOptions,
InstallSelectionOptions, InstallWorkflowOptions, ParsedInstallFlags, Scope, SkillBundle,
SkillFile, StatusOptions, UninstallOptions,
};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use std::fs;
use std::io::{Cursor, Read};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Deserialize)]
struct GoldenFile {
cases: Vec<GoldenCase>,
}
#[derive(Clone, Deserialize)]
struct GoldenCase {
id: String,
operation: String,
options: Map<String, Value>,
given: Map<String, Value>,
expected: Map<String, Value>,
}
#[test]
fn golden_cases() {
let file: GoldenFile = read_json(&repo_path("testdata/cases/bundled-skill-install.json"));
for case in file.cases {
let root = temp_dir(&case.id);
let home = root.join("home");
let workspace = root.join("workspace");
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&workspace).unwrap();
setup_given(&case, &home, &workspace);
run_case(&case, &home, &workspace);
let _ = fs::remove_dir_all(root);
}
}
#[cfg(feature = "include-dir")]
#[test]
fn include_dir_bundle_installs_embedded_tree() {
static SKILLS: include_dir::Dir<'_> =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/../testdata/skills");
let root = temp_dir("include-dir-bundle");
let home = root.join("home");
let report = install_bundled_skill(&InstallOptions {
base: BaseOptions {
home: Some(home.clone()),
hosts_file: Some(repo_path("spec/hosts.json")),
..BaseOptions::default()
},
app_id: "example-cli".to_string(),
skill_bundle: kitup::include_dir_bundle(SKILLS.get_dir("basic").unwrap()),
scope: Scope::User,
agents: AgentSelector::Explicit(vec!["codex".to_string()]),
force: false,
})
.unwrap();
let target = home.join(".agents/skills/basic");
assert_eq!(report.installed.len(), 1);
for file in ["SKILL.md", "references/guide.md", "assets/template.json"] {
assert!(target.join(file).is_file());
}
#[cfg(unix)]
assert_eq!(
mode_string(
&fs::metadata(target.join("scripts/helper.sh"))
.unwrap()
.permissions()
),
"755"
);
let _ = fs::remove_dir_all(root);
}
fn run_case(case: &GoldenCase, home: &Path, workspace: &Path) {
let options = expand_value(&Value::Object(case.options.clone()), home, workspace);
let options = options.as_object().unwrap();
match case.operation.as_str() {
"resolve-hosts" => {
let hosts_file = repo_path(case.given["hostsFile"].as_str().unwrap());
let hosts = load_host_spec(Some(&hosts_file)).unwrap();
let (hosts, errors) = resolve_hosts(&agent_selector(&options["agents"]), &hosts);
if let Some(count) = case.expected.get("count") {
assert_eq!(hosts.len(), count.as_u64().unwrap() as usize);
}
if let Some(expected) = case.expected.get("hostIds") {
assert_json_eq(&json!(host_ids(&hosts)), expected.clone());
}
if let Some(expected) = case.expected.get("resolvedHostIds") {
assert_json_eq(&json!(host_ids(&hosts)), expected.clone());
}
if let Some(expected) = case.expected.get("errors") {
assert_json_eq(&json!(errors), expected.clone());
}
}
"validate" => {
let result = validate_skill_bundle(&skill_bundle_from_options(options));
assert_eq!(result.valid, case.expected["valid"].as_bool().unwrap());
assert_eq!(
result.error_code.as_deref(),
case.expected.get("errorCode").and_then(Value::as_str)
);
}
"parse-install-flags" => {
let parsed = parse_install_flags(InstallFlagValues {
scope: options
.get("scope")
.and_then(Value::as_str)
.map(String::from),
scope_set: options
.get("scopeSet")
.and_then(Value::as_bool)
.unwrap_or_else(|| options.get("scope").is_some()),
agents: options
.get("agents")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|item| item.as_str().unwrap().to_string())
.collect()
})
.unwrap_or_default(),
yes: options.get("yes").and_then(Value::as_bool).unwrap_or(false),
dry_run: options
.get("dryRun")
.and_then(Value::as_bool)
.unwrap_or(false),
force: options
.get("force")
.and_then(Value::as_bool)
.unwrap_or(false),
});
assert_json_eq(
&normalized_parsed_flags(&parsed),
case.expected["parsed"].clone(),
);
}
"resolve-install-selection" => {
let selection = resolve_install_selection(&InstallSelectionOptions {
base: BaseOptions {
home: Some(home.to_path_buf()),
cwd: Some(workspace.to_path_buf()),
hosts_file: Some(repo_path("spec/hosts.json")),
},
scope: Some(scope(options["scope"].as_str().unwrap())),
agents: options.get("agents").map(agent_selector),
yes: options.get("yes").and_then(Value::as_bool).unwrap_or(false),
stdin_tty: options
.get("stdinTTY")
.and_then(Value::as_bool)
.unwrap_or(false),
current_agent: options
.get("currentAgent")
.and_then(Value::as_str)
.map(String::from),
})
.unwrap();
assert_selection(
serde_json::to_value(selection).unwrap(),
case.expected["selection"].clone(),
);
}
"resolve-install-targets" => {
let base = case_base_options(options, home, workspace);
let (targets, _, _) = resolve_install_targets(
&base,
&agent_selector(&options["agents"]),
scope(options["scope"].as_str().unwrap()),
options["skillName"].as_str().unwrap(),
)
.unwrap();
let actual = Value::Array(
targets
.into_iter()
.map(|target| {
json!({
"hostIds": target.host_ids,
"skillName": target.skill_name,
"targetDir": target.target_dir,
})
})
.collect(),
);
assert_json_eq(
&actual,
expand_value(&case.expected["targets"], home, workspace),
);
}
"run-install-workflow" => {
let mut input = Cursor::new(
options
.get("input")
.and_then(Value::as_str)
.unwrap_or("")
.as_bytes()
.to_vec(),
);
let mut output = Vec::new();
let workflow = run_bundled_skill_install_with_io(
&InstallWorkflowOptions {
install: InstallOptions {
base: BaseOptions {
home: Some(home.to_path_buf()),
cwd: Some(workspace.to_path_buf()),
hosts_file: Some(repo_path("spec/hosts.json")),
},
app_id: options["appId"].as_str().unwrap().to_string(),
skill_bundle: skill_bundle_from_options(options),
scope: scope(
options
.get("scope")
.and_then(Value::as_str)
.unwrap_or("user"),
),
agents: options
.get("agents")
.map(agent_selector)
.unwrap_or(AgentSelector::Auto),
force: options
.get("force")
.and_then(Value::as_bool)
.unwrap_or(false),
},
yes: options.get("yes").and_then(Value::as_bool).unwrap_or(false),
dry_run: options
.get("dryRun")
.and_then(Value::as_bool)
.unwrap_or(false),
stdin_tty: options
.get("stdinTTY")
.and_then(Value::as_bool)
.unwrap_or(false),
current_agent: options
.get("currentAgent")
.and_then(Value::as_str)
.map(String::from),
default_scope: options
.get("defaultScope")
.and_then(Value::as_str)
.map(scope),
scope_set: options
.get("scopeSet")
.and_then(Value::as_bool)
.unwrap_or_else(|| options.get("scope").is_some()),
prompt_scope: options
.get("promptScope")
.and_then(Value::as_bool)
.unwrap_or(false),
},
&mut input,
&mut output,
)
.unwrap();
let workflow_value = serde_json::to_value(&workflow).unwrap();
assert_workflow(&workflow_value, case.expected.get("workflow"));
if let Some(expected) = case.expected.get("exit") {
assert_json_eq(
&serde_json::to_value(classify_install_workflow_exit(&workflow)).unwrap(),
expected.clone(),
);
}
assert_output(
&String::from_utf8(output.clone()).unwrap(),
case.expected.get("output"),
);
assert_output_contains(
&String::from_utf8(output).unwrap(),
case.expected.get("outputContains"),
);
if let Some(expected) = case.expected.get("report") {
assert_json_eq(
&serde_json::to_value(&workflow.report).unwrap(),
expand_value(expected, home, workspace),
);
}
assert_expected_files(case, home, workspace);
assert_expected_file_modes(case, home, workspace);
assert_expected_metadata(case, home, workspace);
}
_ => {
let base = case_base_options(options, home, workspace);
if let Some(expected) = case.expected.get("detectedHosts") {
let hosts =
detect_hosts(&base, Some(scope(options["scope"].as_str().unwrap()))).unwrap();
assert_json_eq(&json!(host_ids(&hosts)), expected.clone());
}
let result = run_report_case(case, options, base);
if case.expected.get("throws").and_then(Value::as_bool) == Some(true) {
assert!(result.is_err(), "expected operation to throw");
assert_expected_files(case, home, workspace);
return;
}
let report = result.unwrap();
if let Some(expected) = case.expected.get("report") {
assert_json_eq(&report, expand_value(expected, home, workspace));
}
assert_expected_write_counts(case, &report, home, workspace);
assert_expected_files(case, home, workspace);
assert_expected_file_modes(case, home, workspace);
assert_expected_metadata(case, home, workspace);
}
}
}
fn case_base_options(options: &Map<String, Value>, home: &Path, workspace: &Path) -> BaseOptions {
let hosts_file = match options.get("hostsFile").and_then(Value::as_str) {
Some(path) => {
let expanded = expand_value(&Value::String(path.to_string()), home, workspace);
let expanded = expanded.as_str().unwrap();
if Path::new(expanded).is_absolute() {
PathBuf::from(expanded)
} else {
repo_path(expanded)
}
}
None => repo_path("spec/hosts.json"),
};
BaseOptions {
home: Some(home.to_path_buf()),
cwd: Some(workspace.to_path_buf()),
hosts_file: Some(hosts_file),
}
}
fn run_report_case(
case: &GoldenCase,
options: &Map<String, Value>,
base: BaseOptions,
) -> Result<Value, Box<dyn std::error::Error>> {
match case.operation.as_str() {
"status" => Ok(serde_json::to_value(status_bundled_skill(
&StatusOptions {
base,
app_id: options["appId"].as_str().unwrap().to_string(),
skill_name: options["skillName"].as_str().unwrap().to_string(),
scope: scope(options["scope"].as_str().unwrap()),
agents: agent_selector(&options["agents"]),
},
)?)?),
"uninstall" => Ok(serde_json::to_value(uninstall_bundled_skill(
&UninstallOptions {
base,
app_id: options["appId"].as_str().unwrap().to_string(),
skill_name: options["skillName"].as_str().unwrap().to_string(),
scope: scope(options["scope"].as_str().unwrap()),
agents: agent_selector(&options["agents"]),
},
)?)?),
"install" | "update" | "plan" => {
let options = InstallOptions {
base,
app_id: options["appId"].as_str().unwrap().to_string(),
skill_bundle: skill_bundle_from_options(options),
scope: scope(options["scope"].as_str().unwrap()),
agents: agent_selector(&options["agents"]),
force: options
.get("force")
.and_then(Value::as_bool)
.unwrap_or(false),
};
match case.operation.as_str() {
"update" => Ok(serde_json::to_value(update_bundled_skill(&options)?)?),
"plan" => Ok(serde_json::to_value(plan_bundled_skill(&options)?)?),
_ => Ok(serde_json::to_value(install_bundled_skill(&options)?)?),
}
}
other => panic!("unsupported operation: {other}"),
}
}
fn setup_given(case: &GoldenCase, home: &Path, workspace: &Path) {
for dir in case
.given
.get("dirs")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
fs::create_dir_all(expand_string(dir.as_str().unwrap(), home, workspace)).unwrap();
}
if let Some(files) = case.given.get("files").and_then(Value::as_object) {
for (path, value) in files {
write_fixture_file(&expand_string(path, home, workspace), value);
}
}
if let Some(target) = case.given.get("copySkillBundleTo").and_then(Value::as_str) {
let target = expand_string(target, home, workspace);
let _ = fs::remove_dir_all(&target);
copy_dir(&case_skill_bundle_dir(case), &target);
}
if let Some(modes) = case.given.get("fileModes").and_then(Value::as_object) {
for (path, mode) in modes {
set_fixture_mode(
&expand_string(path, home, workspace),
u32::from_str_radix(mode.as_str().unwrap(), 8).unwrap(),
);
}
}
if let Some(metadata) = case.given.get("metadata").and_then(Value::as_object) {
write_metadata_fixture(case, home, workspace, metadata);
}
if let Some(github) = case.given.get("github").and_then(Value::as_object) {
start_github_fixture(github);
}
}
fn assert_expected_files(case: &GoldenCase, home: &Path, workspace: &Path) {
for path in case
.expected
.get("filesPresent")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let path = expand_string(path.as_str().unwrap(), home, workspace);
assert!(path.exists(), "expected file to exist: {}", path.display());
}
for path in case
.expected
.get("filesAbsent")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let path = expand_string(path.as_str().unwrap(), home, workspace);
assert!(
!path.exists(),
"expected file to be absent: {}",
path.display()
);
}
}
fn assert_expected_file_modes(case: &GoldenCase, home: &Path, workspace: &Path) {
let Some(modes) = case.expected.get("fileModes").and_then(Value::as_object) else {
return;
};
for (path, expected) in modes {
let path = expand_string(path, home, workspace);
let mode = fs::metadata(&path).unwrap().permissions();
assert_eq!(mode_string(&mode), expected.as_str().unwrap());
}
}
#[cfg(unix)]
fn mode_string(mode: &fs::Permissions) -> String {
use std::os::unix::fs::PermissionsExt;
format!("{:o}", mode.mode() & 0o777)
}
#[cfg(not(unix))]
fn mode_string(_mode: &fs::Permissions) -> String {
String::new()
}
#[cfg(unix)]
fn set_fixture_mode(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
}
#[cfg(not(unix))]
fn set_fixture_mode(_path: &Path, _mode: u32) {}
fn assert_expected_metadata(case: &GoldenCase, home: &Path, workspace: &Path) {
let Some(metadata) = case.expected.get("metadata").and_then(Value::as_object) else {
return;
};
let path = expand_string(metadata["path"].as_str().unwrap(), home, workspace);
let actual: Value = read_json(&path);
for (key, value) in metadata["fields"].as_object().unwrap() {
assert_json_eq(&actual[key], value.clone());
}
let hash = expected_bundle_hash(case, metadata["hash"].as_str().unwrap());
assert_eq!(actual["hash"], hash);
}
fn assert_selection(mut actual: Value, mut expected: Value) {
if let Some(selected_count) = expected.get("selectedCount").and_then(Value::as_u64) {
assert_eq!(
actual["selectedHostIds"].as_array().unwrap().len(),
selected_count as usize
);
actual.as_object_mut().unwrap().remove("selectedHostIds");
expected.as_object_mut().unwrap().remove("selectedCount");
}
if let Some(candidate_count) = expected.get("candidateCount").and_then(Value::as_u64) {
assert_eq!(
actual["candidateHostIds"].as_array().unwrap().len(),
candidate_count as usize
);
actual.as_object_mut().unwrap().remove("candidateHostIds");
expected.as_object_mut().unwrap().remove("candidateCount");
}
assert_json_eq(&actual, expected);
}
fn normalized_parsed_flags(parsed: &ParsedInstallFlags) -> Value {
let (agent_kind, agent_ids) = match &parsed.agents {
AgentSelector::Auto => ("auto", vec![]),
AgentSelector::All => ("*", vec![]),
AgentSelector::Explicit(ids) => ("explicit", ids.clone()),
};
json!({
"scope": match parsed.scope {
Scope::User => "user",
Scope::Project => "project",
},
"scopeSet": parsed.scope_set,
"agentKind": agent_kind,
"agentIds": agent_ids,
"yes": parsed.yes,
"dryRun": parsed.dry_run,
"force": parsed.force,
"errors": parsed.errors
})
}
fn assert_workflow(actual: &Value, expected: Option<&Value>) {
let Some(expected) = expected.and_then(Value::as_object) else {
return;
};
for (key, value) in expected {
assert_json_eq(&actual[key], value.clone());
}
}
fn assert_output_contains(actual: &str, expected: Option<&Value>) {
let Some(expected) = expected.and_then(Value::as_array) else {
return;
};
for value in expected {
let text = value.as_str().unwrap();
assert!(
actual.contains(text),
"expected output to contain {text}, got:\n{actual}"
);
}
}
fn assert_output(actual: &str, expected: Option<&Value>) {
let Some(expected) = expected.and_then(Value::as_str) else {
return;
};
assert_eq!(actual, expected);
}
fn assert_expected_write_counts(case: &GoldenCase, report: &Value, home: &Path, workspace: &Path) {
let Some(expected) = case.expected.get("writeCountByTargetDir") else {
return;
};
let mut actual = Map::new();
for key in ["installed", "updated"] {
for item in report[key].as_array().unwrap() {
let target_dir = item["targetDir"].as_str().unwrap();
let count = actual.get(target_dir).and_then(Value::as_u64).unwrap_or(0) + 1;
actual.insert(target_dir.to_string(), json!(count));
}
}
assert_json_eq(
&Value::Object(actual),
expand_value(expected, home, workspace),
);
}
fn write_metadata_fixture(
case: &GoldenCase,
home: &Path,
workspace: &Path,
metadata: &Map<String, Value>,
) {
let mut fields = metadata["fields"].as_object().unwrap().clone();
let hash = expected_bundle_hash(case, metadata["hash"].as_str().unwrap());
fields.insert("hash".to_string(), json!(hash));
write_fixture_file(
&expand_string(metadata["path"].as_str().unwrap(), home, workspace),
&Value::Object(fields),
);
}
fn expected_bundle_hash(case: &GoldenCase, marker: &str) -> String {
match marker {
"from-skill-bundle-dir" => {
compute_bundle_content_hash(&directory_bundle(case_skill_bundle_dir(case))).unwrap()
}
"from-skill-files" => compute_bundle_content_hash(&files_bundle(skill_files(
case.options["skillFiles"].as_array().unwrap(),
)))
.unwrap(),
"from-github-bundle" => {
compute_bundle_content_hash(&files_bundle(github_skill_files(case))).unwrap()
}
_ => marker.to_string(),
}
}
fn write_fixture_file(path: &Path, value: &Value) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
if let Some(text) = value.as_str() {
fs::write(path, text).unwrap();
} else {
fs::write(
path,
format!("{}\n", serde_json::to_string_pretty(value).unwrap()),
)
.unwrap();
}
}
fn copy_dir(src: &Path, dest: &Path) {
fs::create_dir_all(dest).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let to = dest.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
copy_dir(&entry.path(), &to);
} else {
fs::copy(entry.path(), to).unwrap();
}
}
}
fn agent_selector(value: &Value) -> AgentSelector {
if let Some(text) = value.as_str() {
if text == "*" {
AgentSelector::All
} else {
AgentSelector::Auto
}
} else {
AgentSelector::Explicit(
value
.as_array()
.unwrap()
.iter()
.map(|item| item.as_str().unwrap().to_string())
.collect(),
)
}
}
fn skill_bundle_from_options(options: &Map<String, Value>) -> SkillBundle {
let bundle = if let Some(files) = options.get("skillFiles").and_then(Value::as_array) {
files_bundle(skill_files(files))
} else if let Some(dir) = options.get("skillBundleDir").and_then(Value::as_str) {
directory_bundle(repo_path(dir))
} else if let Some(bundle) = options.get("githubBundle").and_then(Value::as_object) {
github_bundle(GitHubBundleOptions {
owner: bundle["owner"].as_str().unwrap().to_string(),
repo: bundle["repo"].as_str().unwrap().to_string(),
path: bundle["path"].as_str().unwrap().to_string(),
ref_name: bundle["ref"].as_str().unwrap().to_string(),
})
} else {
files_bundle(Vec::new())
};
let Some(metadata) = options.get("bundleMetadata").and_then(Value::as_object) else {
return bundle;
};
with_bundle_metadata(
bundle,
BundledSkillMetadata {
source_id: metadata
.get("sourceId")
.and_then(Value::as_str)
.map(String::from),
cli_version: metadata
.get("cliVersion")
.and_then(Value::as_str)
.map(String::from),
cli_revision: metadata
.get("cliRevision")
.and_then(Value::as_str)
.map(String::from),
provenance: metadata
.get("provenance")
.and_then(Value::as_object)
.into_iter()
.flatten()
.map(|(key, value)| (key.clone(), value.as_str().unwrap().to_string()))
.collect(),
},
)
}
fn skill_files(values: &[Value]) -> Vec<SkillFile> {
values
.iter()
.map(|value| {
let value = value.as_object().unwrap();
SkillFile {
path: value["path"].as_str().unwrap().to_string(),
contents: value["contents"].as_str().unwrap().as_bytes().to_vec(),
mode: value
.get("mode")
.and_then(Value::as_u64)
.map(|mode| mode as u32),
}
})
.collect()
}
fn github_skill_files(case: &GoldenCase) -> Vec<SkillFile> {
let bundle = case.options["githubBundle"].as_object().unwrap();
let root = format!("{}/", bundle["path"].as_str().unwrap().trim_matches('/'));
let files = case.given["github"]["files"].as_object().unwrap();
files
.iter()
.filter_map(|(path, contents)| {
path.strip_prefix(&root).map(|relative| SkillFile {
path: relative.to_string(),
contents: contents.as_str().unwrap().as_bytes().to_vec(),
mode: None,
})
})
.collect()
}
fn start_github_fixture(github: &Map<String, Value>) {
let owner = github["owner"].as_str().unwrap().to_string();
let repo = github["repo"].as_str().unwrap().to_string();
let ref_name = github["ref"].as_str().unwrap().to_string();
let commit = github["commit"].as_str().unwrap().to_string();
let tree_sha = github["treeSha"].as_str().unwrap().to_string();
let files = github["files"].as_object().unwrap().clone();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let base = format!("http://{}", addr);
std::env::set_var("KITUP_GITHUB_API_BASE_URL", &base);
std::env::set_var("KITUP_GITHUB_RAW_BASE_URL", &base);
thread::spawn(move || {
for stream in listener.incoming().flatten() {
handle_github_fixture_connection(
stream, &owner, &repo, &ref_name, &commit, &tree_sha, &files,
);
}
});
}
fn handle_github_fixture_connection(
mut stream: TcpStream,
owner: &str,
repo: &str,
ref_name: &str,
commit: &str,
tree_sha: &str,
files: &Map<String, Value>,
) {
let mut buffer = [0; 4096];
let Ok(size) = stream.read(&mut buffer) else {
return;
};
let request = String::from_utf8_lossy(&buffer[..size]);
let path = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("/");
let path = path.split('?').next().unwrap_or(path);
let commit_path = format!("/repos/{owner}/{repo}/commits/{ref_name}");
let tree_path = format!("/repos/{owner}/{repo}/git/trees/{tree_sha}");
if path == commit_path {
write_http_json(
&mut stream,
json!({ "sha": commit, "commit": { "tree": { "sha": tree_sha } } }),
);
return;
}
if path == tree_path {
let tree: Vec<_> = files
.keys()
.map(|path| {
json!({
"path": path,
"type": "blob",
"mode": if path.ends_with(".sh") { "100755" } else { "100644" }
})
})
.collect();
write_http_json(&mut stream, json!({ "tree": tree }));
return;
}
let raw_prefix = format!("/{owner}/{repo}/{commit}/");
if let Some(file) = path.strip_prefix(&raw_prefix) {
if let Some(contents) = files.get(file).and_then(Value::as_str) {
write_http(&mut stream, "200 OK", "application/octet-stream", contents);
return;
}
}
write_http(&mut stream, "404 Not Found", "text/plain", "not found");
}
fn write_http_json(stream: &mut TcpStream, value: Value) {
write_http(stream, "200 OK", "application/json", &value.to_string());
}
fn write_http(stream: &mut TcpStream, status: &str, content_type: &str, body: &str) {
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = std::io::Write::write_all(stream, response.as_bytes());
}
fn scope(value: &str) -> Scope {
match value {
"user" => Scope::User,
"project" => Scope::Project,
other => panic!("bad scope: {other}"),
}
}
fn case_skill_bundle_dir(case: &GoldenCase) -> PathBuf {
if let Some(skill_bundle_dir) = case.options.get("skillBundleDir").and_then(Value::as_str) {
repo_path(skill_bundle_dir)
} else {
repo_path(&format!(
"testdata/skills/{}",
case.options["skillName"].as_str().unwrap()
))
}
}
fn repo_path(path: &str) -> PathBuf {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
PathBuf::from("..").join(path)
}
}
fn host_ids(hosts: &[kitup::Host]) -> Vec<String> {
hosts.iter().map(|host| host.id.clone()).collect()
}
fn expand_value(value: &Value, home: &Path, workspace: &Path) -> Value {
match value {
Value::String(text) => json!(expand_string(text, home, workspace)),
Value::Array(items) => Value::Array(
items
.iter()
.map(|item| expand_value(item, home, workspace))
.collect(),
),
Value::Object(object) => Value::Object(
object
.iter()
.map(|(key, value)| {
(
expand_string(key, home, workspace)
.to_string_lossy()
.to_string(),
expand_value(value, home, workspace),
)
})
.collect(),
),
_ => value.clone(),
}
}
fn expand_string(value: &str, home: &Path, workspace: &Path) -> PathBuf {
PathBuf::from(
value
.replace("$HOME", &home.to_string_lossy())
.replace("$WORKSPACE", &workspace.to_string_lossy()),
)
}
fn assert_json_eq(left: &Value, right: Value) {
assert_eq!(
left,
&right,
"got:\n{}\nwant:\n{}",
serde_json::to_string_pretty(left).unwrap(),
serde_json::to_string_pretty(&right).unwrap()
);
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> T {
serde_json::from_slice(&fs::read(path).unwrap()).unwrap()
}
fn temp_dir(name: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("kitup-{name}-{suffix}"));
fs::create_dir_all(&path).unwrap();
path
}