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 ~/.config/scope/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 ~/.config/scope/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 ~/.config/scope/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/// We use serde_yaml for this since VenueDescriptor doesn't derive Serialize;
517/// instead, we manually build a representation.
518fn serialize_descriptor_yaml(desc: &VenueDescriptor) -> String {
519    // Build a YAML-compatible representation manually
520    let mut lines = Vec::new();
521    lines.push(format!("id: {}", desc.id));
522    lines.push(format!("name: \"{}\"", desc.name));
523    lines.push(format!("base_url: \"{}\"", desc.base_url));
524
525    lines.push("symbol:".to_string());
526    lines.push(format!("  template: \"{}\"", desc.symbol.template));
527    lines.push(format!("  default_quote: {}", desc.symbol.default_quote));
528    let case_str = match desc.symbol.case {
529        scope::market::descriptor::SymbolCase::Upper => "upper",
530        scope::market::descriptor::SymbolCase::Lower => "lower",
531    };
532    lines.push(format!("  case: {}", case_str));
533
534    lines.push("capabilities:".to_string());
535
536    if let Some(ref ep) = desc.capabilities.order_book {
537        lines.push("  order_book:".to_string());
538        append_endpoint_yaml(&mut lines, ep, "    ");
539    }
540    if let Some(ref ep) = desc.capabilities.ticker {
541        lines.push("  ticker:".to_string());
542        append_endpoint_yaml(&mut lines, ep, "    ");
543    }
544    if let Some(ref ep) = desc.capabilities.trades {
545        lines.push("  trades:".to_string());
546        append_endpoint_yaml(&mut lines, ep, "    ");
547    }
548
549    lines.join("\n")
550}
551
552fn append_endpoint_yaml(
553    lines: &mut Vec<String>,
554    ep: &scope::market::descriptor::EndpointDescriptor,
555    indent: &str,
556) {
557    let method = match ep.method {
558        scope::market::descriptor::HttpMethod::GET => "GET",
559        scope::market::descriptor::HttpMethod::POST => "POST",
560    };
561    if method != "GET" {
562        lines.push(format!("{indent}method: {method}"));
563    }
564    lines.push(format!("{indent}path: \"{}\"", ep.path));
565
566    if !ep.params.is_empty() {
567        lines.push(format!("{indent}params:"));
568        let mut params: Vec<_> = ep.params.iter().collect();
569        params.sort_by_key(|(k, _)| (*k).clone());
570        for (k, v) in params {
571            lines.push(format!("{indent}  {k}: \"{v}\""));
572        }
573    }
574
575    if let Some(ref body) = ep.request_body {
576        lines.push(format!(
577            "{indent}request_body: {}",
578            serde_json::to_string(body).unwrap_or_default()
579        ));
580    }
581
582    if let Some(ref root) = ep.response_root {
583        lines.push(format!("{indent}response_root: \"{}\"", root));
584    }
585
586    lines.push(format!("{indent}response:"));
587    let r = &ep.response;
588    let resp_indent = format!("{indent}  ");
589    if let Some(ref v) = r.asks_key {
590        lines.push(format!("{resp_indent}asks_key: {v}"));
591    }
592    if let Some(ref v) = r.bids_key {
593        lines.push(format!("{resp_indent}bids_key: {v}"));
594    }
595    if let Some(ref v) = r.level_format {
596        lines.push(format!("{resp_indent}level_format: {v}"));
597    }
598    if let Some(ref v) = r.level_price_field {
599        lines.push(format!("{resp_indent}level_price_field: {v}"));
600    }
601    if let Some(ref v) = r.level_size_field {
602        lines.push(format!("{resp_indent}level_size_field: {v}"));
603    }
604    if let Some(ref v) = r.last_price {
605        lines.push(format!("{resp_indent}last_price: {v}"));
606    }
607    if let Some(ref v) = r.high_24h {
608        lines.push(format!("{resp_indent}high_24h: {v}"));
609    }
610    if let Some(ref v) = r.low_24h {
611        lines.push(format!("{resp_indent}low_24h: {v}"));
612    }
613    if let Some(ref v) = r.volume_24h {
614        lines.push(format!("{resp_indent}volume_24h: {v}"));
615    }
616    if let Some(ref v) = r.quote_volume_24h {
617        lines.push(format!("{resp_indent}quote_volume_24h: {v}"));
618    }
619    if let Some(ref v) = r.best_bid {
620        lines.push(format!("{resp_indent}best_bid: {v}"));
621    }
622    if let Some(ref v) = r.best_ask {
623        lines.push(format!("{resp_indent}best_ask: {v}"));
624    }
625    if let Some(ref v) = r.items_key {
626        lines.push(format!("{resp_indent}items_key: {v}"));
627    }
628    if let Some(ref f) = r.filter {
629        lines.push(format!("{resp_indent}filter:"));
630        lines.push(format!("{resp_indent}  field: \"{}\"", f.field));
631        lines.push(format!("{resp_indent}  value: \"{}\"", f.value));
632    }
633    if let Some(ref v) = r.price {
634        lines.push(format!("{resp_indent}price: {v}"));
635    }
636    if let Some(ref v) = r.quantity {
637        lines.push(format!("{resp_indent}quantity: {v}"));
638    }
639    if let Some(ref v) = r.quote_quantity {
640        lines.push(format!("{resp_indent}quote_quantity: {v}"));
641    }
642    if let Some(ref v) = r.timestamp_ms {
643        lines.push(format!("{resp_indent}timestamp_ms: {v}"));
644    }
645    if let Some(ref v) = r.id {
646        lines.push(format!("{resp_indent}id: {v}"));
647    }
648    if let Some(ref sm) = r.side {
649        lines.push(format!("{resp_indent}side:"));
650        lines.push(format!("{resp_indent}  field: \"{}\"", sm.field));
651        if !sm.mapping.is_empty() {
652            lines.push(format!("{resp_indent}  mapping:"));
653            let mut entries: Vec<_> = sm.mapping.iter().collect();
654            entries.sort_by_key(|(k, _)| (*k).clone());
655            for (k, v) in entries {
656                lines.push(format!("{resp_indent}    \"{k}\": \"{v}\""));
657            }
658        }
659    }
660}
661
662// =============================================================================
663// Validate
664// =============================================================================
665
666fn run_validate(args: ValidateArgs, out: &Output) -> Result<()> {
667    let path = &args.file;
668
669    if !path.exists() {
670        outln!(
671            out,
672            "{}",
673            check_fail(&format!("File not found: {}", path.display()))
674        )?;
675        return Err(scope::error::ScopeError::Chain(format!(
676            "File not found: {}",
677            path.display()
678        )));
679    }
680
681    let content = std::fs::read_to_string(path).map_err(|e| {
682        scope::error::ScopeError::io_context(e, format!("Failed to read {}", path.display()))
683    })?;
684
685    outln!(out, "{}", section_header("Venue Validation"))?;
686    outln!(out, "{}", kv_row("File", &path.display().to_string()))?;
687    outln!(out, "{}", separator())?;
688
689    match VenueRegistry::validate_yaml(&content) {
690        Ok(desc) => {
691            outln!(out, "{}", check_pass("Valid YAML syntax"))?;
692            outln!(out, "{}", check_pass(&format!("Venue ID: {}", desc.id)))?;
693            outln!(out, "{}", check_pass(&format!("Name: {}", desc.name)))?;
694            outln!(
695                out,
696                "{}",
697                check_pass(&format!("Base URL: {}", desc.base_url))
698            )?;
699
700            // Check capabilities
701            let caps = desc.capability_names();
702            if caps.is_empty() {
703                outln!(
704                    out,
705                    "{}",
706                    check_fail(
707                        "No capabilities defined (need at least one of: order_book, ticker, trades)"
708                    )
709                )?;
710            } else {
711                for cap in &caps {
712                    outln!(out, "{}", check_pass(&format!("Capability: {}", cap)))?;
713                }
714            }
715
716            // Validate symbol template
717            if desc.symbol.template.contains("{base}") {
718                outln!(
719                    out,
720                    "{}",
721                    check_pass("Symbol template contains {base} placeholder")
722                )?;
723            } else {
724                outln!(
725                    out,
726                    "{}",
727                    check_fail("Symbol template missing {base} placeholder")
728                )?;
729            }
730
731            outln!(out, "{}", separator())?;
732            if caps.is_empty() {
733                outln!(out, "{}", check_fail("Validation completed with warnings"))?;
734            } else {
735                outln!(out, "{}", check_pass("Validation passed"))?;
736            }
737            outln!(out, "{}", section_footer())?;
738            Ok(())
739        }
740        Err(e) => {
741            outln!(out, "{}", check_fail("Invalid YAML"))?;
742            outln!(out, "{}", check_fail(&format!("Error: {}", e)))?;
743            outln!(out, "{}", separator())?;
744            outln!(
745                out,
746                "{}",
747                kv_row(
748                    "Hint",
749                    "Run `scope venues schema` to see the expected format"
750                )
751            )?;
752            outln!(out, "{}", section_footer())?;
753            Err(e)
754        }
755    }
756}
757
758// =============================================================================
759// Tests
760// =============================================================================
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765
766    fn quiet() -> Output {
767        Output::capture().0
768    }
769
770    #[test]
771    fn test_list_format_default() {
772        let fmt = ListFormat::default();
773        assert!(matches!(fmt, ListFormat::Table));
774    }
775
776    #[test]
777    fn test_schema_format_default() {
778        let fmt = SchemaFormat::default();
779        assert!(matches!(fmt, SchemaFormat::Text));
780    }
781
782    #[test]
783    fn test_run_list_table() {
784        let args = ListArgs {
785            format: ListFormat::Table,
786        };
787        let result = run_list(args, &quiet());
788        assert!(result.is_ok());
789    }
790
791    #[test]
792    fn test_run_list_json() {
793        let args = ListArgs {
794            format: ListFormat::Json,
795        };
796        let (o, cap) = Output::capture();
797        run_list(args, &o).unwrap();
798        // Scripts parse this: it must be one JSON document on the data channel.
799        let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
800        assert!(v["venues"].as_array().is_some_and(|a| !a.is_empty()));
801        assert_eq!(
802            v["total"].as_u64().unwrap() as usize,
803            v["venues"].as_array().unwrap().len()
804        );
805        // DEX venues are listed too, marked by kind.
806        assert!(
807            v["venues"]
808                .as_array()
809                .unwrap()
810                .iter()
811                .any(|venue| venue["kind"] == "dex")
812        );
813    }
814
815    #[test]
816    fn test_run_schema_text() {
817        let args = SchemaArgs {
818            format: SchemaFormat::Text,
819        };
820        let result = run_schema(args, &quiet());
821        assert!(result.is_ok());
822    }
823
824    #[test]
825    fn test_run_schema_json() {
826        let args = SchemaArgs {
827            format: SchemaFormat::Json,
828        };
829        let (o, cap) = Output::capture();
830        run_schema(args, &o).unwrap();
831        let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
832        assert!(v.is_object());
833    }
834
835    #[test]
836    fn test_validate_missing_file() {
837        let args = ValidateArgs {
838            file: std::path::PathBuf::from("/tmp/nonexistent_venue_test.yaml"),
839        };
840        let result = run_validate(args, &quiet());
841        assert!(result.is_err());
842    }
843
844    #[test]
845    fn test_validate_valid_file() {
846        let yaml = r#"
847id: test_venue
848name: Test Exchange
849base_url: https://api.test.com
850symbol:
851  template: "{base}{quote}"
852  default_quote: USDT
853capabilities:
854  order_book:
855    path: /depth
856    params:
857      symbol: "{pair}"
858    response:
859      asks_key: asks
860      bids_key: bids
861      level_format: positional
862"#;
863        let dir = tempfile::tempdir().unwrap();
864        let path = dir.path().join("test.yaml");
865        std::fs::write(&path, yaml).unwrap();
866
867        let args = ValidateArgs { file: path };
868        let (o, cap) = Output::capture();
869        run_validate(args, &o).unwrap();
870        assert!(cap.out().contains("Venue ID: test_venue"));
871        assert!(cap.out().contains("Validation passed"));
872    }
873
874    #[test]
875    fn test_validate_invalid_file() {
876        let yaml = "this is not valid yaml: [";
877        let dir = tempfile::tempdir().unwrap();
878        let path = dir.path().join("bad.yaml");
879        std::fs::write(&path, yaml).unwrap();
880
881        let args = ValidateArgs { file: path };
882        let result = run_validate(args, &quiet());
883        assert!(result.is_err());
884    }
885
886    #[test]
887    fn test_count_user_venues_nonexistent() {
888        let count = count_user_venues(std::path::Path::new("/tmp/nonexistent_dir_test"));
889        assert_eq!(count, 0);
890    }
891
892    #[test]
893    fn test_count_user_venues_with_files() {
894        let dir = tempfile::tempdir().unwrap();
895        std::fs::write(dir.path().join("a.yaml"), "").unwrap();
896        std::fs::write(dir.path().join("b.yml"), "").unwrap();
897        std::fs::write(dir.path().join("c.txt"), "").unwrap();
898        assert_eq!(count_user_venues(dir.path()), 2);
899    }
900
901    #[test]
902    fn test_serialize_descriptor_yaml_roundtrip() {
903        let yaml = r#"
904id: roundtrip_test
905name: Roundtrip Exchange
906base_url: https://api.roundtrip.com
907symbol:
908  template: "{base}_{quote}"
909  default_quote: USDT
910  case: lower
911capabilities:
912  order_book:
913    path: /api/depth
914    params:
915      symbol: "{pair}"
916    response:
917      asks_key: asks
918      bids_key: bids
919      level_format: positional
920"#;
921        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
922        let serialized = serialize_descriptor_yaml(&desc);
923        assert!(serialized.contains("roundtrip_test"));
924        assert!(serialized.contains("Roundtrip Exchange"));
925        assert!(serialized.contains("/api/depth"));
926        assert!(serialized.contains("case: lower"));
927    }
928
929    #[test]
930    fn test_run_init_to_temp_dir() {
931        let dir = tempfile::tempdir().unwrap();
932        let dest = dir.path().to_path_buf();
933        let args = InitArgs { force: true };
934        let result = run_init_impl(args, dest.clone(), &quiet());
935        assert!(result.is_ok());
936
937        // Verify venue files were created (registry has 11 built-in venues)
938        let registry = VenueRegistry::load().unwrap();
939        for id in registry.list() {
940            let filename = format!("{}.yaml", id);
941            let target = dest.join(&filename);
942            assert!(target.exists(), "Expected {} to exist", filename);
943            let content = std::fs::read_to_string(&target).unwrap();
944            assert!(content.contains(&format!("id: {}", id)));
945        }
946    }
947
948    #[test]
949    fn test_serialize_full_descriptor() {
950        let yaml = r#"
951id: full_caps
952name: Full Capabilities Exchange
953base_url: https://api.full.com
954symbol:
955  template: "{base}{quote}"
956  default_quote: USDT
957capabilities:
958  order_book:
959    path: /api/depth
960    params:
961      symbol: "{pair}"
962    response:
963      asks_key: asks
964      bids_key: bids
965      level_format: positional
966  ticker:
967    path: /api/ticker
968    params:
969      symbol: "{pair}"
970    response:
971      last_price: lastPrice
972      high_24h: high
973      low_24h: low
974  trades:
975    path: /api/trades
976    params:
977      symbol: "{pair}"
978      limit: "{limit}"
979    response:
980      items_key: data
981      price: price
982      quantity: qty
983      timestamp_ms: time
984      side:
985        field: side
986        mapping:
987          buy: buy
988          sell: sell
989"#;
990        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
991        let serialized = serialize_descriptor_yaml(&desc);
992        assert!(serialized.contains("order_book:"));
993        assert!(serialized.contains("ticker:"));
994        assert!(serialized.contains("trades:"));
995        assert!(serialized.contains("asks_key"));
996        assert!(serialized.contains("last_price"));
997        assert!(serialized.contains("items_key"));
998    }
999
1000    #[test]
1001    fn test_serialize_descriptor_with_post_and_request_body() {
1002        let yaml = r#"
1003id: post_venue
1004name: POST Exchange
1005base_url: https://api.post.com
1006symbol:
1007  template: "{base}{quote}"
1008  default_quote: USDT
1009capabilities:
1010  order_book:
1011    method: POST
1012    path: /api/depth
1013    request_body: {"symbol":"{{pair}}"}
1014    response_root: result
1015    params: {}
1016    response:
1017      asks_key: asks
1018      bids_key: bids
1019      level_format: positional
1020"#;
1021        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
1022        let serialized = serialize_descriptor_yaml(&desc);
1023        assert!(serialized.contains("method: POST"));
1024        assert!(serialized.contains("response_root"));
1025        assert!(serialized.contains("request_body"));
1026    }
1027
1028    #[test]
1029    fn test_serialize_descriptor_with_filter_and_side() {
1030        let yaml = r#"
1031id: filter_venue
1032name: Filter Exchange
1033base_url: https://api.filter.com
1034symbol:
1035  template: "{base}{quote}"
1036  default_quote: USDT
1037capabilities:
1038  trades:
1039    path: /api/trades
1040    params:
1041      symbol: "{pair}"
1042    response:
1043      items_key: trades
1044      filter:
1045        field: symbol
1046        value: "{pair}"
1047      side:
1048        field: side
1049        mapping:
1050          buy: B
1051          sell: S
1052"#;
1053        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
1054        let serialized = serialize_descriptor_yaml(&desc);
1055        assert!(serialized.contains("filter:"));
1056        assert!(serialized.contains("field:"));
1057        assert!(serialized.contains("side:"));
1058        assert!(serialized.contains("mapping:"));
1059    }
1060
1061    #[test]
1062    fn test_run_init_skips_existing() {
1063        let dir = tempfile::tempdir().unwrap();
1064        let dest = dir.path().to_path_buf();
1065
1066        // Write an existing binance.yaml before init
1067        let existing_path = dest.join("binance.yaml");
1068        std::fs::create_dir_all(&dest).unwrap();
1069        let original_content = "id: binance\n# pre-existing file\n";
1070        std::fs::write(&existing_path, original_content).unwrap();
1071
1072        // Run init with force=false
1073        let args = InitArgs { force: false };
1074        let result = run_init_impl(args, dest.clone(), &quiet());
1075        assert!(result.is_ok());
1076
1077        // Verify binance.yaml was NOT overwritten (content unchanged)
1078        let content = std::fs::read_to_string(&existing_path).unwrap();
1079        assert_eq!(
1080            content, original_content,
1081            "Existing file should not be overwritten when force=false"
1082        );
1083    }
1084
1085    #[test]
1086    fn test_run_init_creates_directory_when_missing() {
1087        let dir = tempfile::tempdir().unwrap();
1088        let dest = dir.path().join("nested").join("venues");
1089        // Ensure parent doesn't exist
1090        assert!(!dest.exists());
1091        let args = InitArgs { force: true };
1092        let result = run_init_impl(args, dest.clone(), &quiet());
1093        assert!(result.is_ok());
1094        assert!(dest.exists());
1095        assert!(dest.is_dir());
1096    }
1097
1098    #[test]
1099    fn test_validate_file_template_missing_base() {
1100        let yaml = r#"
1101id: bad_template
1102name: Bad Template Exchange
1103base_url: https://api.test.com
1104symbol:
1105  template: "nobase{quote}"
1106  default_quote: USDT
1107capabilities:
1108  order_book:
1109    path: /depth
1110    params:
1111      symbol: "{pair}"
1112    response:
1113      asks_key: asks
1114      bids_key: bids
1115      level_format: positional
1116"#;
1117        let dir = tempfile::tempdir().unwrap();
1118        let path = dir.path().join("bad_template.yaml");
1119        std::fs::write(&path, yaml).unwrap();
1120
1121        let args = ValidateArgs { file: path };
1122        let result = run_validate(args, &quiet());
1123        assert!(result.is_ok()); // YAML parses; check_fail is printed for missing {base}
1124    }
1125
1126    #[test]
1127    fn test_validate_file_empty_capabilities() {
1128        let yaml = r#"
1129id: no_caps
1130name: No Capabilities
1131base_url: https://api.test.com
1132symbol:
1133  template: "{base}{quote}"
1134  default_quote: USDT
1135capabilities: {}
1136"#;
1137        let dir = tempfile::tempdir().unwrap();
1138        let path = dir.path().join("no_caps.yaml");
1139        std::fs::write(&path, yaml).unwrap();
1140
1141        let args = ValidateArgs { file: path };
1142        let result = run_validate(args, &quiet());
1143        assert!(result.is_ok());
1144    }
1145
1146    #[test]
1147    fn test_run_venues_command_routes_to_subcommands() {
1148        let result = run(
1149            VenuesCommands::List(ListArgs {
1150                format: ListFormat::Table,
1151            }),
1152            &quiet(),
1153        );
1154        assert!(result.is_ok());
1155        let result = run(
1156            VenuesCommands::Schema(SchemaArgs {
1157                format: SchemaFormat::Text,
1158            }),
1159            &quiet(),
1160        );
1161        assert!(result.is_ok());
1162    }
1163}