Skip to main content

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