Skip to main content

scope_cli/cli/
venues.rs

1//! `scope venues` subcommands for managing venue descriptors.
2//!
3//! Provides venue discovery, schema documentation, initialisation of user
4//! venue directories, and YAML validation.
5
6use crate::cli::output::Output;
7use crate::outln;
8use clap::{Args, Subcommand};
9use scope::display::terminal::{
10    check_fail, check_pass, kv_row, section_footer, section_header, separator,
11};
12use scope::error::Result;
13use scope::market::{MarketVenue, VenueDescriptor, VenueRegistry};
14
15/// Venue management subcommands.
16///
17/// List available exchange venues, view the YAML schema, initialise the
18/// user venues directory, or validate a custom descriptor file.
19///
20/// # Examples
21///
22/// ```text
23/// scope venues list
24/// scope venues list --format json
25/// scope venues schema
26/// scope venues init
27/// scope venues validate my-exchange.yaml
28/// ```
29#[derive(Debug, Subcommand)]
30pub enum VenuesCommands {
31    /// List all available exchange venues and their capabilities.
32    ///
33    /// Shows built-in and user-defined CEX venues with their supported
34    /// API capabilities (order_book, ticker, trades), then the DEX venues.
35    ///
36    /// # Examples
37    ///
38    /// ```text
39    /// scope venues list
40    /// scope venues list --format json
41    /// ```
42    List(ListArgs),
43
44    /// Display the YAML schema for venue descriptors.
45    ///
46    /// Prints the expected structure, field descriptions, and an annotated
47    /// example you can copy to create your own venue descriptor.
48    ///
49    /// # Examples
50    ///
51    /// ```text
52    /// scope venues schema
53    /// scope venues schema --format json
54    /// ```
55    Schema(SchemaArgs),
56
57    /// Initialise the user venues directory with built-in descriptors.
58    ///
59    /// Copies all built-in venue YAML files to ~/.scope-bca/venues/
60    /// so you can customise them or use them as templates for new venues.
61    ///
62    /// # Examples
63    ///
64    /// ```text
65    /// scope venues init
66    /// scope venues init --force
67    /// ```
68    Init(InitArgs),
69
70    /// Validate a venue descriptor YAML file.
71    ///
72    /// Parses the file against the VenueDescriptor schema and reports
73    /// any errors or warnings. Exits with code 0 on success, 1 on failure.
74    ///
75    /// # Examples
76    ///
77    /// ```text
78    /// scope venues validate my-exchange.yaml
79    /// scope venues validate ~/.scope-bca/venues/custom.yaml
80    /// ```
81    Validate(ValidateArgs),
82}
83
84/// Arguments for `scope venues list`.
85#[derive(Debug, Args)]
86#[command(after_help = "\x1b[1mExamples:\x1b[0m
87  scope venues list
88  scope venues list --format json")]
89pub struct ListArgs {
90    /// Output format.
91    #[arg(short, long, default_value = "table")]
92    pub format: ListFormat,
93}
94
95/// Output format for venue listing.
96#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
97pub enum ListFormat {
98    /// Human-readable table (default).
99    #[default]
100    Table,
101    /// JSON for programmatic consumption.
102    Json,
103}
104
105/// Arguments for `scope venues schema`.
106#[derive(Debug, Args)]
107#[command(after_help = "\x1b[1mExamples:\x1b[0m
108  scope venues schema
109  scope venues schema --format json")]
110pub struct SchemaArgs {
111    /// Output format.
112    #[arg(short, long, default_value = "text")]
113    pub format: SchemaFormat,
114}
115
116/// Output format for schema display.
117#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
118pub enum SchemaFormat {
119    /// Human-readable annotated text (default).
120    #[default]
121    Text,
122    /// JSON Schema representation.
123    Json,
124}
125
126/// Arguments for `scope venues init`.
127#[derive(Debug, Args)]
128#[command(after_help = "\x1b[1mExamples:\x1b[0m
129  scope venues init
130  scope venues init --force")]
131pub struct InitArgs {
132    /// Overwrite existing files in the user venues directory.
133    #[arg(long)]
134    pub force: bool,
135}
136
137/// Arguments for `scope venues validate`.
138#[derive(Debug, Args)]
139#[command(after_help = "\x1b[1mExamples:\x1b[0m
140  scope venues validate my-exchange.yaml
141  scope venues validate ~/.scope-bca/venues/custom.yaml")]
142pub struct ValidateArgs {
143    /// Path to the YAML file to validate.
144    pub file: std::path::PathBuf,
145}
146
147/// Run the venues command.
148pub fn run(cmd: VenuesCommands, out: &Output) -> Result<()> {
149    match cmd {
150        VenuesCommands::List(args) => run_list(args, out),
151        VenuesCommands::Schema(args) => run_schema(args, out),
152        VenuesCommands::Init(args) => run_init(args, out),
153        VenuesCommands::Validate(args) => run_validate(args, out),
154    }
155}
156
157// =============================================================================
158// List
159// =============================================================================
160
161fn run_list(args: ListArgs, out: &Output) -> Result<()> {
162    let registry = VenueRegistry::load()?;
163
164    match args.format {
165        ListFormat::Table => {
166            outln!(out, "{}", section_header("Available Venues"))?;
167            for listing in MarketVenue::listings(&registry) {
168                let caps = listing.capabilities.join(", ");
169                let detail = match listing.chain {
170                    Some(chain) => format!("{caps} (DEX on {chain})"),
171                    None => caps,
172                };
173                outln!(out, "{}", kv_row(&listing.id, &detail))?;
174            }
175            outln!(out, "{}", separator())?;
176            let user_dir = VenueRegistry::user_venues_dir();
177            let user_count = count_user_venues(&user_dir);
178            let total = registry.len();
179            let built_in = total - user_count;
180            outln!(
181                out,
182                "{}",
183                kv_row(
184                    "Loaded",
185                    &format!(
186                        "{} venues ({} built-in, {} user)",
187                        total, built_in, user_count
188                    )
189                )
190            )?;
191            outln!(
192                out,
193                "{}",
194                kv_row("User dir", &user_dir.display().to_string())
195            )?;
196            outln!(out, "{}", section_footer())?;
197        }
198        ListFormat::Json => {
199            // The same list the web serves at /api/venues.
200            let venues = MarketVenue::listings(&registry);
201            let output = serde_json::json!({
202                "total": venues.len(),
203                "venues": venues,
204                "user_venues_dir": VenueRegistry::user_venues_dir().display().to_string(),
205            });
206            outln!(out, "{}", serde_json::to_string_pretty(&output).unwrap())?;
207        }
208    }
209
210    Ok(())
211}
212
213/// Count YAML files in the user venues directory.
214fn count_user_venues(dir: &std::path::Path) -> usize {
215    if !dir.exists() {
216        return 0;
217    }
218    std::fs::read_dir(dir)
219        .map(|entries| {
220            entries
221                .filter_map(|e| e.ok())
222                .filter(|e| {
223                    e.path()
224                        .extension()
225                        .map(|ext| ext == "yaml" || ext == "yml")
226                        .unwrap_or(false)
227                })
228                .count()
229        })
230        .unwrap_or(0)
231}
232
233// =============================================================================
234// Schema
235// =============================================================================
236
237fn run_schema(args: SchemaArgs, out: &Output) -> Result<()> {
238    match args.format {
239        SchemaFormat::Text => print_annotated_schema(out)?,
240        SchemaFormat::Json => print_json_schema(out)?,
241    }
242    Ok(())
243}
244
245fn print_annotated_schema(out: &Output) -> std::io::Result<()> {
246    outln!(out, "{}", section_header("Venue Descriptor Schema"))?;
247    outln!(
248        out,
249        r#"
250A venue descriptor is a YAML file that tells Scope how to talk to
251an exchange API. Place custom descriptors in:
252
253  {}
254
255Each file defines one venue with the following structure:
256
257  id: my_exchange              # Unique ID (lowercase, underscores ok)
258  name: My Exchange            # Human-readable display name
259  base_url: https://api.example.com  # API base URL
260
261  symbol:
262    template: "{{base}}{{quote}}"  # Pair format (placeholders: {{base}}, {{quote}})
263    default_quote: USDT          # Quote currency when user omits it
264    case: upper                  # "upper" or "lower" (default: upper)
265
266  error_envelope:                # Optional: errors sent with HTTP 200
267    code_key: retCode            # Status field; omit to test message_key only
268    ok_codes: ["0"]              # Status values that mean success
269    message_key: retMsg          # Error text (a string, or an array like Kraken's)
270
271  capabilities:
272    order_book:                  # Omit if the venue has no depth API
273      method: GET                # GET (default) or POST
274      path: /api/v1/depth       # URL path appended to base_url
275      params:                    # Query parameters (GET) — values can use {{pair}}, {{limit}}
276        symbol: "{{pair}}"
277        limit: "{{limit}}"
278      response_root: data        # JSON path to the relevant data (optional)
279      response:
280        asks_key: asks           # JSON key for asks array
281        bids_key: bids           # JSON key for bids array
282        level_format: positional # "positional" for [price, qty] or "object"
283        level_price_field: price # Only needed when level_format = object
284        level_size_field: size   # Only needed when level_format = object
285
286    ticker:                      # Omit if the venue has no ticker API
287      path: /api/v1/ticker
288      params:
289        symbol: "{{pair}}"
290      response:
291        last_price: lastPrice
292        high_24h: highPrice
293        low_24h: lowPrice
294        volume_24h: volume
295        quote_volume_24h: quoteVolume
296        price_change_24h: priceChange
297        price_change_pct_24h: priceChangePercent
298
299    trades:                      # Omit if the venue has no trades API
300      path: /api/v1/trades
301      params:
302        symbol: "{{pair}}"
303        limit: "{{limit}}"
304      response:
305        items_key: data          # JSON key containing the trades array (omit if root)
306        price: price             # Field for trade price
307        quantity: qty            # Field for trade quantity
308        timestamp_ms: time       # Field for trade timestamp (epoch ms)
309        side:                    # Side detection
310          field: side            # JSON field containing buy/sell indicator
311          mapping:
312            buy: buy
313            sell: sell
314
315Validate your file with:  scope venues validate <file>
316"#,
317        VenueRegistry::user_venues_dir().display()
318    )?;
319    outln!(out, "{}", section_footer())?;
320    Ok(())
321}
322
323fn print_json_schema(out: &Output) -> std::io::Result<()> {
324    use serde_json::{Map, Value};
325
326    fn str_prop(desc: &str) -> Value {
327        let mut m = Map::new();
328        m.insert("type".into(), Value::String("string".into()));
329        m.insert("description".into(), Value::String(desc.into()));
330        Value::Object(m)
331    }
332
333    fn str_type() -> Value {
334        serde_json::json!({"type": "string"})
335    }
336
337    // Build response properties
338    let mut resp_props = Map::new();
339    for key in &[
340        "asks_key",
341        "bids_key",
342        "level_format",
343        "level_price_field",
344        "level_size_field",
345        "last_price",
346        "high_24h",
347        "low_24h",
348        "volume_24h",
349        "quote_volume_24h",
350        "best_bid",
351        "best_ask",
352        "items_key",
353        "price",
354        "quantity",
355        "quote_quantity",
356        "timestamp_ms",
357        "id",
358    ] {
359        resp_props.insert((*key).into(), str_type());
360    }
361    resp_props.insert(
362        "filter".into(),
363        serde_json::json!({
364            "type": "object",
365            "properties": {"field": {"type": "string"}, "value": {"type": "string"}}
366        }),
367    );
368    resp_props.insert(
369        "side".into(),
370        serde_json::json!({
371            "type": "object",
372            "properties": {
373                "field": {"type": "string"},
374                "mapping": {"type": "object", "additionalProperties": {"type": "string"}}
375            }
376        }),
377    );
378
379    // Build endpoint def
380    let endpoint_def = serde_json::json!({
381        "type": "object",
382        "required": ["path", "response"],
383        "properties": {
384            "method": {"type": "string", "enum": ["GET", "POST"], "default": "GET"},
385            "path": {"type": "string"},
386            "params": {"type": "object", "additionalProperties": {"type": "string"}},
387            "request_body": {"description": "JSON body template for POST requests"},
388            "response_root": {"type": "string", "description": "Dot-path to navigate JSON response"},
389            "response": {"type": "object", "properties": Value::Object(resp_props)}
390        }
391    });
392
393    let endpoint_ref = serde_json::json!({"$ref": "#/$defs/endpoint"});
394
395    let schema = serde_json::json!({
396        "$schema": "https://json-schema.org/draft/2020-12/schema",
397        "title": "VenueDescriptor",
398        "description": "Schema for Scope exchange venue descriptor YAML files.",
399        "type": "object",
400        "required": ["id", "name", "base_url", "symbol", "capabilities"],
401        "properties": {
402            "id": str_prop("Unique venue identifier (e.g., 'binance')"),
403            "name": str_prop("Human-readable venue name (e.g., 'Binance Spot')"),
404            "base_url": str_prop("API base URL (e.g., 'https://api.binance.com')"),
405            "symbol": {
406                "type": "object",
407                "required": ["template", "default_quote"],
408                "properties": {
409                    "template": str_prop("Pair template with {base} and {quote} placeholders"),
410                    "default_quote": str_prop("Default quote currency (e.g., 'USDT')"),
411                    "case": {"type": "string", "enum": ["upper", "lower"], "default": "upper"}
412                }
413            },
414            "error_envelope": {
415                "type": "object",
416                "description": "How the venue reports an error inside an HTTP 200 reply",
417                "required": ["message_key"],
418                "properties": {
419                    "code_key": str_prop("Status field; a missing field means success"),
420                    "ok_codes": {"type": "array", "items": {"type": "string"}, "description": "Status values that mean success, compared as strings"},
421                    "message_key": str_prop("Field with the error message (string or array)")
422                }
423            },
424            "capabilities": {
425                "type": "object",
426                "properties": {
427                    "order_book": endpoint_ref.clone(),
428                    "ticker": endpoint_ref.clone(),
429                    "trades": endpoint_ref
430                }
431            }
432        },
433        "$defs": {
434            "endpoint": endpoint_def
435        }
436    });
437
438    outln!(out, "{}", serde_json::to_string_pretty(&schema).unwrap())?;
439    Ok(())
440}
441
442// =============================================================================
443// Init
444// =============================================================================
445
446fn run_init(args: InitArgs, out: &Output) -> Result<()> {
447    run_init_impl(args, VenueRegistry::user_venues_dir(), out)
448}
449
450/// Core init logic with explicit destination path (used by tests).
451fn run_init_impl(args: InitArgs, dest: std::path::PathBuf, out: &Output) -> Result<()> {
452    // Ensure directory exists
453    if !dest.exists() {
454        std::fs::create_dir_all(&dest).map_err(|e| {
455            let context = format!("Failed to create venues directory {}", dest.display());
456            scope::error::ScopeError::io_context(e, context)
457        })?;
458        outln!(out, "Created {}", dest.display())?;
459    }
460
461    // Get built-in venues to copy
462    let registry = VenueRegistry::load()?;
463    let mut copied = 0;
464    let mut skipped = 0;
465
466    for id in registry.list() {
467        let filename = format!("{}.yaml", id);
468        let target = dest.join(&filename);
469
470        if target.exists() && !args.force {
471            skipped += 1;
472            outln!(
473                out,
474                "  skip {} (exists, use --force to overwrite)",
475                filename
476            )?;
477            continue;
478        }
479
480        // Get the YAML content from the embedded built-in data
481        if let Some(desc) = registry.get(id) {
482            // Re-serialise the descriptor to YAML for the user's copy
483            let yaml = format!(
484                "# {name} venue descriptor\n# Auto-generated by `scope venues init`\n\n{content}",
485                name = desc.name,
486                content = serialize_descriptor_yaml(desc),
487            );
488            std::fs::write(&target, yaml).map_err(|e| {
489                scope::error::ScopeError::io_context(
490                    e,
491                    format!("Failed to write {}", target.display()),
492                )
493            })?;
494            copied += 1;
495            outln!(out, "  {}", check_pass(&filename))?;
496        }
497    }
498
499    outln!(out)?;
500    outln!(out, "{}", section_header("Venues Init"))?;
501    outln!(out, "{}", kv_row("Directory", &dest.display().to_string()))?;
502    outln!(out, "{}", kv_row("Copied", &format!("{} files", copied)))?;
503    if skipped > 0 {
504        outln!(
505            out,
506            "{}",
507            kv_row("Skipped", &format!("{} files (already exist)", skipped))
508        )?;
509    }
510    outln!(out, "{}", section_footer())?;
511
512    Ok(())
513}
514
515/// Serialize a VenueDescriptor to a readable YAML string.
516/// VenueDescriptor doesn't derive Serialize, so the YAML is built by hand.
517fn serialize_descriptor_yaml(desc: &VenueDescriptor) -> String {
518    // Build a YAML-compatible representation manually
519    let mut lines = Vec::new();
520    lines.push(format!("id: {}", desc.id));
521    lines.push(format!("name: \"{}\"", desc.name));
522    lines.push(format!("base_url: \"{}\"", desc.base_url));
523
524    lines.push("symbol:".to_string());
525    lines.push(format!("  template: \"{}\"", desc.symbol.template));
526    lines.push(format!("  default_quote: {}", desc.symbol.default_quote));
527    let case_str = match desc.symbol.case {
528        scope::market::descriptor::SymbolCase::Upper => "upper",
529        scope::market::descriptor::SymbolCase::Lower => "lower",
530    };
531    lines.push(format!("  case: {}", case_str));
532
533    lines.push("capabilities:".to_string());
534
535    if let Some(ref ep) = desc.capabilities.order_book {
536        lines.push("  order_book:".to_string());
537        append_endpoint_yaml(&mut lines, ep, "    ");
538    }
539    if let Some(ref ep) = desc.capabilities.ticker {
540        lines.push("  ticker:".to_string());
541        append_endpoint_yaml(&mut lines, ep, "    ");
542    }
543    if let Some(ref ep) = desc.capabilities.trades {
544        lines.push("  trades:".to_string());
545        append_endpoint_yaml(&mut lines, ep, "    ");
546    }
547
548    lines.join("\n")
549}
550
551fn append_endpoint_yaml(
552    lines: &mut Vec<String>,
553    ep: &scope::market::descriptor::EndpointDescriptor,
554    indent: &str,
555) {
556    let method = match ep.method {
557        scope::market::descriptor::HttpMethod::GET => "GET",
558        scope::market::descriptor::HttpMethod::POST => "POST",
559    };
560    if method != "GET" {
561        lines.push(format!("{indent}method: {method}"));
562    }
563    lines.push(format!("{indent}path: \"{}\"", ep.path));
564
565    if !ep.params.is_empty() {
566        lines.push(format!("{indent}params:"));
567        let mut params: Vec<_> = ep.params.iter().collect();
568        params.sort_by_key(|(k, _)| (*k).clone());
569        for (k, v) in params {
570            lines.push(format!("{indent}  {k}: \"{v}\""));
571        }
572    }
573
574    if let Some(ref body) = ep.request_body {
575        lines.push(format!(
576            "{indent}request_body: {}",
577            serde_json::to_string(body).unwrap_or_default()
578        ));
579    }
580
581    if let Some(ref root) = ep.response_root {
582        lines.push(format!("{indent}response_root: \"{}\"", root));
583    }
584
585    lines.push(format!("{indent}response:"));
586    let r = &ep.response;
587    let resp_indent = format!("{indent}  ");
588    if let Some(ref v) = r.asks_key {
589        lines.push(format!("{resp_indent}asks_key: {v}"));
590    }
591    if let Some(ref v) = r.bids_key {
592        lines.push(format!("{resp_indent}bids_key: {v}"));
593    }
594    if let Some(ref v) = r.level_format {
595        lines.push(format!("{resp_indent}level_format: {v}"));
596    }
597    if let Some(ref v) = r.level_price_field {
598        lines.push(format!("{resp_indent}level_price_field: {v}"));
599    }
600    if let Some(ref v) = r.level_size_field {
601        lines.push(format!("{resp_indent}level_size_field: {v}"));
602    }
603    if let Some(ref v) = r.last_price {
604        lines.push(format!("{resp_indent}last_price: {v}"));
605    }
606    if let Some(ref v) = r.high_24h {
607        lines.push(format!("{resp_indent}high_24h: {v}"));
608    }
609    if let Some(ref v) = r.low_24h {
610        lines.push(format!("{resp_indent}low_24h: {v}"));
611    }
612    if let Some(ref v) = r.volume_24h {
613        lines.push(format!("{resp_indent}volume_24h: {v}"));
614    }
615    if let Some(ref v) = r.quote_volume_24h {
616        lines.push(format!("{resp_indent}quote_volume_24h: {v}"));
617    }
618    if let Some(ref v) = r.best_bid {
619        lines.push(format!("{resp_indent}best_bid: {v}"));
620    }
621    if let Some(ref v) = r.best_ask {
622        lines.push(format!("{resp_indent}best_ask: {v}"));
623    }
624    if let Some(ref v) = r.items_key {
625        lines.push(format!("{resp_indent}items_key: {v}"));
626    }
627    if let Some(ref f) = r.filter {
628        lines.push(format!("{resp_indent}filter:"));
629        lines.push(format!("{resp_indent}  field: \"{}\"", f.field));
630        lines.push(format!("{resp_indent}  value: \"{}\"", f.value));
631    }
632    if let Some(ref v) = r.price {
633        lines.push(format!("{resp_indent}price: {v}"));
634    }
635    if let Some(ref v) = r.quantity {
636        lines.push(format!("{resp_indent}quantity: {v}"));
637    }
638    if let Some(ref v) = r.quote_quantity {
639        lines.push(format!("{resp_indent}quote_quantity: {v}"));
640    }
641    if let Some(ref v) = r.timestamp_ms {
642        lines.push(format!("{resp_indent}timestamp_ms: {v}"));
643    }
644    if let Some(ref v) = r.id {
645        lines.push(format!("{resp_indent}id: {v}"));
646    }
647    if let Some(ref sm) = r.side {
648        lines.push(format!("{resp_indent}side:"));
649        lines.push(format!("{resp_indent}  field: \"{}\"", sm.field));
650        if !sm.mapping.is_empty() {
651            lines.push(format!("{resp_indent}  mapping:"));
652            let mut entries: Vec<_> = sm.mapping.iter().collect();
653            entries.sort_by_key(|(k, _)| (*k).clone());
654            for (k, v) in entries {
655                lines.push(format!("{resp_indent}    \"{k}\": \"{v}\""));
656            }
657        }
658    }
659}
660
661// =============================================================================
662// Validate
663// =============================================================================
664
665fn run_validate(args: ValidateArgs, out: &Output) -> Result<()> {
666    let path = &args.file;
667
668    if !path.exists() {
669        outln!(
670            out,
671            "{}",
672            check_fail(&format!("File not found: {}", path.display()))
673        )?;
674        return Err(scope::error::ScopeError::Chain(format!(
675            "File not found: {}",
676            path.display()
677        )));
678    }
679
680    let content = std::fs::read_to_string(path).map_err(|e| {
681        scope::error::ScopeError::io_context(e, format!("Failed to read {}", path.display()))
682    })?;
683
684    outln!(out, "{}", section_header("Venue Validation"))?;
685    outln!(out, "{}", kv_row("File", &path.display().to_string()))?;
686    outln!(out, "{}", separator())?;
687
688    match VenueRegistry::validate_yaml(&content) {
689        Ok(desc) => {
690            outln!(out, "{}", check_pass("Valid YAML syntax"))?;
691            outln!(out, "{}", check_pass(&format!("Venue ID: {}", desc.id)))?;
692            outln!(out, "{}", check_pass(&format!("Name: {}", desc.name)))?;
693            outln!(
694                out,
695                "{}",
696                check_pass(&format!("Base URL: {}", desc.base_url))
697            )?;
698
699            // Check capabilities
700            let caps = desc.capability_names();
701            if caps.is_empty() {
702                outln!(
703                    out,
704                    "{}",
705                    check_fail(
706                        "No capabilities defined (need at least one of: order_book, ticker, trades)"
707                    )
708                )?;
709            } else {
710                for cap in &caps {
711                    outln!(out, "{}", check_pass(&format!("Capability: {}", cap)))?;
712                }
713            }
714
715            // Validate symbol template
716            if desc.symbol.template.contains("{base}") {
717                outln!(
718                    out,
719                    "{}",
720                    check_pass("Symbol template contains {base} placeholder")
721                )?;
722            } else {
723                outln!(
724                    out,
725                    "{}",
726                    check_fail("Symbol template missing {base} placeholder")
727                )?;
728            }
729
730            outln!(out, "{}", separator())?;
731            if caps.is_empty() {
732                outln!(out, "{}", check_fail("Validation completed with warnings"))?;
733            } else {
734                outln!(out, "{}", check_pass("Validation passed"))?;
735            }
736            outln!(out, "{}", section_footer())?;
737            Ok(())
738        }
739        Err(e) => {
740            outln!(out, "{}", check_fail("Invalid YAML"))?;
741            outln!(out, "{}", check_fail(&format!("Error: {}", e)))?;
742            outln!(out, "{}", separator())?;
743            outln!(
744                out,
745                "{}",
746                kv_row(
747                    "Hint",
748                    "Run `scope venues schema` to see the expected format"
749                )
750            )?;
751            outln!(out, "{}", section_footer())?;
752            Err(e)
753        }
754    }
755}
756
757// =============================================================================
758// Tests
759// =============================================================================
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    fn quiet() -> Output {
766        Output::capture().0
767    }
768
769    #[test]
770    fn test_list_format_default() {
771        let fmt = ListFormat::default();
772        assert!(matches!(fmt, ListFormat::Table));
773    }
774
775    #[test]
776    fn test_schema_format_default() {
777        let fmt = SchemaFormat::default();
778        assert!(matches!(fmt, SchemaFormat::Text));
779    }
780
781    #[test]
782    fn test_run_list_table() {
783        let args = ListArgs {
784            format: ListFormat::Table,
785        };
786        let result = run_list(args, &quiet());
787        assert!(result.is_ok());
788    }
789
790    #[test]
791    fn test_run_list_json() {
792        let args = ListArgs {
793            format: ListFormat::Json,
794        };
795        let (o, cap) = Output::capture();
796        run_list(args, &o).unwrap();
797        // Scripts parse this: it must be one JSON document on the data channel.
798        let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
799        assert!(v["venues"].as_array().is_some_and(|a| !a.is_empty()));
800        assert_eq!(
801            v["total"].as_u64().unwrap() as usize,
802            v["venues"].as_array().unwrap().len()
803        );
804        // DEX venues are listed too, marked by kind.
805        assert!(
806            v["venues"]
807                .as_array()
808                .unwrap()
809                .iter()
810                .any(|venue| venue["kind"] == "dex")
811        );
812    }
813
814    #[test]
815    fn test_run_schema_text() {
816        let args = SchemaArgs {
817            format: SchemaFormat::Text,
818        };
819        let result = run_schema(args, &quiet());
820        assert!(result.is_ok());
821    }
822
823    #[test]
824    fn test_run_schema_json() {
825        let args = SchemaArgs {
826            format: SchemaFormat::Json,
827        };
828        let (o, cap) = Output::capture();
829        run_schema(args, &o).unwrap();
830        let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
831        assert!(v.is_object());
832    }
833
834    #[test]
835    fn test_validate_missing_file() {
836        let args = ValidateArgs {
837            file: std::path::PathBuf::from("/tmp/nonexistent_venue_test.yaml"),
838        };
839        let result = run_validate(args, &quiet());
840        assert!(result.is_err());
841    }
842
843    #[test]
844    fn test_validate_valid_file() {
845        let yaml = r#"
846id: test_venue
847name: Test Exchange
848base_url: https://api.test.com
849symbol:
850  template: "{base}{quote}"
851  default_quote: USDT
852capabilities:
853  order_book:
854    path: /depth
855    params:
856      symbol: "{pair}"
857    response:
858      asks_key: asks
859      bids_key: bids
860      level_format: positional
861"#;
862        let dir = tempfile::tempdir().unwrap();
863        let path = dir.path().join("test.yaml");
864        std::fs::write(&path, yaml).unwrap();
865
866        let args = ValidateArgs { file: path };
867        let (o, cap) = Output::capture();
868        run_validate(args, &o).unwrap();
869        assert!(cap.out().contains("Venue ID: test_venue"));
870        assert!(cap.out().contains("Validation passed"));
871    }
872
873    #[test]
874    fn test_validate_invalid_file() {
875        let yaml = "this is not valid yaml: [";
876        let dir = tempfile::tempdir().unwrap();
877        let path = dir.path().join("bad.yaml");
878        std::fs::write(&path, yaml).unwrap();
879
880        let args = ValidateArgs { file: path };
881        let result = run_validate(args, &quiet());
882        assert!(result.is_err());
883    }
884
885    #[test]
886    fn test_count_user_venues_nonexistent() {
887        let count = count_user_venues(std::path::Path::new("/tmp/nonexistent_dir_test"));
888        assert_eq!(count, 0);
889    }
890
891    #[test]
892    fn test_count_user_venues_with_files() {
893        let dir = tempfile::tempdir().unwrap();
894        std::fs::write(dir.path().join("a.yaml"), "").unwrap();
895        std::fs::write(dir.path().join("b.yml"), "").unwrap();
896        std::fs::write(dir.path().join("c.txt"), "").unwrap();
897        assert_eq!(count_user_venues(dir.path()), 2);
898    }
899
900    #[test]
901    fn test_serialize_descriptor_yaml_roundtrip() {
902        let yaml = r#"
903id: roundtrip_test
904name: Roundtrip Exchange
905base_url: https://api.roundtrip.com
906symbol:
907  template: "{base}_{quote}"
908  default_quote: USDT
909  case: lower
910capabilities:
911  order_book:
912    path: /api/depth
913    params:
914      symbol: "{pair}"
915    response:
916      asks_key: asks
917      bids_key: bids
918      level_format: positional
919"#;
920        let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
921        let serialized = serialize_descriptor_yaml(&desc);
922        assert!(serialized.contains("roundtrip_test"));
923        assert!(serialized.contains("Roundtrip Exchange"));
924        assert!(serialized.contains("/api/depth"));
925        assert!(serialized.contains("case: lower"));
926    }
927
928    #[test]
929    fn test_run_init_to_temp_dir() {
930        let dir = tempfile::tempdir().unwrap();
931        let dest = dir.path().to_path_buf();
932        let args = InitArgs { force: true };
933        let result = run_init_impl(args, dest.clone(), &quiet());
934        assert!(result.is_ok());
935
936        // Verify venue files were created (registry has 11 built-in venues)
937        let registry = VenueRegistry::load().unwrap();
938        for id in registry.list() {
939            let filename = format!("{}.yaml", id);
940            let target = dest.join(&filename);
941            assert!(target.exists(), "Expected {} to exist", filename);
942            let content = std::fs::read_to_string(&target).unwrap();
943            assert!(content.contains(&format!("id: {}", id)));
944        }
945    }
946
947    #[test]
948    fn test_serialize_full_descriptor() {
949        let yaml = r#"
950id: full_caps
951name: Full Capabilities Exchange
952base_url: https://api.full.com
953symbol:
954  template: "{base}{quote}"
955  default_quote: USDT
956capabilities:
957  order_book:
958    path: /api/depth
959    params:
960      symbol: "{pair}"
961    response:
962      asks_key: asks
963      bids_key: bids
964      level_format: positional
965  ticker:
966    path: /api/ticker
967    params:
968      symbol: "{pair}"
969    response:
970      last_price: lastPrice
971      high_24h: high
972      low_24h: low
973  trades:
974    path: /api/trades
975    params:
976      symbol: "{pair}"
977      limit: "{limit}"
978    response:
979      items_key: data
980      price: price
981      quantity: qty
982      timestamp_ms: time
983      side:
984        field: side
985        mapping:
986          buy: buy
987          sell: sell
988"#;
989        let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
990        let serialized = serialize_descriptor_yaml(&desc);
991        assert!(serialized.contains("order_book:"));
992        assert!(serialized.contains("ticker:"));
993        assert!(serialized.contains("trades:"));
994        assert!(serialized.contains("asks_key"));
995        assert!(serialized.contains("last_price"));
996        assert!(serialized.contains("items_key"));
997    }
998
999    #[test]
1000    fn test_serialize_descriptor_with_post_and_request_body() {
1001        let yaml = r#"
1002id: post_venue
1003name: POST Exchange
1004base_url: https://api.post.com
1005symbol:
1006  template: "{base}{quote}"
1007  default_quote: USDT
1008capabilities:
1009  order_book:
1010    method: POST
1011    path: /api/depth
1012    request_body: {"symbol":"{{pair}}"}
1013    response_root: result
1014    params: {}
1015    response:
1016      asks_key: asks
1017      bids_key: bids
1018      level_format: positional
1019"#;
1020        let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
1021        let serialized = serialize_descriptor_yaml(&desc);
1022        assert!(serialized.contains("method: POST"));
1023        assert!(serialized.contains("response_root"));
1024        assert!(serialized.contains("request_body"));
1025    }
1026
1027    #[test]
1028    fn test_serialize_descriptor_with_filter_and_side() {
1029        let yaml = r#"
1030id: filter_venue
1031name: Filter Exchange
1032base_url: https://api.filter.com
1033symbol:
1034  template: "{base}{quote}"
1035  default_quote: USDT
1036capabilities:
1037  trades:
1038    path: /api/trades
1039    params:
1040      symbol: "{pair}"
1041    response:
1042      items_key: trades
1043      filter:
1044        field: symbol
1045        value: "{pair}"
1046      side:
1047        field: side
1048        mapping:
1049          buy: B
1050          sell: S
1051"#;
1052        let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
1053        let serialized = serialize_descriptor_yaml(&desc);
1054        assert!(serialized.contains("filter:"));
1055        assert!(serialized.contains("field:"));
1056        assert!(serialized.contains("side:"));
1057        assert!(serialized.contains("mapping:"));
1058    }
1059
1060    #[test]
1061    fn test_run_init_skips_existing() {
1062        let dir = tempfile::tempdir().unwrap();
1063        let dest = dir.path().to_path_buf();
1064
1065        // Write an existing binance.yaml before init
1066        let existing_path = dest.join("binance.yaml");
1067        std::fs::create_dir_all(&dest).unwrap();
1068        let original_content = "id: binance\n# pre-existing file\n";
1069        std::fs::write(&existing_path, original_content).unwrap();
1070
1071        // Run init with force=false
1072        let args = InitArgs { force: false };
1073        let result = run_init_impl(args, dest.clone(), &quiet());
1074        assert!(result.is_ok());
1075
1076        // Verify binance.yaml was NOT overwritten (content unchanged)
1077        let content = std::fs::read_to_string(&existing_path).unwrap();
1078        assert_eq!(
1079            content, original_content,
1080            "Existing file should not be overwritten when force=false"
1081        );
1082    }
1083
1084    #[test]
1085    fn test_run_init_creates_directory_when_missing() {
1086        let dir = tempfile::tempdir().unwrap();
1087        let dest = dir.path().join("nested").join("venues");
1088        // Ensure parent doesn't exist
1089        assert!(!dest.exists());
1090        let args = InitArgs { force: true };
1091        let result = run_init_impl(args, dest.clone(), &quiet());
1092        assert!(result.is_ok());
1093        assert!(dest.exists());
1094        assert!(dest.is_dir());
1095    }
1096
1097    #[test]
1098    fn test_validate_file_template_missing_base() {
1099        let yaml = r#"
1100id: bad_template
1101name: Bad Template Exchange
1102base_url: https://api.test.com
1103symbol:
1104  template: "nobase{quote}"
1105  default_quote: USDT
1106capabilities:
1107  order_book:
1108    path: /depth
1109    params:
1110      symbol: "{pair}"
1111    response:
1112      asks_key: asks
1113      bids_key: bids
1114      level_format: positional
1115"#;
1116        let dir = tempfile::tempdir().unwrap();
1117        let path = dir.path().join("bad_template.yaml");
1118        std::fs::write(&path, yaml).unwrap();
1119
1120        let args = ValidateArgs { file: path };
1121        let result = run_validate(args, &quiet());
1122        assert!(result.is_ok()); // YAML parses; check_fail is printed for missing {base}
1123    }
1124
1125    #[test]
1126    fn test_validate_file_empty_capabilities() {
1127        let yaml = r#"
1128id: no_caps
1129name: No Capabilities
1130base_url: https://api.test.com
1131symbol:
1132  template: "{base}{quote}"
1133  default_quote: USDT
1134capabilities: {}
1135"#;
1136        let dir = tempfile::tempdir().unwrap();
1137        let path = dir.path().join("no_caps.yaml");
1138        std::fs::write(&path, yaml).unwrap();
1139
1140        let args = ValidateArgs { file: path };
1141        let result = run_validate(args, &quiet());
1142        assert!(result.is_ok());
1143    }
1144
1145    #[test]
1146    fn test_run_venues_command_routes_to_subcommands() {
1147        let result = run(
1148            VenuesCommands::List(ListArgs {
1149                format: ListFormat::Table,
1150            }),
1151            &quiet(),
1152        );
1153        assert!(result.is_ok());
1154        let result = run(
1155            VenuesCommands::Schema(SchemaArgs {
1156                format: SchemaFormat::Text,
1157            }),
1158            &quiet(),
1159        );
1160        assert!(result.is_ok());
1161    }
1162}