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