use crate::cli::output::Output;
use crate::outln;
use clap::{Args, Subcommand};
use scope::display::terminal::{
check_fail, check_pass, kv_row, section_footer, section_header, separator,
};
use scope::error::Result;
use scope::market::{MarketVenue, VenueDescriptor, VenueRegistry};
#[derive(Debug, Subcommand)]
pub enum VenuesCommands {
List(ListArgs),
Schema(SchemaArgs),
Init(InitArgs),
Validate(ValidateArgs),
}
#[derive(Debug, Args)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
scope venues list
scope venues list --format json")]
pub struct ListArgs {
#[arg(short, long, default_value = "table")]
pub format: ListFormat,
}
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
pub enum ListFormat {
#[default]
Table,
Json,
}
#[derive(Debug, Args)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
scope venues schema
scope venues schema --format json")]
pub struct SchemaArgs {
#[arg(short, long, default_value = "text")]
pub format: SchemaFormat,
}
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
pub enum SchemaFormat {
#[default]
Text,
Json,
}
#[derive(Debug, Args)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
scope venues init
scope venues init --force")]
pub struct InitArgs {
#[arg(long)]
pub force: bool,
}
#[derive(Debug, Args)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
scope venues validate my-exchange.yaml
scope venues validate ~/.config/scope/venues/custom.yaml")]
pub struct ValidateArgs {
pub file: std::path::PathBuf,
}
pub fn run(cmd: VenuesCommands, out: &Output) -> Result<()> {
match cmd {
VenuesCommands::List(args) => run_list(args, out),
VenuesCommands::Schema(args) => run_schema(args, out),
VenuesCommands::Init(args) => run_init(args, out),
VenuesCommands::Validate(args) => run_validate(args, out),
}
}
fn run_list(args: ListArgs, out: &Output) -> Result<()> {
let registry = VenueRegistry::load()?;
match args.format {
ListFormat::Table => {
outln!(out, "{}", section_header("Available Venues"))?;
for listing in MarketVenue::listings(®istry) {
let caps = listing.capabilities.join(", ");
let detail = match listing.chain {
Some(chain) => format!("{caps} (DEX on {chain})"),
None => caps,
};
outln!(out, "{}", kv_row(&listing.id, &detail))?;
}
outln!(out, "{}", separator())?;
let user_dir = VenueRegistry::user_venues_dir();
let user_count = count_user_venues(&user_dir);
let total = registry.len();
let built_in = total - user_count;
outln!(
out,
"{}",
kv_row(
"Loaded",
&format!(
"{} venues ({} built-in, {} user)",
total, built_in, user_count
)
)
)?;
outln!(
out,
"{}",
kv_row("User dir", &user_dir.display().to_string())
)?;
outln!(out, "{}", section_footer())?;
}
ListFormat::Json => {
let venues = MarketVenue::listings(®istry);
let output = serde_json::json!({
"total": venues.len(),
"venues": venues,
"user_venues_dir": VenueRegistry::user_venues_dir().display().to_string(),
});
outln!(out, "{}", serde_json::to_string_pretty(&output).unwrap())?;
}
}
Ok(())
}
fn count_user_venues(dir: &std::path::Path) -> usize {
if !dir.exists() {
return 0;
}
std::fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "yaml" || ext == "yml")
.unwrap_or(false)
})
.count()
})
.unwrap_or(0)
}
fn run_schema(args: SchemaArgs, out: &Output) -> Result<()> {
match args.format {
SchemaFormat::Text => print_annotated_schema(out)?,
SchemaFormat::Json => print_json_schema(out)?,
}
Ok(())
}
fn print_annotated_schema(out: &Output) -> std::io::Result<()> {
outln!(out, "{}", section_header("Venue Descriptor Schema"))?;
outln!(
out,
r#"
A venue descriptor is a YAML file that tells Scope how to talk to
an exchange API. Place custom descriptors in:
{}
Each file defines one venue with the following structure:
id: my_exchange # Unique ID (lowercase, underscores ok)
name: My Exchange # Human-readable display name
base_url: https://api.example.com # API base URL
symbol:
template: "{{base}}{{quote}}" # Pair format (placeholders: {{base}}, {{quote}})
default_quote: USDT # Quote currency when user omits it
case: upper # "upper" or "lower" (default: upper)
error_envelope: # Optional: errors sent with HTTP 200
code_key: retCode # Status field; omit to test message_key only
ok_codes: ["0"] # Status values that mean success
message_key: retMsg # Error text (a string, or an array like Kraken's)
capabilities:
order_book: # Omit if the venue has no depth API
method: GET # GET (default) or POST
path: /api/v1/depth # URL path appended to base_url
params: # Query parameters (GET) — values can use {{pair}}, {{limit}}
symbol: "{{pair}}"
limit: "{{limit}}"
response_root: data # JSON path to the relevant data (optional)
response:
asks_key: asks # JSON key for asks array
bids_key: bids # JSON key for bids array
level_format: positional # "positional" for [price, qty] or "object"
level_price_field: price # Only needed when level_format = object
level_size_field: size # Only needed when level_format = object
ticker: # Omit if the venue has no ticker API
path: /api/v1/ticker
params:
symbol: "{{pair}}"
response:
last_price: lastPrice
high_24h: highPrice
low_24h: lowPrice
volume_24h: volume
quote_volume_24h: quoteVolume
price_change_24h: priceChange
price_change_pct_24h: priceChangePercent
trades: # Omit if the venue has no trades API
path: /api/v1/trades
params:
symbol: "{{pair}}"
limit: "{{limit}}"
response:
items_key: data # JSON key containing the trades array (omit if root)
price: price # Field for trade price
quantity: qty # Field for trade quantity
timestamp_ms: time # Field for trade timestamp (epoch ms)
side: # Side detection
field: side # JSON field containing buy/sell indicator
mapping:
buy: buy
sell: sell
Validate your file with: scope venues validate <file>
"#,
VenueRegistry::user_venues_dir().display()
)?;
outln!(out, "{}", section_footer())?;
Ok(())
}
fn print_json_schema(out: &Output) -> std::io::Result<()> {
use serde_json::{Map, Value};
fn str_prop(desc: &str) -> Value {
let mut m = Map::new();
m.insert("type".into(), Value::String("string".into()));
m.insert("description".into(), Value::String(desc.into()));
Value::Object(m)
}
fn str_type() -> Value {
serde_json::json!({"type": "string"})
}
let mut resp_props = Map::new();
for key in &[
"asks_key",
"bids_key",
"level_format",
"level_price_field",
"level_size_field",
"last_price",
"high_24h",
"low_24h",
"volume_24h",
"quote_volume_24h",
"best_bid",
"best_ask",
"items_key",
"price",
"quantity",
"quote_quantity",
"timestamp_ms",
"id",
] {
resp_props.insert((*key).into(), str_type());
}
resp_props.insert(
"filter".into(),
serde_json::json!({
"type": "object",
"properties": {"field": {"type": "string"}, "value": {"type": "string"}}
}),
);
resp_props.insert(
"side".into(),
serde_json::json!({
"type": "object",
"properties": {
"field": {"type": "string"},
"mapping": {"type": "object", "additionalProperties": {"type": "string"}}
}
}),
);
let endpoint_def = serde_json::json!({
"type": "object",
"required": ["path", "response"],
"properties": {
"method": {"type": "string", "enum": ["GET", "POST"], "default": "GET"},
"path": {"type": "string"},
"params": {"type": "object", "additionalProperties": {"type": "string"}},
"request_body": {"description": "JSON body template for POST requests"},
"response_root": {"type": "string", "description": "Dot-path to navigate JSON response"},
"response": {"type": "object", "properties": Value::Object(resp_props)}
}
});
let endpoint_ref = serde_json::json!({"$ref": "#/$defs/endpoint"});
let schema = serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "VenueDescriptor",
"description": "Schema for Scope exchange venue descriptor YAML files.",
"type": "object",
"required": ["id", "name", "base_url", "symbol", "capabilities"],
"properties": {
"id": str_prop("Unique venue identifier (e.g., 'binance')"),
"name": str_prop("Human-readable venue name (e.g., 'Binance Spot')"),
"base_url": str_prop("API base URL (e.g., 'https://api.binance.com')"),
"symbol": {
"type": "object",
"required": ["template", "default_quote"],
"properties": {
"template": str_prop("Pair template with {base} and {quote} placeholders"),
"default_quote": str_prop("Default quote currency (e.g., 'USDT')"),
"case": {"type": "string", "enum": ["upper", "lower"], "default": "upper"}
}
},
"error_envelope": {
"type": "object",
"description": "How the venue reports an error inside an HTTP 200 reply",
"required": ["message_key"],
"properties": {
"code_key": str_prop("Status field; a missing field means success"),
"ok_codes": {"type": "array", "items": {"type": "string"}, "description": "Status values that mean success, compared as strings"},
"message_key": str_prop("Field with the error message (string or array)")
}
},
"capabilities": {
"type": "object",
"properties": {
"order_book": endpoint_ref.clone(),
"ticker": endpoint_ref.clone(),
"trades": endpoint_ref
}
}
},
"$defs": {
"endpoint": endpoint_def
}
});
outln!(out, "{}", serde_json::to_string_pretty(&schema).unwrap())?;
Ok(())
}
fn run_init(args: InitArgs, out: &Output) -> Result<()> {
run_init_impl(args, VenueRegistry::user_venues_dir(), out)
}
fn run_init_impl(args: InitArgs, dest: std::path::PathBuf, out: &Output) -> Result<()> {
if !dest.exists() {
std::fs::create_dir_all(&dest).map_err(|e| {
let context = format!("Failed to create venues directory {}", dest.display());
scope::error::ScopeError::io_context(e, context)
})?;
outln!(out, "Created {}", dest.display())?;
}
let registry = VenueRegistry::load()?;
let mut copied = 0;
let mut skipped = 0;
for id in registry.list() {
let filename = format!("{}.yaml", id);
let target = dest.join(&filename);
if target.exists() && !args.force {
skipped += 1;
outln!(
out,
" skip {} (exists, use --force to overwrite)",
filename
)?;
continue;
}
if let Some(desc) = registry.get(id) {
let yaml = format!(
"# {name} venue descriptor\n# Auto-generated by `scope venues init`\n\n{content}",
name = desc.name,
content = serialize_descriptor_yaml(desc),
);
std::fs::write(&target, yaml).map_err(|e| {
scope::error::ScopeError::io_context(
e,
format!("Failed to write {}", target.display()),
)
})?;
copied += 1;
outln!(out, " {}", check_pass(&filename))?;
}
}
outln!(out)?;
outln!(out, "{}", section_header("Venues Init"))?;
outln!(out, "{}", kv_row("Directory", &dest.display().to_string()))?;
outln!(out, "{}", kv_row("Copied", &format!("{} files", copied)))?;
if skipped > 0 {
outln!(
out,
"{}",
kv_row("Skipped", &format!("{} files (already exist)", skipped))
)?;
}
outln!(out, "{}", section_footer())?;
Ok(())
}
fn serialize_descriptor_yaml(desc: &VenueDescriptor) -> String {
let mut lines = Vec::new();
lines.push(format!("id: {}", desc.id));
lines.push(format!("name: \"{}\"", desc.name));
lines.push(format!("base_url: \"{}\"", desc.base_url));
lines.push("symbol:".to_string());
lines.push(format!(" template: \"{}\"", desc.symbol.template));
lines.push(format!(" default_quote: {}", desc.symbol.default_quote));
let case_str = match desc.symbol.case {
scope::market::descriptor::SymbolCase::Upper => "upper",
scope::market::descriptor::SymbolCase::Lower => "lower",
};
lines.push(format!(" case: {}", case_str));
lines.push("capabilities:".to_string());
if let Some(ref ep) = desc.capabilities.order_book {
lines.push(" order_book:".to_string());
append_endpoint_yaml(&mut lines, ep, " ");
}
if let Some(ref ep) = desc.capabilities.ticker {
lines.push(" ticker:".to_string());
append_endpoint_yaml(&mut lines, ep, " ");
}
if let Some(ref ep) = desc.capabilities.trades {
lines.push(" trades:".to_string());
append_endpoint_yaml(&mut lines, ep, " ");
}
lines.join("\n")
}
fn append_endpoint_yaml(
lines: &mut Vec<String>,
ep: &scope::market::descriptor::EndpointDescriptor,
indent: &str,
) {
let method = match ep.method {
scope::market::descriptor::HttpMethod::GET => "GET",
scope::market::descriptor::HttpMethod::POST => "POST",
};
if method != "GET" {
lines.push(format!("{indent}method: {method}"));
}
lines.push(format!("{indent}path: \"{}\"", ep.path));
if !ep.params.is_empty() {
lines.push(format!("{indent}params:"));
let mut params: Vec<_> = ep.params.iter().collect();
params.sort_by_key(|(k, _)| (*k).clone());
for (k, v) in params {
lines.push(format!("{indent} {k}: \"{v}\""));
}
}
if let Some(ref body) = ep.request_body {
lines.push(format!(
"{indent}request_body: {}",
serde_json::to_string(body).unwrap_or_default()
));
}
if let Some(ref root) = ep.response_root {
lines.push(format!("{indent}response_root: \"{}\"", root));
}
lines.push(format!("{indent}response:"));
let r = &ep.response;
let resp_indent = format!("{indent} ");
if let Some(ref v) = r.asks_key {
lines.push(format!("{resp_indent}asks_key: {v}"));
}
if let Some(ref v) = r.bids_key {
lines.push(format!("{resp_indent}bids_key: {v}"));
}
if let Some(ref v) = r.level_format {
lines.push(format!("{resp_indent}level_format: {v}"));
}
if let Some(ref v) = r.level_price_field {
lines.push(format!("{resp_indent}level_price_field: {v}"));
}
if let Some(ref v) = r.level_size_field {
lines.push(format!("{resp_indent}level_size_field: {v}"));
}
if let Some(ref v) = r.last_price {
lines.push(format!("{resp_indent}last_price: {v}"));
}
if let Some(ref v) = r.high_24h {
lines.push(format!("{resp_indent}high_24h: {v}"));
}
if let Some(ref v) = r.low_24h {
lines.push(format!("{resp_indent}low_24h: {v}"));
}
if let Some(ref v) = r.volume_24h {
lines.push(format!("{resp_indent}volume_24h: {v}"));
}
if let Some(ref v) = r.quote_volume_24h {
lines.push(format!("{resp_indent}quote_volume_24h: {v}"));
}
if let Some(ref v) = r.best_bid {
lines.push(format!("{resp_indent}best_bid: {v}"));
}
if let Some(ref v) = r.best_ask {
lines.push(format!("{resp_indent}best_ask: {v}"));
}
if let Some(ref v) = r.items_key {
lines.push(format!("{resp_indent}items_key: {v}"));
}
if let Some(ref f) = r.filter {
lines.push(format!("{resp_indent}filter:"));
lines.push(format!("{resp_indent} field: \"{}\"", f.field));
lines.push(format!("{resp_indent} value: \"{}\"", f.value));
}
if let Some(ref v) = r.price {
lines.push(format!("{resp_indent}price: {v}"));
}
if let Some(ref v) = r.quantity {
lines.push(format!("{resp_indent}quantity: {v}"));
}
if let Some(ref v) = r.quote_quantity {
lines.push(format!("{resp_indent}quote_quantity: {v}"));
}
if let Some(ref v) = r.timestamp_ms {
lines.push(format!("{resp_indent}timestamp_ms: {v}"));
}
if let Some(ref v) = r.id {
lines.push(format!("{resp_indent}id: {v}"));
}
if let Some(ref sm) = r.side {
lines.push(format!("{resp_indent}side:"));
lines.push(format!("{resp_indent} field: \"{}\"", sm.field));
if !sm.mapping.is_empty() {
lines.push(format!("{resp_indent} mapping:"));
let mut entries: Vec<_> = sm.mapping.iter().collect();
entries.sort_by_key(|(k, _)| (*k).clone());
for (k, v) in entries {
lines.push(format!("{resp_indent} \"{k}\": \"{v}\""));
}
}
}
}
fn run_validate(args: ValidateArgs, out: &Output) -> Result<()> {
let path = &args.file;
if !path.exists() {
outln!(
out,
"{}",
check_fail(&format!("File not found: {}", path.display()))
)?;
return Err(scope::error::ScopeError::Chain(format!(
"File not found: {}",
path.display()
)));
}
let content = std::fs::read_to_string(path).map_err(|e| {
scope::error::ScopeError::io_context(e, format!("Failed to read {}", path.display()))
})?;
outln!(out, "{}", section_header("Venue Validation"))?;
outln!(out, "{}", kv_row("File", &path.display().to_string()))?;
outln!(out, "{}", separator())?;
match VenueRegistry::validate_yaml(&content) {
Ok(desc) => {
outln!(out, "{}", check_pass("Valid YAML syntax"))?;
outln!(out, "{}", check_pass(&format!("Venue ID: {}", desc.id)))?;
outln!(out, "{}", check_pass(&format!("Name: {}", desc.name)))?;
outln!(
out,
"{}",
check_pass(&format!("Base URL: {}", desc.base_url))
)?;
let caps = desc.capability_names();
if caps.is_empty() {
outln!(
out,
"{}",
check_fail(
"No capabilities defined (need at least one of: order_book, ticker, trades)"
)
)?;
} else {
for cap in &caps {
outln!(out, "{}", check_pass(&format!("Capability: {}", cap)))?;
}
}
if desc.symbol.template.contains("{base}") {
outln!(
out,
"{}",
check_pass("Symbol template contains {base} placeholder")
)?;
} else {
outln!(
out,
"{}",
check_fail("Symbol template missing {base} placeholder")
)?;
}
outln!(out, "{}", separator())?;
if caps.is_empty() {
outln!(out, "{}", check_fail("Validation completed with warnings"))?;
} else {
outln!(out, "{}", check_pass("Validation passed"))?;
}
outln!(out, "{}", section_footer())?;
Ok(())
}
Err(e) => {
outln!(out, "{}", check_fail("Invalid YAML"))?;
outln!(out, "{}", check_fail(&format!("Error: {}", e)))?;
outln!(out, "{}", separator())?;
outln!(
out,
"{}",
kv_row(
"Hint",
"Run `scope venues schema` to see the expected format"
)
)?;
outln!(out, "{}", section_footer())?;
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quiet() -> Output {
Output::capture().0
}
#[test]
fn test_list_format_default() {
let fmt = ListFormat::default();
assert!(matches!(fmt, ListFormat::Table));
}
#[test]
fn test_schema_format_default() {
let fmt = SchemaFormat::default();
assert!(matches!(fmt, SchemaFormat::Text));
}
#[test]
fn test_run_list_table() {
let args = ListArgs {
format: ListFormat::Table,
};
let result = run_list(args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_run_list_json() {
let args = ListArgs {
format: ListFormat::Json,
};
let (o, cap) = Output::capture();
run_list(args, &o).unwrap();
let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
assert!(v["venues"].as_array().is_some_and(|a| !a.is_empty()));
assert_eq!(
v["total"].as_u64().unwrap() as usize,
v["venues"].as_array().unwrap().len()
);
assert!(
v["venues"]
.as_array()
.unwrap()
.iter()
.any(|venue| venue["kind"] == "dex")
);
}
#[test]
fn test_run_schema_text() {
let args = SchemaArgs {
format: SchemaFormat::Text,
};
let result = run_schema(args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_run_schema_json() {
let args = SchemaArgs {
format: SchemaFormat::Json,
};
let (o, cap) = Output::capture();
run_schema(args, &o).unwrap();
let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
assert!(v.is_object());
}
#[test]
fn test_validate_missing_file() {
let args = ValidateArgs {
file: std::path::PathBuf::from("/tmp/nonexistent_venue_test.yaml"),
};
let result = run_validate(args, &quiet());
assert!(result.is_err());
}
#[test]
fn test_validate_valid_file() {
let yaml = r#"
id: test_venue
name: Test Exchange
base_url: https://api.test.com
symbol:
template: "{base}{quote}"
default_quote: USDT
capabilities:
order_book:
path: /depth
params:
symbol: "{pair}"
response:
asks_key: asks
bids_key: bids
level_format: positional
"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.yaml");
std::fs::write(&path, yaml).unwrap();
let args = ValidateArgs { file: path };
let (o, cap) = Output::capture();
run_validate(args, &o).unwrap();
assert!(cap.out().contains("Venue ID: test_venue"));
assert!(cap.out().contains("Validation passed"));
}
#[test]
fn test_validate_invalid_file() {
let yaml = "this is not valid yaml: [";
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.yaml");
std::fs::write(&path, yaml).unwrap();
let args = ValidateArgs { file: path };
let result = run_validate(args, &quiet());
assert!(result.is_err());
}
#[test]
fn test_count_user_venues_nonexistent() {
let count = count_user_venues(std::path::Path::new("/tmp/nonexistent_dir_test"));
assert_eq!(count, 0);
}
#[test]
fn test_count_user_venues_with_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.yaml"), "").unwrap();
std::fs::write(dir.path().join("b.yml"), "").unwrap();
std::fs::write(dir.path().join("c.txt"), "").unwrap();
assert_eq!(count_user_venues(dir.path()), 2);
}
#[test]
fn test_serialize_descriptor_yaml_roundtrip() {
let yaml = r#"
id: roundtrip_test
name: Roundtrip Exchange
base_url: https://api.roundtrip.com
symbol:
template: "{base}_{quote}"
default_quote: USDT
case: lower
capabilities:
order_book:
path: /api/depth
params:
symbol: "{pair}"
response:
asks_key: asks
bids_key: bids
level_format: positional
"#;
let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
let serialized = serialize_descriptor_yaml(&desc);
assert!(serialized.contains("roundtrip_test"));
assert!(serialized.contains("Roundtrip Exchange"));
assert!(serialized.contains("/api/depth"));
assert!(serialized.contains("case: lower"));
}
#[test]
fn test_run_init_to_temp_dir() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().to_path_buf();
let args = InitArgs { force: true };
let result = run_init_impl(args, dest.clone(), &quiet());
assert!(result.is_ok());
let registry = VenueRegistry::load().unwrap();
for id in registry.list() {
let filename = format!("{}.yaml", id);
let target = dest.join(&filename);
assert!(target.exists(), "Expected {} to exist", filename);
let content = std::fs::read_to_string(&target).unwrap();
assert!(content.contains(&format!("id: {}", id)));
}
}
#[test]
fn test_serialize_full_descriptor() {
let yaml = r#"
id: full_caps
name: Full Capabilities Exchange
base_url: https://api.full.com
symbol:
template: "{base}{quote}"
default_quote: USDT
capabilities:
order_book:
path: /api/depth
params:
symbol: "{pair}"
response:
asks_key: asks
bids_key: bids
level_format: positional
ticker:
path: /api/ticker
params:
symbol: "{pair}"
response:
last_price: lastPrice
high_24h: high
low_24h: low
trades:
path: /api/trades
params:
symbol: "{pair}"
limit: "{limit}"
response:
items_key: data
price: price
quantity: qty
timestamp_ms: time
side:
field: side
mapping:
buy: buy
sell: sell
"#;
let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
let serialized = serialize_descriptor_yaml(&desc);
assert!(serialized.contains("order_book:"));
assert!(serialized.contains("ticker:"));
assert!(serialized.contains("trades:"));
assert!(serialized.contains("asks_key"));
assert!(serialized.contains("last_price"));
assert!(serialized.contains("items_key"));
}
#[test]
fn test_serialize_descriptor_with_post_and_request_body() {
let yaml = r#"
id: post_venue
name: POST Exchange
base_url: https://api.post.com
symbol:
template: "{base}{quote}"
default_quote: USDT
capabilities:
order_book:
method: POST
path: /api/depth
request_body: {"symbol":"{{pair}}"}
response_root: result
params: {}
response:
asks_key: asks
bids_key: bids
level_format: positional
"#;
let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
let serialized = serialize_descriptor_yaml(&desc);
assert!(serialized.contains("method: POST"));
assert!(serialized.contains("response_root"));
assert!(serialized.contains("request_body"));
}
#[test]
fn test_serialize_descriptor_with_filter_and_side() {
let yaml = r#"
id: filter_venue
name: Filter Exchange
base_url: https://api.filter.com
symbol:
template: "{base}{quote}"
default_quote: USDT
capabilities:
trades:
path: /api/trades
params:
symbol: "{pair}"
response:
items_key: trades
filter:
field: symbol
value: "{pair}"
side:
field: side
mapping:
buy: B
sell: S
"#;
let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
let serialized = serialize_descriptor_yaml(&desc);
assert!(serialized.contains("filter:"));
assert!(serialized.contains("field:"));
assert!(serialized.contains("side:"));
assert!(serialized.contains("mapping:"));
}
#[test]
fn test_run_init_skips_existing() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().to_path_buf();
let existing_path = dest.join("binance.yaml");
std::fs::create_dir_all(&dest).unwrap();
let original_content = "id: binance\n# pre-existing file\n";
std::fs::write(&existing_path, original_content).unwrap();
let args = InitArgs { force: false };
let result = run_init_impl(args, dest.clone(), &quiet());
assert!(result.is_ok());
let content = std::fs::read_to_string(&existing_path).unwrap();
assert_eq!(
content, original_content,
"Existing file should not be overwritten when force=false"
);
}
#[test]
fn test_run_init_creates_directory_when_missing() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("nested").join("venues");
assert!(!dest.exists());
let args = InitArgs { force: true };
let result = run_init_impl(args, dest.clone(), &quiet());
assert!(result.is_ok());
assert!(dest.exists());
assert!(dest.is_dir());
}
#[test]
fn test_validate_file_template_missing_base() {
let yaml = r#"
id: bad_template
name: Bad Template Exchange
base_url: https://api.test.com
symbol:
template: "nobase{quote}"
default_quote: USDT
capabilities:
order_book:
path: /depth
params:
symbol: "{pair}"
response:
asks_key: asks
bids_key: bids
level_format: positional
"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad_template.yaml");
std::fs::write(&path, yaml).unwrap();
let args = ValidateArgs { file: path };
let result = run_validate(args, &quiet());
assert!(result.is_ok()); }
#[test]
fn test_validate_file_empty_capabilities() {
let yaml = r#"
id: no_caps
name: No Capabilities
base_url: https://api.test.com
symbol:
template: "{base}{quote}"
default_quote: USDT
capabilities: {}
"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no_caps.yaml");
std::fs::write(&path, yaml).unwrap();
let args = ValidateArgs { file: path };
let result = run_validate(args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_run_venues_command_routes_to_subcommands() {
let result = run(
VenuesCommands::List(ListArgs {
format: ListFormat::Table,
}),
&quiet(),
);
assert!(result.is_ok());
let result = run(
VenuesCommands::Schema(SchemaArgs {
format: SchemaFormat::Text,
}),
&quiet(),
);
assert!(result.is_ok());
}
}