feat: complete native execution and Steam discovery
This commit is contained in:
@@ -12,6 +12,9 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { version = "=0.0.1", path = "../kiln-core" }
|
||||
kiln-adapter-native = { version = "=0.0.1", path = "../kiln-adapter-native" }
|
||||
kiln-adapter-steam = { version = "=0.0.1", path = "../kiln-adapter-steam" }
|
||||
serde_json = "=1.0.150"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+447
-17
@@ -1,36 +1,466 @@
|
||||
//! Private smoke CLI for the Project Kiln workspace.
|
||||
//! Project Kiln command-line entry point.
|
||||
|
||||
use std::env;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use kiln_core::CONTRACT_SCHEMA_VERSION;
|
||||
use kiln_adapter_native::{NativeAdapter, current_log_path, execute};
|
||||
use kiln_adapter_steam::SteamAdapter;
|
||||
use kiln_core::adapter::ProviderAdapter;
|
||||
use kiln_core::config::{
|
||||
COMMENTED_DEFAULT_CONFIG, CONFIG_SCHEMA_VERSION, ConfigurationSource, ConfigurationTier,
|
||||
EditableScalar, built_in_configuration, configuration_path_for_current_user,
|
||||
load_configuration, resolve_layers, set_configuration_scalar,
|
||||
};
|
||||
use kiln_core::contract_error::{
|
||||
CONTRACT_ERROR_SCHEMA_VERSION, ContractErrorCode, ContractErrorV1,
|
||||
};
|
||||
use kiln_core::launch::LAUNCH_PLAN_SCHEMA_VERSION;
|
||||
use kiln_core::registry::INSTALLATION_REGISTRY_SCHEMA_VERSION;
|
||||
use kiln_core::{CONTRACT_SCHEMA_VERSION, ContractName, ContractText, GameRecordV1};
|
||||
use serde_json::json;
|
||||
|
||||
const HELP: &str = "Project Kiln private scaffold\n\nUSAGE:\n kiln <COMMAND>\n\nCOMMANDS:\n version Print the private build version\n doctor [--json] Check the scaffold contract\n help Print this help\n";
|
||||
const HELP: &str = "Project Kiln private development CLI
|
||||
|
||||
Usage:
|
||||
kiln doctor [--json]
|
||||
kiln list [--provider native|steam] [--json]
|
||||
kiln search <query> [--provider native|steam] [--json]
|
||||
kiln info <id-or-name> [--provider native|steam] [--json]
|
||||
kiln scan [--provider native|steam] [--no-cache] [--json]
|
||||
kiln launch <id-or-name> [--dry-run] [--json]
|
||||
kiln config defaults
|
||||
kiln config validate [--json]
|
||||
kiln config explain [--json]
|
||||
kiln config set runtime|session-backend <value> [--json]
|
||||
kiln --help
|
||||
kiln --version";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut arguments = env::args().skip(1);
|
||||
match (arguments.next().as_deref(), arguments.next().as_deref()) {
|
||||
(None | Some("help" | "--help" | "-h"), None) => {
|
||||
print!("{HELP}");
|
||||
run(env::args().skip(1).collect())
|
||||
}
|
||||
|
||||
fn run(mut arguments: Vec<String>) -> ExitCode {
|
||||
let json_output = take_flag(&mut arguments, "--json");
|
||||
if arguments.first().is_some_and(|argument| argument == "scan") {
|
||||
let _ = take_flag(&mut arguments, "--no-cache");
|
||||
}
|
||||
if let Some(result) = explicit_provider_command(&arguments, json_output) {
|
||||
return result;
|
||||
}
|
||||
match arguments.as_slice() {
|
||||
[flag] if flag == "--help" || flag == "-h" => {
|
||||
println!("{HELP}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
(Some("version" | "--version" | "-V"), None) => {
|
||||
println!("kiln {} (private placeholder)", env!("CARGO_PKG_VERSION"));
|
||||
[flag] if flag == "--version" || flag == "-V" => {
|
||||
println!("kiln {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
(Some("doctor"), None) => {
|
||||
println!("ok: core contract schema {CONTRACT_SCHEMA_VERSION}");
|
||||
ExitCode::SUCCESS
|
||||
[command] if command == "doctor" => doctor(json_output),
|
||||
[command] if command == "list" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.discover()?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
[command, query] if command == "search" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.search(query)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
[command, selector] if command == "info" && selector.starts_with("steam:") => {
|
||||
with_steam(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
(Some("doctor"), Some("--json")) => {
|
||||
println!(
|
||||
"{{\"schema_version\":{CONTRACT_SCHEMA_VERSION},\"status\":\"ok\",\"private_placeholder\":true}}"
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
[command, selector] if command == "info" => with_adapter(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
[command] if command == "scan" => scan_native(json_output),
|
||||
_ if arguments.first().is_some_and(|value| value == "launch") => {
|
||||
launch(arguments, json_output)
|
||||
}
|
||||
_ if arguments.first().is_some_and(|value| value == "config") => {
|
||||
config(&arguments, json_output)
|
||||
}
|
||||
[command, ..] if command == "frontend" && json_output => {
|
||||
json_error(&ContractErrorV1::unsupported_capability(
|
||||
text("frontend capabilities are post-version-1"),
|
||||
name("frontend"),
|
||||
))
|
||||
}
|
||||
_ if json_output => json_error(&ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("unsupported arguments"),
|
||||
)),
|
||||
_ => {
|
||||
eprintln!("error: unsupported arguments\n\n{HELP}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_provider_command(arguments: &[String], json_output: bool) -> Option<ExitCode> {
|
||||
match arguments {
|
||||
[command, provider, value] if command == "list" && provider == "--provider" => {
|
||||
Some(list_provider(value, json_output))
|
||||
}
|
||||
[command, query, provider, value] if command == "search" && provider == "--provider" => {
|
||||
Some(search_provider(value, query, json_output))
|
||||
}
|
||||
[command, selector, provider, value] if command == "info" && provider == "--provider" => {
|
||||
Some(info_provider(value, selector, json_output))
|
||||
}
|
||||
[command, provider, value] if command == "scan" && provider == "--provider" => {
|
||||
Some(match value.as_str() {
|
||||
"native" => scan_native(json_output),
|
||||
"steam" => scan_steam(json_output),
|
||||
_ => invalid_arguments(json_output),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_provider(provider: &str, json_output: bool) -> ExitCode {
|
||||
match provider {
|
||||
"native" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.discover()?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
"steam" => with_steam(json_output, |adapter| {
|
||||
output_records(adapter.discover()?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
_ => invalid_arguments(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn search_provider(provider: &str, query: &str, json_output: bool) -> ExitCode {
|
||||
match provider {
|
||||
"native" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.search(query)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
"steam" => with_steam(json_output, |adapter| {
|
||||
output_records(adapter.search(query)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
_ => invalid_arguments(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn info_provider(provider: &str, selector: &str, json_output: bool) -> ExitCode {
|
||||
match provider {
|
||||
"native" => with_adapter(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
"steam" => with_steam(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
_ => invalid_arguments(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn config(arguments: &[String], json_output: bool) -> ExitCode {
|
||||
let result = match arguments {
|
||||
[command, operation] if command == "config" && operation == "defaults" => {
|
||||
print!("{COMMENTED_DEFAULT_CONFIG}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
[command, operation] if command == "config" && operation == "validate" => {
|
||||
validate_config(json_output)
|
||||
}
|
||||
[command, operation] if command == "config" && operation == "explain" => {
|
||||
explain_config(json_output)
|
||||
}
|
||||
[command, operation, field, value] if command == "config" && operation == "set" => {
|
||||
set_config(field, value, json_output)
|
||||
}
|
||||
_ => return invalid_arguments(json_output),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) if json_output => json_error(&error),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config(json_output: bool) -> Result<(), ContractErrorV1> {
|
||||
let path = configuration_path_for_current_user().map_err(configuration_error)?;
|
||||
let layer = load_configuration(
|
||||
&path,
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: text("global user configuration"),
|
||||
},
|
||||
)
|
||||
.map_err(configuration_error)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"status": "ok", "path": path, "exists": layer.is_some()})
|
||||
);
|
||||
} else if layer.is_some() {
|
||||
println!("ok: {}", path.display());
|
||||
} else {
|
||||
println!("ok: defaults in use; {} does not exist", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn explain_config(json_output: bool) -> Result<(), ContractErrorV1> {
|
||||
let path = configuration_path_for_current_user().map_err(configuration_error)?;
|
||||
let mut layers = vec![built_in_configuration()];
|
||||
if let Some(layer) = load_configuration(
|
||||
&path,
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: text("global user configuration"),
|
||||
},
|
||||
)
|
||||
.map_err(configuration_error)?
|
||||
{
|
||||
layers.push(layer);
|
||||
}
|
||||
let resolved = resolve_layers(&layers).map_err(configuration_error)?;
|
||||
let output = if json_output {
|
||||
serde_json::to_string(&resolved)
|
||||
} else {
|
||||
serde_json::to_string_pretty(&resolved)
|
||||
}
|
||||
.expect("configuration explanation serializes");
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_config(field: &str, raw: &str, json_output: bool) -> Result<(), ContractErrorV1> {
|
||||
let scalar = match field {
|
||||
"runtime" => EditableScalar::Runtime,
|
||||
"session-backend" => EditableScalar::SessionBackend,
|
||||
_ => {
|
||||
return Err(ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("config set supports runtime or session-backend"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let value: ContractName = raw.parse().map_err(|_| {
|
||||
ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("configuration value must be a valid contract name"),
|
||||
)
|
||||
})?;
|
||||
let path = configuration_path_for_current_user().map_err(configuration_error)?;
|
||||
set_configuration_scalar(&path, scalar, &value).map_err(configuration_error)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"status": "updated", "path": path, "field": field, "value": value})
|
||||
);
|
||||
} else {
|
||||
println!("updated {field} in {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configuration_error(error: impl std::fmt::Display) -> ContractErrorV1 {
|
||||
let message = format!("configuration error: {error}");
|
||||
ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
message
|
||||
.parse()
|
||||
.unwrap_or_else(|_| text("configuration operation failed")),
|
||||
)
|
||||
}
|
||||
|
||||
fn doctor(json_output: bool) -> ExitCode {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": "ok",
|
||||
"contracts": {
|
||||
"game_record": CONTRACT_SCHEMA_VERSION,
|
||||
"installation_registry": INSTALLATION_REGISTRY_SCHEMA_VERSION,
|
||||
"launch_plan": LAUNCH_PLAN_SCHEMA_VERSION,
|
||||
"error": CONTRACT_ERROR_SCHEMA_VERSION,
|
||||
"configuration": CONFIG_SCHEMA_VERSION
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
println!("ok: core contract schema {CONTRACT_SCHEMA_VERSION}");
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn scan_native(json_output: bool) -> ExitCode {
|
||||
with_adapter(json_output, |adapter| {
|
||||
let records = adapter.discover()?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"provider": "native", "discovered": records.len()})
|
||||
);
|
||||
} else {
|
||||
println!("native: {} installed game(s)", records.len());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_steam(json_output: bool) -> ExitCode {
|
||||
with_steam(json_output, |adapter| {
|
||||
let records = adapter.discover()?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"provider": "steam", "discovered": records.len()})
|
||||
);
|
||||
} else {
|
||||
println!("steam: {} installed game(s)", records.len());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
|
||||
let dry_run = take_flag(&mut arguments, "--dry-run");
|
||||
let _ = take_flag(&mut arguments, "--no-cache");
|
||||
let [command, selector] = arguments.as_slice() else {
|
||||
return invalid_arguments(json_output);
|
||||
};
|
||||
if command != "launch" {
|
||||
return invalid_arguments(json_output);
|
||||
}
|
||||
with_adapter(json_output, |adapter| {
|
||||
let record = adapter.resolve(selector)?;
|
||||
let plan = adapter.plan_launch(record.id())?;
|
||||
if dry_run {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&plan).expect("launch plan serializes")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&plan).expect("launch plan serializes")
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let log = current_log_path()?;
|
||||
let outcome = execute(&plan, &log)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"game_id": record.id(), "status": "exited", "exit_code": outcome.exit_code, "log": log})
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} exited successfully; log: {}",
|
||||
record.id(),
|
||||
log.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn with_adapter(
|
||||
json_output: bool,
|
||||
operation: impl FnOnce(&NativeAdapter) -> Result<(), ContractErrorV1>,
|
||||
) -> ExitCode {
|
||||
match NativeAdapter::for_current_user().and_then(|adapter| operation(&adapter)) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) if json_output => json_error(&error),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_steam(
|
||||
json_output: bool,
|
||||
operation: impl FnOnce(&SteamAdapter) -> Result<(), ContractErrorV1>,
|
||||
) -> ExitCode {
|
||||
match SteamAdapter::for_current_user().and_then(|adapter| operation(&adapter)) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) if json_output => json_error(&error),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_records(records: Vec<GameRecordV1>, json_output: bool) {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&records).expect("records serialize")
|
||||
);
|
||||
} else {
|
||||
for record in records {
|
||||
println!("{}\t{}", record.id(), record.name().as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_record(record: &GameRecordV1, json_output: bool) {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(record).expect("record serializes")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}\t{}\t{}",
|
||||
record.id(),
|
||||
record.name().as_str(),
|
||||
record.install_path().as_path().display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_arguments(json_output: bool) -> ExitCode {
|
||||
let error = ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("unsupported arguments"),
|
||||
);
|
||||
if json_output {
|
||||
json_error(&error)
|
||||
} else {
|
||||
eprintln!("error: unsupported arguments\n\n{HELP}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
|
||||
fn take_flag(arguments: &mut Vec<String>, flag: &str) -> bool {
|
||||
let found = arguments.iter().any(|argument| argument == flag);
|
||||
arguments.retain(|argument| argument != flag);
|
||||
found
|
||||
}
|
||||
|
||||
fn json_error(error: &ContractErrorV1) -> ExitCode {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string(error).expect("contract error serializes")
|
||||
);
|
||||
ExitCode::from(2)
|
||||
}
|
||||
|
||||
fn name(raw: &str) -> ContractName {
|
||||
raw.parse().expect("static contract name")
|
||||
}
|
||||
|
||||
fn text(raw: &str) -> ContractText {
|
||||
raw.parse().expect("static contract text")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Isolated CLI contract tests for Phase 1 machine output.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn kiln(arguments: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_kiln"))
|
||||
.args(arguments)
|
||||
.output()
|
||||
.expect("kiln runs")
|
||||
}
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"kiln-cli-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::thread::current().name().unwrap_or("unnamed")
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir(&path).expect("test directory");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn run(&self, arguments: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_kiln"))
|
||||
.args(arguments)
|
||||
.env("XDG_CONFIG_HOME", self.0.join("config"))
|
||||
.env("XDG_STATE_HOME", self.0.join("state"))
|
||||
.env("HOME", self.0.join("home"))
|
||||
.output()
|
||||
.expect("kiln runs")
|
||||
}
|
||||
|
||||
fn add_game(&self, id: &str, name: &str, executable: &Path) {
|
||||
let manifests = self.0.join("config/kiln/native");
|
||||
let install = self.0.join("games").join(id);
|
||||
fs::create_dir_all(&manifests).expect("manifest directory");
|
||||
fs::create_dir_all(&install).expect("install directory");
|
||||
fs::write(
|
||||
manifests.join(format!("{id}.toml")),
|
||||
format!(
|
||||
"schema_version = 1\nid = \"{id}\"\nname = \"{name}\"\ninstall_path = \"{}\"\nexecutable = \"{}\"\narguments = [\"native-output\"]\n",
|
||||
install.display(),
|
||||
executable.display()
|
||||
),
|
||||
)
|
||||
.expect("manifest");
|
||||
}
|
||||
|
||||
fn add_steam_game(&self, app_id: &str, name: &str) {
|
||||
let root = self.0.join("home/.local/share/Steam");
|
||||
let steamapps = root.join("steamapps");
|
||||
let install = steamapps.join("common").join(name);
|
||||
fs::create_dir_all(&install).unwrap();
|
||||
fs::write(
|
||||
steamapps.join("libraryfolders.vdf"),
|
||||
format!(
|
||||
"\"libraryfolders\" {{ \"0\" {{ \"path\" \"{}\" }} }}",
|
||||
root.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
steamapps.join(format!("appmanifest_{app_id}.acf")),
|
||||
format!(
|
||||
"\"AppState\" {{ \"appid\" \"{app_id}\" \"name\" \"{name}\" \"StateFlags\" \"4\" \"installdir\" \"{name}\" }}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_every_phase_one_schema() {
|
||||
let output = kiln(&["doctor", "--json"]);
|
||||
assert!(output.status.success());
|
||||
let json: Value = serde_json::from_slice(&output.stdout).expect("doctor JSON");
|
||||
assert_eq!(json["status"], "ok");
|
||||
for contract in [
|
||||
"game_record",
|
||||
"installation_registry",
|
||||
"launch_plan",
|
||||
"error",
|
||||
"configuration",
|
||||
] {
|
||||
let expected = if contract == "launch_plan" { 2 } else { 1 };
|
||||
assert_eq!(json["contracts"][contract], expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_invalid_arguments_return_structured_error() {
|
||||
let output = kiln(&["unknown", "--json"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let json: Value = serde_json::from_slice(&output.stderr).expect("error JSON");
|
||||
assert_eq!(json["schema_version"], 1);
|
||||
assert_eq!(json["code"], "invalid-input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_frontend_returns_unsupported_capability() {
|
||||
let output = kiln(&["frontend", "sync", "--json"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let json: Value = serde_json::from_slice(&output.stderr).expect("error JSON");
|
||||
assert_eq!(json["code"], "unsupported-capability");
|
||||
assert_eq!(json["capability"], "frontend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cli_completes_discovery_search_plan_execution_and_logging() {
|
||||
let directory = TestDirectory::new();
|
||||
directory.add_game("sample", "Sample Game", Path::new("/usr/bin/printf"));
|
||||
|
||||
let list = directory.run(&["list", "--json"]);
|
||||
assert!(list.status.success());
|
||||
let records: Value = serde_json::from_slice(&list.stdout).expect("list JSON");
|
||||
assert_eq!(records[0]["id"], "native:sample");
|
||||
|
||||
let search = directory.run(&["search", "sample", "--json"]);
|
||||
assert!(search.status.success());
|
||||
let records: Value = serde_json::from_slice(&search.stdout).expect("search JSON");
|
||||
assert_eq!(records.as_array().unwrap().len(), 1);
|
||||
|
||||
let info = directory.run(&["info", "native:sample", "--json"]);
|
||||
assert!(info.status.success());
|
||||
let record: Value = serde_json::from_slice(&info.stdout).expect("info JSON");
|
||||
assert_eq!(record["runtime"], "native");
|
||||
|
||||
let scan = directory.run(&["scan", "--provider", "native", "--no-cache", "--json"]);
|
||||
assert!(scan.status.success());
|
||||
let result: Value = serde_json::from_slice(&scan.stdout).expect("scan JSON");
|
||||
assert_eq!(result["discovered"], 1);
|
||||
|
||||
let dry_run = directory.run(&["launch", "Sample Game", "--dry-run", "--json"]);
|
||||
assert!(dry_run.status.success());
|
||||
let plan: Value = serde_json::from_slice(&dry_run.stdout).expect("plan JSON");
|
||||
assert_eq!(plan["command"]["arguments"][0], "native-output");
|
||||
|
||||
let launch = directory.run(&["launch", "native:sample", "--json"]);
|
||||
assert!(launch.status.success());
|
||||
let outcome: Value = serde_json::from_slice(&launch.stdout).expect("outcome JSON");
|
||||
assert_eq!(outcome["exit_code"], 0);
|
||||
assert!(
|
||||
fs::read_to_string(directory.0.join("state/kiln/logs/native.log"))
|
||||
.expect("launch log")
|
||||
.contains("native-output")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_native_manifest_returns_structured_failure() {
|
||||
let directory = TestDirectory::new();
|
||||
let manifests = directory.0.join("config/kiln/native");
|
||||
fs::create_dir_all(&manifests).unwrap();
|
||||
fs::write(manifests.join("bad.toml"), "schema_version = 99\n").unwrap();
|
||||
let output = directory.run(&["list", "--json"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&output.stderr).expect("error JSON");
|
||||
assert!(matches!(
|
||||
error["code"].as_str(),
|
||||
Some("invalid-input" | "unsupported-version")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_validate_set_and_explain_are_consistent() {
|
||||
let directory = TestDirectory::new();
|
||||
let defaults = directory.run(&["config", "defaults"]);
|
||||
assert!(defaults.status.success());
|
||||
assert!(
|
||||
String::from_utf8(defaults.stdout)
|
||||
.unwrap()
|
||||
.contains("schema_version = 1")
|
||||
);
|
||||
|
||||
let validate = directory.run(&["config", "validate", "--json"]);
|
||||
assert!(validate.status.success());
|
||||
let validated: Value = serde_json::from_slice(&validate.stdout).unwrap();
|
||||
assert_eq!(validated["exists"], false);
|
||||
|
||||
let runtime = directory.run(&["config", "set", "runtime", "umu", "--json"]);
|
||||
assert!(runtime.status.success());
|
||||
let backend = directory.run(&["config", "set", "session-backend", "wayland", "--json"]);
|
||||
assert!(backend.status.success());
|
||||
let configuration = fs::read_to_string(directory.0.join("config/kiln/config.toml")).unwrap();
|
||||
assert!(configuration.contains("# Project Kiln private configuration"));
|
||||
assert!(configuration.contains("runtime = \"umu\""));
|
||||
assert!(configuration.contains("session_backend = \"wayland\""));
|
||||
|
||||
let explain = directory.run(&["config", "explain", "--json"]);
|
||||
assert!(explain.status.success());
|
||||
let resolved: Value = serde_json::from_slice(&explain.stdout).unwrap();
|
||||
assert_eq!(resolved["runtime"]["value"], "umu");
|
||||
assert_eq!(resolved["runtime"]["source"]["tier"], "global-user");
|
||||
assert_eq!(resolved["session_backend"]["value"], "wayland");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_configuration_and_failed_launch_are_structured() {
|
||||
let directory = TestDirectory::new();
|
||||
fs::create_dir_all(directory.0.join("config/kiln")).unwrap();
|
||||
fs::write(
|
||||
directory.0.join("config/kiln/config.toml"),
|
||||
"schema_version = 99\n",
|
||||
)
|
||||
.unwrap();
|
||||
let invalid = directory.run(&["config", "validate", "--json"]);
|
||||
assert_eq!(invalid.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&invalid.stderr).unwrap();
|
||||
assert_eq!(error["code"], "invalid-input");
|
||||
|
||||
fs::remove_file(directory.0.join("config/kiln/config.toml")).unwrap();
|
||||
directory.add_game("failure", "Failure", Path::new("/usr/bin/false"));
|
||||
let failed = directory.run(&["launch", "native:failure", "--json"]);
|
||||
assert_eq!(failed.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&failed.stderr).unwrap();
|
||||
assert_eq!(error["code"], "launch-failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_provider_cli_discovers_searches_and_resolves_without_launching() {
|
||||
let directory = TestDirectory::new();
|
||||
directory.add_steam_game("620", "Portal 2");
|
||||
|
||||
let list = directory.run(&["list", "--provider", "steam", "--json"]);
|
||||
assert!(list.status.success());
|
||||
let records: Value = serde_json::from_slice(&list.stdout).unwrap();
|
||||
assert_eq!(records[0]["id"], "steam:620");
|
||||
assert_eq!(records[0]["runtime"], "steam-managed");
|
||||
assert_eq!(records[0]["client_required"], true);
|
||||
|
||||
let search = directory.run(&["search", "portal", "--provider", "steam", "--json"]);
|
||||
assert!(search.status.success());
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&search.stdout).unwrap()[0]["name"],
|
||||
"Portal 2"
|
||||
);
|
||||
|
||||
let info = directory.run(&["info", "steam:620", "--json"]);
|
||||
assert!(info.status.success());
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&info.stdout).unwrap()["store"],
|
||||
"steam"
|
||||
);
|
||||
|
||||
let scan = directory.run(&["scan", "--provider", "steam", "--json"]);
|
||||
assert!(scan.status.success());
|
||||
let result: Value = serde_json::from_slice(&scan.stdout).unwrap();
|
||||
assert_eq!(result["provider"], "steam");
|
||||
assert_eq!(result["discovered"], 1);
|
||||
}
|
||||
Reference in New Issue
Block a user