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