use crate::cli::output::Output;
use crate::outln;
use clap::{Args, Subcommand};
use scope::config::Config;
use scope::display::terminal::{
check_fail, check_pass, info_row, kv_row, section_footer, section_header, separator,
};
use scope::error::Result;
use scope::http::HttpClient;
use scope::market::catalog;
use scope::market::registry::VenueSource;
use scope::market::{MarketVenue, VenueRegistry};
use std::sync::Arc;
#[derive(Debug, Subcommand)]
pub enum VenuesCommands {
List(ListArgs),
Schema(SchemaArgs),
Update(UpdateArgs),
Init(InitArgs),
Validate(ValidateArgs),
}
#[derive(Debug, Args)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
scope venues update
scope venues update --url https://example.com/my-catalog")]
pub struct UpdateArgs {
#[arg(long, value_name = "URL")]
pub url: Option<String>,
}
#[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 ~/.scope-bca/venues/custom.yaml")]
pub struct ValidateArgs {
pub file: std::path::PathBuf,
}
pub async fn run(
cmd: VenuesCommands,
config: &Config,
http: Arc<dyn HttpClient>,
out: &Output,
) -> Result<()> {
match cmd {
VenuesCommands::List(args) => run_list(args, out),
VenuesCommands::Schema(args) => run_schema(args, out),
VenuesCommands::Update(args) => {
let url = args.url.as_deref().unwrap_or(config.market.catalog_url());
run_update(url, http.as_ref(), &VenueRegistry::catalog_dir(), out).await
}
VenuesCommands::Init(args) => {
if catalog::installed(&VenueRegistry::catalog_dir()).is_empty() {
run_update(
config.market.catalog_url(),
http.as_ref(),
&VenueRegistry::catalog_dir(),
out,
)
.await?;
}
run_init(args, out)
}
VenuesCommands::Validate(args) => run_validate(args, out),
}
}
async fn run_update(
url: &str,
http: &dyn HttpClient,
dest: &std::path::Path,
out: &Output,
) -> Result<()> {
let example = catalog::example_path();
let _ = catalog::write_example(&example);
let report = catalog::sync(http, url, dest).await?;
outln!(out, "{}", section_header("Venue Catalog"))?;
outln!(out, "{}", kv_row("Updated", &report.summary()))?;
outln!(out, "{}", kv_row("From", url))?;
outln!(out, "{}", kv_row("Catalog", &dest.display().to_string()))?;
outln!(
out,
"{}",
kv_row(
"Your venues",
&VenueRegistry::user_venues_dir().display().to_string()
)
)?;
outln!(out, "{}", section_footer())?;
Ok(())
}
pub async fn ensure_catalog(config: &Config, http: &dyn HttpClient, out: &Output) {
static TRIED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
ensure_catalog_at(
config.market.catalog_url(),
http,
&VenueRegistry::catalog_dir(),
&catalog::example_path(),
&TRIED,
out,
)
.await;
}
async fn ensure_catalog_at(
url: &str,
http: &dyn HttpClient,
dest: &std::path::Path,
example: &std::path::Path,
tried: &std::sync::atomic::AtomicBool,
out: &Output,
) {
if !catalog::installed(dest).is_empty() || tried.swap(true, std::sync::atomic::Ordering::SeqCst)
{
return;
}
let _ = catalog::write_example(example);
crate::note!(out, "Fetching the venue catalog (first use)…");
match catalog::sync(http, url, dest).await {
Ok(report) => crate::note!(out, "Venue catalog installed: {}", report.summary()),
Err(e) => crate::note!(
out,
"Could not fetch the venue catalog ({e}). Run `scope venues update` when online, or describe an exchange yourself: see {}",
example.display()
),
}
}
fn run_list(args: ListArgs, out: &Output) -> Result<()> {
run_list_with(&VenueRegistry::load()?, args, out)
}
fn run_list_with(registry: &VenueRegistry, args: ListArgs, out: &Output) -> Result<()> {
match args.format {
ListFormat::Table => {
outln!(out, "{}", section_header("Available Venues"))?;
for listing in MarketVenue::listings(registry) {
let caps = listing.capabilities.join(", ");
let detail = match (listing.chain, registry.source(&listing.id)) {
(Some(chain), _) => format!("{caps} (DEX on {chain})"),
(None, Some(VenueSource::User)) => format!("{caps} (yours)"),
(None, _) => caps,
};
outln!(out, "{}", kv_row(&listing.id, &detail))?;
}
outln!(out, "{}", separator())?;
let user_dir = VenueRegistry::user_venues_dir();
let ids = registry.list();
let yours = ids
.iter()
.filter(|id| registry.source(id) == Some(VenueSource::User))
.count();
outln!(
out,
"{}",
kv_row(
"Loaded",
&format!(
"{} venues ({} from the catalog, {} yours)",
ids.len(),
ids.len() - yours,
yours
)
)
)?;
if ids.is_empty() {
outln!(
out,
"{}",
info_row("No exchange venues yet: run `scope venues update`")
)?;
}
outln!(
out,
"{}",
kv_row(
"Catalog",
&VenueRegistry::catalog_dir().display().to_string()
)
)?;
outln!(
out,
"{}",
kv_row("Your venues", &user_dir.display().to_string())
)?;
outln!(out, "{}", section_footer())?;
}
ListFormat::Json => {
let venues = MarketVenue::listings(registry);
let output = serde_json::json!({
"total": venues.len(),
"venues": venues,
"user_venues_dir": VenueRegistry::user_venues_dir().display().to_string(),
"catalog_dir": VenueRegistry::catalog_dir().display().to_string(),
});
outln!(out, "{}", serde_json::to_string_pretty(&output).unwrap())?;
}
}
Ok(())
}
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 source = VenueRegistry::catalog_dir();
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&source)
.map(|entries| {
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "yaml"))
.collect()
})
.unwrap_or_default();
files.sort();
if files.is_empty() {
return Err(scope::error::ScopeError::InvalidInput(
"The venue catalog is not installed: run `scope venues update` first.".into(),
));
}
let mut copied = 0;
let mut skipped = 0;
for path in files {
let filename = path.file_name().unwrap().to_string_lossy().into_owned();
let target = dest.join(&filename);
if target.exists() && !args.force {
skipped += 1;
outln!(
out,
" skip {} (exists, use --force to overwrite)",
filename
)?;
continue;
}
std::fs::copy(&path, &target).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 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_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_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());
}
#[tokio::test]
async fn test_run_venues_command_routes_to_subcommands() {
let config = Config::default();
let http = scope::http::test_transport();
let result = run(
VenuesCommands::List(ListArgs {
format: ListFormat::Table,
}),
&config,
http.clone(),
&quiet(),
)
.await;
assert!(result.is_ok());
let result = run(
VenuesCommands::Schema(SchemaArgs {
format: SchemaFormat::Text,
}),
&config,
http,
&quiet(),
)
.await;
assert!(result.is_ok());
}
async fn catalog_server(venue: &str) -> mockito::ServerGuard {
let mut server = mockito::Server::new_async().await;
let index = serde_json::json!({
"schema": 1,
"venues": [{"id": "weex", "file": "weex.yaml", "sha256": catalog::checksum(venue)}],
});
server
.mock("GET", "/c/index.json")
.with_body(index.to_string())
.create_async()
.await;
server
.mock("GET", "/c/weex.yaml")
.with_body(venue)
.create_async()
.await;
server
}
const WEEX: &str = "id: weex\nname: WEEX Spot\nbase_url: https://api-spot.weex.com\nsymbol:\n template: \"{base}{quote}\"\n default_quote: USDT\ncapabilities:\n ohlc:\n path: /api/v3/market/klines\n response:\n ohlc_format: array_of_arrays\n ohlc_fields: [open_time, open, high, low, close, volume, close_time]\n";
#[tokio::test]
async fn test_update_installs_the_catalog_and_reports_it() {
let server = catalog_server(WEEX).await;
let home = tempfile::tempdir().unwrap();
let dest = home.path().join("catalog");
let (out, cap) = Output::capture();
run_update(
&format!("{}/c", server.url()),
scope::http::test_transport().as_ref(),
&dest,
&out,
)
.await
.unwrap();
let text = crate::cli::ansi::strip(&cap.out());
assert!(
text.contains("1 venues: 1 added (weex), 0 updated, 0 removed"),
"{text}"
);
let registry = VenueRegistry::load_dirs(&dest, &home.path().join("none")).unwrap();
assert_eq!(
std::fs::read_to_string(dest.join("weex.yaml")).unwrap(),
WEEX
);
assert!(registry.get("weex").unwrap().capabilities.ohlc.is_some());
}
#[test]
fn test_init_copies_the_catalog_files_verbatim() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("venues");
run_init_impl(InitArgs { force: false }, dest.clone(), &quiet()).unwrap();
let catalog = VenueRegistry::catalog_dir();
for name in ["binance.yaml", "kraken.yaml"] {
assert_eq!(
std::fs::read(dest.join(name)).unwrap(),
std::fs::read(catalog.join(name)).unwrap(),
"{name} is copied as it is"
);
}
let copied = VenueRegistry::load_dirs(&dir.path().join("none"), &dest).unwrap();
assert!(copied.get("binance").unwrap().capabilities.ohlc.is_some());
}
#[test]
fn test_list_marks_your_own_venues() {
let (out, cap) = Output::capture();
run_list(
ListArgs {
format: ListFormat::Table,
},
&out,
)
.unwrap();
let text = crate::cli::ansi::strip(&cap.out());
assert!(text.contains("from the catalog"), "{text}");
assert!(text.contains("Catalog"), "{text}");
}
#[tokio::test]
async fn test_first_use_fetches_the_catalog_once_and_says_so() {
let server = catalog_server(WEEX).await;
let home = tempfile::tempdir().unwrap();
let dest = home.path().join("catalog");
let example = home.path().join(catalog::EXAMPLE_FILE);
let tried = std::sync::atomic::AtomicBool::new(false);
let http = scope::http::test_transport();
let url = format!("{}/c", server.url());
let (out, cap) = Output::capture();
ensure_catalog_at(&url, http.as_ref(), &dest, &example, &tried, &out).await;
let notes = cap.err();
assert!(
notes.contains("Fetching the venue catalog (first use)"),
"{notes}"
);
assert!(notes.contains("1 added (weex)"), "{notes}");
assert!(example.exists(), "the example is written");
let (out, cap) = Output::capture();
let fresh = std::sync::atomic::AtomicBool::new(false);
ensure_catalog_at(&url, http.as_ref(), &dest, &example, &fresh, &out).await;
assert!(cap.err().is_empty(), "{}", cap.err());
}
#[tokio::test]
async fn test_first_use_offline_says_how_to_get_venues_and_tries_once() {
let home = tempfile::tempdir().unwrap();
let dest = home.path().join("catalog");
let example = home.path().join(catalog::EXAMPLE_FILE);
let tried = std::sync::atomic::AtomicBool::new(false);
let http = scope::http::test_transport();
let (out, cap) = Output::capture();
ensure_catalog_at(
"http://127.0.0.1:9/none",
http.as_ref(),
&dest,
&example,
&tried,
&out,
)
.await;
let notes = cap.err();
assert!(
notes.contains("Could not fetch the venue catalog"),
"{notes}"
);
assert!(notes.contains("scope venues update"), "{notes}");
assert!(notes.contains(catalog::EXAMPLE_FILE), "{notes}");
let (out, cap) = Output::capture();
ensure_catalog_at(
"http://127.0.0.1:9/none",
http.as_ref(),
&dest,
&example,
&tried,
&out,
)
.await;
assert!(cap.err().is_empty(), "{}", cap.err());
}
#[tokio::test]
async fn test_update_from_an_unreachable_url_fails_and_keeps_the_catalog() {
let before = catalog::installed(&VenueRegistry::catalog_dir());
let result = run(
VenuesCommands::Update(UpdateArgs {
url: Some("http://127.0.0.1:9/none".into()),
}),
&Config::default(),
scope::http::test_transport(),
&quiet(),
)
.await;
assert!(result.is_err());
assert_eq!(catalog::installed(&VenueRegistry::catalog_dir()), before);
}
#[test]
fn test_list_with_no_venues_says_how_to_get_them_and_marks_yours() {
let (out, cap) = Output::capture();
run_list_with(
&VenueRegistry::default(),
ListArgs {
format: ListFormat::Table,
},
&out,
)
.unwrap();
let text = crate::cli::ansi::strip(&cap.out());
assert!(
text.contains("0 venues (0 from the catalog, 0 yours)"),
"{text}"
);
assert!(text.contains("No exchange venues yet"), "{text}");
let user = tempfile::tempdir().unwrap();
std::fs::write(user.path().join("weex.yaml"), WEEX).unwrap();
let registry =
VenueRegistry::load_dirs(&VenueRegistry::catalog_dir(), user.path()).unwrap();
let (out, cap) = Output::capture();
run_list_with(
®istry,
ListArgs {
format: ListFormat::Table,
},
&out,
)
.unwrap();
let text = crate::cli::ansi::strip(&cap.out());
assert!(text.contains("ohlc (yours)"), "{text}");
assert!(text.contains(", 1 yours)"), "{text}");
}
}