use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tropel_sdk::scenario::{Scenario, ScenarioItem};
use tropel_sdk::traits::{Driver, InputAdapter};
use tropel_sdk::{Result, TropelError};
use crate::cli_registry::build_registry;
pub(crate) async fn inspect_command(
input: &Path,
format: Option<&str>,
plugins_dir: Option<&Path>,
subprocess_adapter: &[String],
) -> Result<()> {
let registry = build_registry(subprocess_adapter, plugins_dir)?;
let bytes = std::fs::read(input)
.map_err(|e| TropelError::Parse(format!("Failed to read '{}': {}", input.display(), e)))?;
println!("Tropel Inspect — v{}", env!("CARGO_PKG_VERSION"));
println!("Input: {}", input.display());
let driver: Option<Box<dyn Driver>> = if let Some(fmt) = format {
registry.resolve_driver_by_id(fmt)
} else {
registry.resolve_driver(&bytes)
};
if let Some(driver) = driver {
println!("Resolved by driver: {}", driver.id());
println!("Kind: imperative (runs JS per iteration)");
match driver
.declared_options(&bytes, Some(input), &HashMap::new())
.await
{
Ok(Some(opts)) => {
println!("Declared options:");
if let Some(exec) = &opts.execution {
println!(" execution: {} ({:?})", exec.executor_name(), exec);
}
if let Some(scenarios) = &opts.scenarios {
println!(" scenarios: {}", scenarios.len());
for (name, sc) in scenarios {
println!(
" - {}: {} ({:?})",
name,
sc.execution.executor_name(),
sc.execution
);
}
}
if !opts.thresholds.is_empty() {
println!(" thresholds: {}", opts.thresholds.len());
for (name, t) in &opts.thresholds {
println!(" - {}: {}", name, t.expression);
}
}
}
Ok(None) => {
println!("Declared options: (none)");
}
Err(e) => {
println!("Declared options: ERROR — {}", e);
}
}
return Ok(());
}
let adapter: Box<dyn InputAdapter> = if let Some(fmt) = format {
registry.resolve_input_by_id(fmt).ok_or_else(|| {
let available = registry.list_inputs();
TropelError::Config(format!(
"Unknown input format '{}'. Available formats: {}",
fmt,
available.join(", ")
))
})?
} else {
registry.resolve_input(&bytes).ok_or_else(|| {
let available = registry.list_inputs();
TropelError::Parse(format!(
"No input adapter recognized '{}'. Available adapters: {}",
input.display(),
if available.is_empty() {
"(none registered — check build configuration)".to_string()
} else {
available.join(", ")
}
))
})?
};
println!("Resolved by adapter: {}", adapter.id());
println!("Kind: declarative (static request list)");
let scenario = adapter.parse_with_path(&bytes, Some(input))?;
print_scenario_summary(&scenario);
Ok(())
}
pub(crate) fn print_scenario_summary(scenario: &Scenario) {
println!("Scenario: {}", scenario.info.name);
if let Some(desc) = &scenario.info.description {
println!(" description: {}", desc);
}
if let Some(auth) = &scenario.auth {
println!(" global auth: {:?}", auth);
}
println!(" variables: {} defined", scenario.variables.len());
for (k, v) in &scenario.variables {
println!(" {} = {}", k, v);
}
fn walk(items: &[ScenarioItem], depth: usize, out: &mut (usize, usize)) {
for item in items {
let indent = " ".repeat(depth);
match &item.request {
Some(req) => {
out.0 += 1;
let scripted = !item.test.is_empty() || !item.prerequest.is_empty();
if scripted {
out.1 += 1;
}
println!(
"{}• {} — {} {}{}",
indent,
item.name,
req.method,
req.url,
if scripted { " (scripted)" } else { "" }
);
}
None => {
println!("{}▸ {} (folder)", indent, item.name);
walk(&item.items, depth + 1, out);
}
}
}
}
let mut counts = (0usize, 0usize);
walk(&scenario.items, 1, &mut counts);
println!("Totals: {} requests ({} with scripts)", counts.0, counts.1);
}
pub(crate) async fn archive_command(
input: &Path,
format: Option<&str>,
output: Option<&Path>,
data_file: Option<&Path>,
env_file: Option<&Path>,
config: Option<&Path>,
) -> Result<()> {
let out_dir = output.unwrap_or_else(|| Path::new("./tropel-archive"));
std::fs::create_dir_all(out_dir).map_err(TropelError::Io)?;
let input_name = input
.file_name()
.ok_or_else(|| {
TropelError::Config(format!("Input '{}' has no file name", input.display()))
})?
.to_string_lossy()
.to_string();
let bundled_input = out_dir.join(&input_name);
std::fs::copy(input, &bundled_input).map_err(TropelError::Io)?;
let mut deps: Vec<(String, PathBuf, PathBuf)> = Vec::new(); let mut used_names: HashMap<String, String> = HashMap::new(); used_names.insert(input_name.clone(), "input".to_string());
let mut copy_dep = |role: &str, src: &Path, out: &Path| -> Result<()> {
let name = src
.file_name()
.ok_or_else(|| {
TropelError::Config(format!("{} '{}' has no file name", role, src.display()))
})?
.to_string_lossy()
.to_string();
if let Some(existing) = used_names.get(&name) {
return Err(TropelError::Config(format!(
"archive: '{}' (from {}) collides with {} — all bundled files \
share one directory, rename it or bundle separately",
name,
src.display(),
existing
)));
}
used_names.insert(name.clone(), role.to_string());
let dest = out.join(&name);
std::fs::copy(src, &dest).map_err(TropelError::Io)?;
deps.push((role.to_string(), src.to_path_buf(), dest));
Ok(())
};
if let Some(d) = data_file {
copy_dep("data_file", d, out_dir)?;
}
if let Some(e) = env_file {
copy_dep("env_file", e, out_dir)?;
}
if let Some(c) = config {
copy_dep("config", c, out_dir)?;
}
let mut manifest = serde_json::Map::new();
manifest.insert(
"version".into(),
serde_json::Value::String(env!("CARGO_PKG_VERSION").into()),
);
manifest.insert(
"input".into(),
serde_json::Value::String(input_name.clone()),
);
if let Some(fmt) = format {
manifest.insert("format".into(), serde_json::Value::String(fmt.to_string()));
}
let mut dep_map = serde_json::Map::new();
for (role, _src, dest) in &deps {
dep_map.insert(
role.clone(),
serde_json::Value::String(
dest.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
),
);
}
manifest.insert("bundled_files".into(), serde_json::Value::Object(dep_map));
let mut run_cmd = format!("tropel run {}", input_name);
if let Some(fmt) = format {
run_cmd.push_str(&format!(" --format {}", fmt));
}
for (role, _src, dest) in &deps {
let flag = match role.as_str() {
"data_file" => "--data-file",
"env_file" => "--env-file",
"config" => "--config",
_ => continue,
};
run_cmd.push_str(&format!(
" {} {}",
flag,
dest.file_name().unwrap_or_default().to_string_lossy()
));
}
manifest.insert("run".into(), serde_json::Value::String(run_cmd.clone()));
let manifest_path = out_dir.join("tropel-archive.json");
let manifest_json = serde_json::Value::Object(manifest);
std::fs::write(
&manifest_path,
serde_json::to_string_pretty(&manifest_json)
.map_err(|e| TropelError::Other(format!("manifest serialize: {}", e)))?,
)
.map_err(TropelError::Io)?;
println!("Tropel Archive — v{}", env!("CARGO_PKG_VERSION"));
println!("Bundle created in: {}", out_dir.display());
println!(
" input: {} (from {})",
bundled_input.display(),
input.display()
);
for (role, src, dest) in &deps {
println!(" {}: {} (from {})", role, dest.display(), src.display());
}
println!(" manifest: {}", manifest_path.display());
println!("Re-run from the bundle directory:");
println!(" cd {} && {}", out_dir.display(), run_cmd);
Ok(())
}
pub(crate) async fn list_extensions(plugins_dir: Option<&std::path::Path>) -> Result<()> {
let registry = build_registry(&[], plugins_dir)?;
let inputs = registry.list_inputs();
println!("Tropel Extensions — v{}", env!("CARGO_PKG_VERSION"));
println!();
if inputs.is_empty() {
println!(" No input adapters registered.");
println!(" Use `tropel build --with <crate>` to build a custom binary with extensions.");
} else {
println!(" Input formats:");
for fmt in &inputs {
println!(
" - {} (use: `tropel run input.{} --format {})",
fmt, fmt, fmt
);
}
println!();
println!(" Use `tropel run <file> --format <name>` to select a specific format.");
println!(" Without `--format`, the engine auto-detects from file content.");
}
let protocols = registry.list_protocols();
if !protocols.is_empty() {
println!();
println!(" Protocols:");
for p in &protocols {
println!(" - {}", p);
}
}
let outputs = registry.list_outputs();
if !outputs.is_empty() {
println!();
println!(" Outputs:");
for o in &outputs {
println!(" - {}", o);
}
}
Ok(())
}
pub(crate) async fn build_custom(
with: &[String],
output: &std::path::Path,
release: bool,
) -> Result<()> {
use tropel_build::{build, BuildConfig};
let mut extensions = Vec::with_capacity(with.len());
for spec in with {
extensions.push(tropel_build::parse_dep_spec(spec)?);
}
let config = BuildConfig {
extensions,
output: output.to_path_buf(),
release,
};
build(&config)
}
pub(crate) fn print_version() -> Result<()> {
println!("Tropel v{}", env!("CARGO_PKG_VERSION"));
println!("Repository: {}", env!("CARGO_PKG_REPOSITORY"));
println!("License: Apache-2.0");
println!("Shim bundle: v{}", crate::js_bootstrap::SHIM_BUNDLE_VERSION);
Ok(())
}
pub(crate) fn new_command(output: &Path) -> Result<()> {
if output.exists() {
return Err(TropelError::Config(format!(
"'{}' already exists — refusing to overwrite",
output.display()
)));
}
let template = r#"import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 1,
duration: '30s',
};
export default function () {
const res = http.get('https://test.k6.io/');
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1);
}
"#;
std::fs::write(output, template).map_err(TropelError::Io)?;
println!("Created '{}'", output.display());
Ok(())
}
pub(crate) fn load_data_file(path: &PathBuf) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let content = std::fs::read_to_string(path).map_err(TropelError::Io)?;
let trimmed = content.trim();
if trimmed.starts_with('[') {
let data: Vec<HashMap<String, serde_json::Value>> = serde_json::from_str(trimmed)
.map_err(|e| TropelError::Parse(format!("JSON data-file parse error: {}", e)))?;
return Ok(data);
}
if trimmed.contains(',') || trimmed.starts_with('"') {
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.from_reader(content.as_bytes());
let headers: Vec<String> = reader
.headers()
.map_err(|e| TropelError::Parse(format!("CSV header error: {}", e)))?
.iter()
.map(|h| h.to_string())
.collect();
let mut rows = Vec::new();
for result in reader.records() {
let record =
result.map_err(|e| TropelError::Parse(format!("CSV record error: {}", e)))?;
let mut map = HashMap::new();
for (i, field) in record.iter().enumerate() {
if i < headers.len() {
map.insert(
headers[i].clone(),
serde_json::Value::String(field.to_string()),
);
}
}
rows.push(map);
}
return Ok(rows);
}
Ok(vec![])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_writes_a_runnable_template_and_refuses_overwrite() {
let dir = std::env::temp_dir();
let path = dir.join(format!("tropel-new-{}.js", std::process::id()));
let _ = std::fs::remove_file(&path);
new_command(&path).unwrap();
let script = std::fs::read_to_string(&path).unwrap();
assert!(
script.contains("export default function"),
"template must be a runnable script, got: {}",
script
);
assert!(script.contains("http.get"));
let err = new_command(&path).unwrap_err();
assert!(
err.to_string().contains("already exists"),
"must refuse to overwrite, got: {}",
err
);
let _ = std::fs::remove_file(&path);
}
}