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            // A newer catalog may now be hidden by the user's own copies.
195            let scripted =
196                crate::cli::ghola_offer::scripted(&crate::cli::ghola_offer::process_args(), config);
197            offer_catalog_updates(scripted, out).await;
198            Ok(())
199        }
200        VenuesCommands::Init(args) => {
201            if catalog::installed(&VenueRegistry::catalog_dir()).is_empty() {
202                run_update(
203                    config.market.catalog_url(),
204                    http.as_ref(),
205                    &VenueRegistry::catalog_dir(),
206                    out,
207                )
208                .await?;
209            }
210            run_init(args, out)
211        }
212        VenuesCommands::Validate(args) => run_validate(args, out),
213    }
214}
215
216// =============================================================================
217// Update
218// =============================================================================
219
220/// Syncs the catalog from `url` into `dest` and reports what changed. The
221/// example file goes next to the user's venues either way.
222async fn run_update(
223    url: &str,
224    http: &dyn HttpClient,
225    dest: &std::path::Path,
226    out: &Output,
227) -> Result<()> {
228    let example = catalog::example_path();
229    let _ = catalog::write_example(&example);
230    let report = catalog::sync(http, url, dest).await?;
231    outln!(out, "{}", section_header("Venue Catalog"))?;
232    outln!(out, "{}", kv_row("Updated", &report.summary()))?;
233    outln!(out, "{}", kv_row("From", url))?;
234    outln!(out, "{}", kv_row("Catalog", &dest.display().to_string()))?;
235    outln!(
236        out,
237        "{}",
238        kv_row(
239            "Your venues",
240            &VenueRegistry::user_venues_dir().display().to_string()
241        )
242    )?;
243    outln!(out, "{}", section_footer())?;
244    Ok(())
245}
246
247/// Before a command that needs venues: when no catalog is installed, fetch
248/// it once (first use), saying so. Offline, it says how to get venues later
249/// and leaves the example file; the command then runs with what there is.
250pub async fn ensure_catalog(config: &Config, http: &dyn HttpClient, out: &Output) {
251    static TRIED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
252    ensure_catalog_at(
253        config.market.catalog_url(),
254        http,
255        &VenueRegistry::catalog_dir(),
256        &catalog::example_path(),
257        &TRIED,
258        out,
259    )
260    .await;
261}
262
263/// [`ensure_catalog`] for the catalog `dest`, the `example` file and a
264/// once-per-process `tried` flag.
265async fn ensure_catalog_at(
266    url: &str,
267    http: &dyn HttpClient,
268    dest: &std::path::Path,
269    example: &std::path::Path,
270    tried: &std::sync::atomic::AtomicBool,
271    out: &Output,
272) {
273    if !catalog::installed(dest).is_empty() || tried.swap(true, std::sync::atomic::Ordering::SeqCst)
274    {
275        return;
276    }
277    let _ = catalog::write_example(example);
278    crate::note!(out, "Fetching the venue catalog (first use)…");
279    match catalog::sync(http, url, dest).await {
280        Ok(report) => crate::note!(out, "Venue catalog installed: {}", report.summary()),
281        Err(e) => crate::note!(
282            out,
283            "Could not fetch the venue catalog ({e}). Run `scope venues update` when online, or describe an exchange yourself: see {}",
284            example.display()
285        ),
286    }
287}
288
289// =============================================================================
290// Stale copies: your files that hide a newer catalog venue
291// =============================================================================
292
293/// The question, in the user's words.
294const OVERRIDE_QUESTION: &str = "Would you like me to save your existing copies and overwrite? [Y]es/[N]o/[O]verwrite, don't save: ";
295
296/// What to do with the user's copies that hide catalog capabilities.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub enum OverrideAnswer {
299    /// Move the copies to a backup folder; the catalog's apply.
300    SaveAndReplace,
301    /// Keep them, and do not ask again until the catalog changes.
302    Keep,
303    /// Remove the copies without a backup; the catalog's apply.
304    Replace,
305}
306
307/// `y`, `n` or `o` (any case, a word starting with one); anything else,
308/// an empty line included, keeps the copies.
309pub fn parse_override_answer(answer: &str) -> OverrideAnswer {
310    match answer.trim().chars().next().map(|c| c.to_ascii_lowercase()) {
311        Some('y') => OverrideAnswer::SaveAndReplace,
312        Some('o') => OverrideAnswer::Replace,
313        _ => OverrideAnswer::Keep,
314    }
315}
316
317/// One of the user's files that hides a newer catalog venue.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct StaleCopy {
320    pub id: String,
321    pub file: std::path::PathBuf,
322    /// What the catalog's version has that the copy lacks.
323    pub hidden: Vec<&'static str>,
324    /// The catalog file's checksum, so a "no" lasts until it changes.
325    pub catalog_sha: String,
326}
327
328/// The user's copies in `registry` that hide catalog capabilities, minus
329/// those already declined for the same catalog version (`declined`).
330pub fn stale_copies(
331    registry: &VenueRegistry,
332    catalog_dir: &std::path::Path,
333    declined: &std::collections::HashMap<String, String>,
334) -> Vec<StaleCopy> {
335    let shas = catalog::installed(catalog_dir);
336    registry
337        .list()
338        .into_iter()
339        .filter_map(|id| {
340            let hidden = registry.hidden_capabilities(id);
341            let file = registry.user_file(id)?.to_path_buf();
342            let catalog_sha = shas.iter().find(|e| e.id == id)?.sha256.clone();
343            (!hidden.is_empty() && declined.get(id) != Some(&catalog_sha)).then(|| StaleCopy {
344                id: id.to_string(),
345                file,
346                hidden,
347                catalog_sha,
348            })
349        })
350        .collect()
351}
352
353/// Where the "no" answers are kept: `{id: catalog sha256}`.
354fn declined_path() -> std::path::PathBuf {
355    scope::paths::state_dir().join("venue-overrides-declined.json")
356}
357
358fn read_declined(path: &std::path::Path) -> std::collections::HashMap<String, String> {
359    std::fs::read_to_string(path)
360        .ok()
361        .and_then(|t| serde_json::from_str(&t).ok())
362        .unwrap_or_default()
363}
364
365/// Carries out `answer` for `stale`: moves the copies to a backup folder
366/// under `home` (named by `stamp`), removes them, or records the "no" in
367/// `declined_file`. Returns what it did, for the user.
368pub fn apply_override_answer(
369    answer: OverrideAnswer,
370    stale: &[StaleCopy],
371    home: &std::path::Path,
372    stamp: &str,
373    declined_file: &std::path::Path,
374) -> Result<String> {
375    let names = stale
376        .iter()
377        .map(|c| c.id.as_str())
378        .collect::<Vec<_>>()
379        .join(", ");
380    match answer {
381        OverrideAnswer::SaveAndReplace => {
382            let backup = home.join(format!("venues-backup-{stamp}"));
383            std::fs::create_dir_all(&backup).map_err(|e| {
384                scope::error::ScopeError::io_context(
385                    e,
386                    format!("Cannot create {}", backup.display()),
387                )
388            })?;
389            for c in stale {
390                let to = backup.join(c.file.file_name().unwrap_or_default());
391                std::fs::rename(&c.file, &to).map_err(|e| {
392                    scope::error::ScopeError::io_context(
393                        e,
394                        format!("Cannot move {}", c.file.display()),
395                    )
396                })?;
397            }
398            Ok(format!(
399                "Saved your copies of {names} to {} and switched to the catalog's.",
400                backup.display()
401            ))
402        }
403        OverrideAnswer::Replace => {
404            for c in stale {
405                std::fs::remove_file(&c.file).map_err(|e| {
406                    scope::error::ScopeError::io_context(
407                        e,
408                        format!("Cannot remove {}", c.file.display()),
409                    )
410                })?;
411            }
412            Ok(format!(
413                "Removed your copies of {names}; the catalog's apply."
414            ))
415        }
416        OverrideAnswer::Keep => {
417            let mut declined = read_declined(declined_file);
418            for c in stale {
419                declined.insert(c.id.clone(), c.catalog_sha.clone());
420            }
421            if let Some(dir) = declined_file.parent() {
422                let _ = std::fs::create_dir_all(dir);
423            }
424            let json = serde_json::to_string_pretty(&declined).unwrap_or_default();
425            std::fs::write(declined_file, json).map_err(|e| {
426                scope::error::ScopeError::io_context(
427                    e,
428                    format!("Cannot write {}", declined_file.display()),
429                )
430            })?;
431            Ok(format!(
432                "Kept your copies of {names}; scope will ask again when the catalog's change."
433            ))
434        }
435    }
436}
437
438/// The question's text: which venues, and what the catalog adds.
439fn override_prompt(stale: &[StaleCopy]) -> String {
440    let lines: Vec<String> = stale
441        .iter()
442        .map(|c| {
443            format!(
444                "  {} ({}): the catalog's also has {}",
445                c.id,
446                c.file.display(),
447                c.hidden.join(", ")
448            )
449        })
450        .collect();
451    format!(
452        "New version of the venues catalog found. Your own venue files hide what it adds:\n{}\n",
453        lines.join("\n")
454    )
455}
456
457/// Before a command that reads venues: when the user's copies hide a newer
458/// catalog venue, ask (once a process) whether to save and replace them,
459/// replace them, or keep them. In the TUI the question is a list; on a
460/// terminal, the `[Y]es/[N]o/[O]verwrite` prompt; scripted runs never ask.
461pub async fn offer_catalog_updates(scripted: bool, out: &Output) {
462    static ASKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
463    if scripted || ASKED.load(std::sync::atomic::Ordering::SeqCst) {
464        return;
465    }
466    let Ok(registry) = VenueRegistry::load() else {
467        return;
468    };
469    let asked = offer_with(
470        &registry,
471        &VenueRegistry::catalog_dir(),
472        &declined_path(),
473        &scope::paths::home(),
474        out,
475    )
476    .await;
477    if asked {
478        ASKED.store(true, std::sync::atomic::Ordering::SeqCst);
479    }
480}
481
482/// [`offer_catalog_updates`] for `registry`, its catalog folder, the file
483/// of "no" answers and the scope home (where backups go). Returns whether
484/// it asked.
485async fn offer_with(
486    registry: &VenueRegistry,
487    catalog_dir: &std::path::Path,
488    declined_file: &std::path::Path,
489    home: &std::path::Path,
490    out: &Output,
491) -> bool {
492    let stale = stale_copies(registry, catalog_dir, &read_declined(declined_file));
493    if stale.is_empty() {
494        return false;
495    }
496    let answer = if let Some(asker) = out.asker() {
497        let picker = crate::cli::resolve::TuiPicker { asker };
498        let choice = picker
499            .ask(
500                format!("{}Use the catalog's versions?", override_prompt(&stale)),
501                vec![
502                    "Yes: save my copies, then use the catalog's".to_string(),
503                    "No: keep my copies".to_string(),
504                    "Overwrite: use the catalog's, don't save my copies".to_string(),
505                ],
506            )
507            .await;
508        match choice {
509            Some(0) => OverrideAnswer::SaveAndReplace,
510            Some(2) => OverrideAnswer::Replace,
511            // A cancelled question: nothing changes, and it was asked.
512            None => return true,
513            _ => OverrideAnswer::Keep,
514        }
515    } else {
516        if !crate::cli::ghola_offer::may_ask(out) {
517            return false;
518        }
519        let question = format!("{}{OVERRIDE_QUESTION}", override_prompt(&stale));
520        match crate::cli::prompt::ask_stdin(out, &question) {
521            Ok(a) => parse_override_answer(&a),
522            Err(_) => return false,
523        }
524    };
525    let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M%S").to_string();
526    match apply_override_answer(answer, &stale, home, &stamp, declined_file) {
527        Ok(done) => crate::note!(out, "{done}"),
528        Err(e) => crate::note!(out, "Could not update your venue files: {e}"),
529    }
530    true
531}
532
533// =============================================================================
534// List
535// =============================================================================
536
537fn run_list(args: ListArgs, out: &Output) -> Result<()> {
538    run_list_with(&VenueRegistry::load()?, args, out)
539}
540
541/// [`run_list`] for `registry`.
542fn run_list_with(registry: &VenueRegistry, args: ListArgs, out: &Output) -> Result<()> {
543    match args.format {
544        ListFormat::Table => {
545            outln!(out, "{}", section_header("Available Venues"))?;
546            for listing in MarketVenue::listings(registry) {
547                let caps = listing.capabilities.join(", ");
548                let detail = match (listing.chain, registry.source(&listing.id)) {
549                    (Some(chain), _) => format!("{caps} (DEX on {chain})"),
550                    (None, Some(VenueSource::User)) => {
551                        match registry.hidden_capabilities(&listing.id).as_slice() {
552                            [] => format!("{caps} (yours)"),
553                            hidden => format!(
554                                "{caps} (yours; the catalog's also has {})",
555                                hidden.join(", ")
556                            ),
557                        }
558                    }
559                    (None, _) => caps,
560                };
561                outln!(out, "{}", kv_row(&listing.id, &detail))?;
562            }
563            outln!(out, "{}", separator())?;
564            let user_dir = VenueRegistry::user_venues_dir();
565            let ids = registry.list();
566            let yours = ids
567                .iter()
568                .filter(|id| registry.source(id) == Some(VenueSource::User))
569                .count();
570            outln!(
571                out,
572                "{}",
573                kv_row(
574                    "Loaded",
575                    &format!(
576                        "{} venues ({} from the catalog, {} yours)",
577                        ids.len(),
578                        ids.len() - yours,
579                        yours
580                    )
581                )
582            )?;
583            if ids.is_empty() {
584                outln!(
585                    out,
586                    "{}",
587                    info_row("No exchange venues yet: run `scope venues update`")
588                )?;
589            }
590            for id in &ids {
591                if let Some(hint) = registry.override_hint(id) {
592                    outln!(out, "{}", info_row(&hint))?;
593                }
594            }
595            outln!(
596                out,
597                "{}",
598                kv_row(
599                    "Catalog",
600                    &VenueRegistry::catalog_dir().display().to_string()
601                )
602            )?;
603            outln!(
604                out,
605                "{}",
606                kv_row("Your venues", &user_dir.display().to_string())
607            )?;
608            outln!(out, "{}", section_footer())?;
609        }
610        ListFormat::Json => {
611            // The same list the web serves at /api/venues.
612            let venues = MarketVenue::listings(registry);
613            let output = serde_json::json!({
614                "total": venues.len(),
615                "venues": venues,
616                "user_venues_dir": VenueRegistry::user_venues_dir().display().to_string(),
617                "catalog_dir": VenueRegistry::catalog_dir().display().to_string(),
618            });
619            outln!(out, "{}", serde_json::to_string_pretty(&output).unwrap())?;
620        }
621    }
622
623    Ok(())
624}
625
626// =============================================================================
627// Schema
628// =============================================================================
629
630fn run_schema(args: SchemaArgs, out: &Output) -> Result<()> {
631    match args.format {
632        SchemaFormat::Text => print_annotated_schema(out)?,
633        SchemaFormat::Json => print_json_schema(out)?,
634    }
635    Ok(())
636}
637
638fn print_annotated_schema(out: &Output) -> std::io::Result<()> {
639    outln!(out, "{}", section_header("Venue Descriptor Schema"))?;
640    outln!(
641        out,
642        r#"
643A venue descriptor is a YAML file that tells Scope how to talk to
644an exchange API. Place custom descriptors in:
645
646  {}
647
648Each file defines one venue with the following structure:
649
650  id: my_exchange              # Unique ID (lowercase, underscores ok)
651  name: My Exchange            # Human-readable display name
652  base_url: https://api.example.com  # API base URL
653
654  symbol:
655    template: "{{base}}{{quote}}"  # Pair format (placeholders: {{base}}, {{quote}})
656    default_quote: USDT          # Quote currency when user omits it
657    case: upper                  # "upper" or "lower" (default: upper)
658
659  error_envelope:                # Optional: errors sent with HTTP 200
660    code_key: retCode            # Status field; omit to test message_key only
661    ok_codes: ["0"]              # Status values that mean success
662    message_key: retMsg          # Error text (a string, or an array like Kraken's)
663
664  capabilities:
665    order_book:                  # Omit if the venue has no depth API
666      method: GET                # GET (default) or POST
667      path: /api/v1/depth       # URL path appended to base_url
668      params:                    # Query parameters (GET) — values can use {{pair}}, {{limit}}
669        symbol: "{{pair}}"
670        limit: "{{limit}}"
671      response_root: data        # JSON path to the relevant data (optional)
672      response:
673        asks_key: asks           # JSON key for asks array
674        bids_key: bids           # JSON key for bids array
675        level_format: positional # "positional" for [price, qty] or "object"
676        level_price_field: price # Only needed when level_format = object
677        level_size_field: size   # Only needed when level_format = object
678
679    ticker:                      # Omit if the venue has no ticker API
680      path: /api/v1/ticker
681      params:
682        symbol: "{{pair}}"
683      response:
684        last_price: lastPrice
685        high_24h: highPrice
686        low_24h: lowPrice
687        volume_24h: volume
688        quote_volume_24h: quoteVolume
689        price_change_24h: priceChange
690        price_change_pct_24h: priceChangePercent
691
692    trades:                      # Omit if the venue has no trades API
693      path: /api/v1/trades
694      params:
695        symbol: "{{pair}}"
696        limit: "{{limit}}"
697      response:
698        items_key: data          # JSON key containing the trades array (omit if root)
699        price: price             # Field for trade price
700        quantity: qty            # Field for trade quantity
701        timestamp_ms: time       # Field for trade timestamp (epoch ms)
702        side:                    # Side detection
703          field: side            # JSON field containing buy/sell indicator
704          mapping:
705            buy: buy
706            sell: sell
707
708Validate your file with:  scope venues validate <file>
709"#,
710        VenueRegistry::user_venues_dir().display()
711    )?;
712    outln!(out, "{}", section_footer())?;
713    Ok(())
714}
715
716fn print_json_schema(out: &Output) -> std::io::Result<()> {
717    use serde_json::{Map, Value};
718
719    fn str_prop(desc: &str) -> Value {
720        let mut m = Map::new();
721        m.insert("type".into(), Value::String("string".into()));
722        m.insert("description".into(), Value::String(desc.into()));
723        Value::Object(m)
724    }
725
726    fn str_type() -> Value {
727        serde_json::json!({"type": "string"})
728    }
729
730    // Build response properties
731    let mut resp_props = Map::new();
732    for key in &[
733        "asks_key",
734        "bids_key",
735        "level_format",
736        "level_price_field",
737        "level_size_field",
738        "last_price",
739        "high_24h",
740        "low_24h",
741        "volume_24h",
742        "quote_volume_24h",
743        "best_bid",
744        "best_ask",
745        "items_key",
746        "price",
747        "quantity",
748        "quote_quantity",
749        "timestamp_ms",
750        "id",
751    ] {
752        resp_props.insert((*key).into(), str_type());
753    }
754    resp_props.insert(
755        "filter".into(),
756        serde_json::json!({
757            "type": "object",
758            "properties": {"field": {"type": "string"}, "value": {"type": "string"}}
759        }),
760    );
761    resp_props.insert(
762        "side".into(),
763        serde_json::json!({
764            "type": "object",
765            "properties": {
766                "field": {"type": "string"},
767                "mapping": {"type": "object", "additionalProperties": {"type": "string"}}
768            }
769        }),
770    );
771
772    // Build endpoint def
773    let endpoint_def = serde_json::json!({
774        "type": "object",
775        "required": ["path", "response"],
776        "properties": {
777            "method": {"type": "string", "enum": ["GET", "POST"], "default": "GET"},
778            "path": {"type": "string"},
779            "params": {"type": "object", "additionalProperties": {"type": "string"}},
780            "request_body": {"description": "JSON body template for POST requests"},
781            "response_root": {"type": "string", "description": "Dot-path to navigate JSON response"},
782            "response": {"type": "object", "properties": Value::Object(resp_props)}
783        }
784    });
785
786    let endpoint_ref = serde_json::json!({"$ref": "#/$defs/endpoint"});
787
788    let schema = serde_json::json!({
789        "$schema": "https://json-schema.org/draft/2020-12/schema",
790        "title": "VenueDescriptor",
791        "description": "Schema for Scope exchange venue descriptor YAML files.",
792        "type": "object",
793        "required": ["id", "name", "base_url", "symbol", "capabilities"],
794        "properties": {
795            "id": str_prop("Unique venue identifier (e.g., 'binance')"),
796            "name": str_prop("Human-readable venue name (e.g., 'Binance Spot')"),
797            "base_url": str_prop("API base URL (e.g., 'https://api.binance.com')"),
798            "symbol": {
799                "type": "object",
800                "required": ["template", "default_quote"],
801                "properties": {
802                    "template": str_prop("Pair template with {base} and {quote} placeholders"),
803                    "default_quote": str_prop("Default quote currency (e.g., 'USDT')"),
804                    "case": {"type": "string", "enum": ["upper", "lower"], "default": "upper"}
805                }
806            },
807            "error_envelope": {
808                "type": "object",
809                "description": "How the venue reports an error inside an HTTP 200 reply",
810                "required": ["message_key"],
811                "properties": {
812                    "code_key": str_prop("Status field; a missing field means success"),
813                    "ok_codes": {"type": "array", "items": {"type": "string"}, "description": "Status values that mean success, compared as strings"},
814                    "message_key": str_prop("Field with the error message (string or array)")
815                }
816            },
817            "capabilities": {
818                "type": "object",
819                "properties": {
820                    "order_book": endpoint_ref.clone(),
821                    "ticker": endpoint_ref.clone(),
822                    "trades": endpoint_ref
823                }
824            }
825        },
826        "$defs": {
827            "endpoint": endpoint_def
828        }
829    });
830
831    outln!(out, "{}", serde_json::to_string_pretty(&schema).unwrap())?;
832    Ok(())
833}
834
835// =============================================================================
836// Init
837// =============================================================================
838
839fn run_init(args: InitArgs, out: &Output) -> Result<()> {
840    run_init_impl(args, VenueRegistry::user_venues_dir(), out)
841}
842
843/// Core init logic with explicit destination path (used by tests).
844fn run_init_impl(args: InitArgs, dest: std::path::PathBuf, out: &Output) -> Result<()> {
845    // Ensure directory exists
846    if !dest.exists() {
847        std::fs::create_dir_all(&dest).map_err(|e| {
848            let context = format!("Failed to create venues directory {}", dest.display());
849            scope::error::ScopeError::io_context(e, context)
850        })?;
851        outln!(out, "Created {}", dest.display())?;
852    }
853
854    // The catalog's files, copied as they are: a re-serialised copy lost
855    // what the serialiser did not know (the `ohlc` capability).
856    let source = VenueRegistry::catalog_dir();
857    let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&source)
858        .map(|entries| {
859            entries
860                .flatten()
861                .map(|e| e.path())
862                .filter(|p| p.extension().is_some_and(|ext| ext == "yaml"))
863                .collect()
864        })
865        .unwrap_or_default();
866    files.sort();
867    if files.is_empty() {
868        return Err(scope::error::ScopeError::InvalidInput(
869            "The venue catalog is not installed: run `scope venues update` first.".into(),
870        ));
871    }
872    let mut copied = 0;
873    let mut skipped = 0;
874
875    for path in files {
876        let filename = path.file_name().unwrap().to_string_lossy().into_owned();
877        let target = dest.join(&filename);
878
879        if target.exists() && !args.force {
880            skipped += 1;
881            outln!(
882                out,
883                "  skip {} (exists, use --force to overwrite)",
884                filename
885            )?;
886            continue;
887        }
888
889        std::fs::copy(&path, &target).map_err(|e| {
890            scope::error::ScopeError::io_context(e, format!("Failed to write {}", target.display()))
891        })?;
892        copied += 1;
893        outln!(out, "  {}", check_pass(&filename))?;
894    }
895
896    outln!(out)?;
897    outln!(out, "{}", section_header("Venues Init"))?;
898    outln!(out, "{}", kv_row("Directory", &dest.display().to_string()))?;
899    outln!(out, "{}", kv_row("Copied", &format!("{} files", copied)))?;
900    if skipped > 0 {
901        outln!(
902            out,
903            "{}",
904            kv_row("Skipped", &format!("{} files (already exist)", skipped))
905        )?;
906    }
907    outln!(out, "{}", section_footer())?;
908
909    Ok(())
910}
911
912// =============================================================================
913// Validate
914// =============================================================================
915
916fn run_validate(args: ValidateArgs, out: &Output) -> Result<()> {
917    let path = &args.file;
918
919    if !path.exists() {
920        outln!(
921            out,
922            "{}",
923            check_fail(&format!("File not found: {}", path.display()))
924        )?;
925        return Err(scope::error::ScopeError::Chain(format!(
926            "File not found: {}",
927            path.display()
928        )));
929    }
930
931    let content = std::fs::read_to_string(path).map_err(|e| {
932        scope::error::ScopeError::io_context(e, format!("Failed to read {}", path.display()))
933    })?;
934
935    outln!(out, "{}", section_header("Venue Validation"))?;
936    outln!(out, "{}", kv_row("File", &path.display().to_string()))?;
937    outln!(out, "{}", separator())?;
938
939    match VenueRegistry::validate_yaml(&content) {
940        Ok(desc) => {
941            outln!(out, "{}", check_pass("Valid YAML syntax"))?;
942            outln!(out, "{}", check_pass(&format!("Venue ID: {}", desc.id)))?;
943            outln!(out, "{}", check_pass(&format!("Name: {}", desc.name)))?;
944            outln!(
945                out,
946                "{}",
947                check_pass(&format!("Base URL: {}", desc.base_url))
948            )?;
949
950            // Check capabilities
951            let caps = desc.capability_names();
952            if caps.is_empty() {
953                outln!(
954                    out,
955                    "{}",
956                    check_fail(
957                        "No capabilities defined (need at least one of: order_book, ticker, trades)"
958                    )
959                )?;
960            } else {
961                for cap in &caps {
962                    outln!(out, "{}", check_pass(&format!("Capability: {}", cap)))?;
963                }
964            }
965
966            // Validate symbol template
967            if desc.symbol.template.contains("{base}") {
968                outln!(
969                    out,
970                    "{}",
971                    check_pass("Symbol template contains {base} placeholder")
972                )?;
973            } else {
974                outln!(
975                    out,
976                    "{}",
977                    check_fail("Symbol template missing {base} placeholder")
978                )?;
979            }
980
981            outln!(out, "{}", separator())?;
982            if caps.is_empty() {
983                outln!(out, "{}", check_fail("Validation completed with warnings"))?;
984            } else {
985                outln!(out, "{}", check_pass("Validation passed"))?;
986            }
987            outln!(out, "{}", section_footer())?;
988            Ok(())
989        }
990        Err(e) => {
991            outln!(out, "{}", check_fail("Invalid YAML"))?;
992            outln!(out, "{}", check_fail(&format!("Error: {}", e)))?;
993            outln!(out, "{}", separator())?;
994            outln!(
995                out,
996                "{}",
997                kv_row(
998                    "Hint",
999                    "Run `scope venues schema` to see the expected format"
1000                )
1001            )?;
1002            outln!(out, "{}", section_footer())?;
1003            Err(e)
1004        }
1005    }
1006}
1007
1008// =============================================================================
1009// Tests
1010// =============================================================================
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015
1016    fn quiet() -> Output {
1017        Output::capture().0
1018    }
1019
1020    #[test]
1021    fn test_list_format_default() {
1022        let fmt = ListFormat::default();
1023        assert!(matches!(fmt, ListFormat::Table));
1024    }
1025
1026    #[test]
1027    fn test_schema_format_default() {
1028        let fmt = SchemaFormat::default();
1029        assert!(matches!(fmt, SchemaFormat::Text));
1030    }
1031
1032    #[test]
1033    fn test_run_list_table() {
1034        let args = ListArgs {
1035            format: ListFormat::Table,
1036        };
1037        let result = run_list(args, &quiet());
1038        assert!(result.is_ok());
1039    }
1040
1041    #[test]
1042    fn test_run_list_json() {
1043        let args = ListArgs {
1044            format: ListFormat::Json,
1045        };
1046        let (o, cap) = Output::capture();
1047        run_list(args, &o).unwrap();
1048        // Scripts parse this: it must be one JSON document on the data channel.
1049        let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1050        assert!(v["venues"].as_array().is_some_and(|a| !a.is_empty()));
1051        assert_eq!(
1052            v["total"].as_u64().unwrap() as usize,
1053            v["venues"].as_array().unwrap().len()
1054        );
1055        // DEX venues are listed too, marked by kind.
1056        assert!(
1057            v["venues"]
1058                .as_array()
1059                .unwrap()
1060                .iter()
1061                .any(|venue| venue["kind"] == "dex")
1062        );
1063    }
1064
1065    #[test]
1066    fn test_run_schema_text() {
1067        let args = SchemaArgs {
1068            format: SchemaFormat::Text,
1069        };
1070        let result = run_schema(args, &quiet());
1071        assert!(result.is_ok());
1072    }
1073
1074    #[test]
1075    fn test_run_schema_json() {
1076        let args = SchemaArgs {
1077            format: SchemaFormat::Json,
1078        };
1079        let (o, cap) = Output::capture();
1080        run_schema(args, &o).unwrap();
1081        let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1082        assert!(v.is_object());
1083    }
1084
1085    #[test]
1086    fn test_validate_missing_file() {
1087        let args = ValidateArgs {
1088            file: std::path::PathBuf::from("/tmp/nonexistent_venue_test.yaml"),
1089        };
1090        let result = run_validate(args, &quiet());
1091        assert!(result.is_err());
1092    }
1093
1094    #[test]
1095    fn test_validate_valid_file() {
1096        let yaml = r#"
1097id: test_venue
1098name: Test Exchange
1099base_url: https://api.test.com
1100symbol:
1101  template: "{base}{quote}"
1102  default_quote: USDT
1103capabilities:
1104  order_book:
1105    path: /depth
1106    params:
1107      symbol: "{pair}"
1108    response:
1109      asks_key: asks
1110      bids_key: bids
1111      level_format: positional
1112"#;
1113        let dir = tempfile::tempdir().unwrap();
1114        let path = dir.path().join("test.yaml");
1115        std::fs::write(&path, yaml).unwrap();
1116
1117        let args = ValidateArgs { file: path };
1118        let (o, cap) = Output::capture();
1119        run_validate(args, &o).unwrap();
1120        assert!(cap.out().contains("Venue ID: test_venue"));
1121        assert!(cap.out().contains("Validation passed"));
1122    }
1123
1124    #[test]
1125    fn test_validate_invalid_file() {
1126        let yaml = "this is not valid yaml: [";
1127        let dir = tempfile::tempdir().unwrap();
1128        let path = dir.path().join("bad.yaml");
1129        std::fs::write(&path, yaml).unwrap();
1130
1131        let args = ValidateArgs { file: path };
1132        let result = run_validate(args, &quiet());
1133        assert!(result.is_err());
1134    }
1135
1136    #[test]
1137    fn test_run_init_to_temp_dir() {
1138        let dir = tempfile::tempdir().unwrap();
1139        let dest = dir.path().to_path_buf();
1140        let args = InitArgs { force: true };
1141        let result = run_init_impl(args, dest.clone(), &quiet());
1142        assert!(result.is_ok());
1143
1144        // Verify venue files were created (registry has 11 built-in venues)
1145        let registry = VenueRegistry::load().unwrap();
1146        for id in registry.list() {
1147            let filename = format!("{}.yaml", id);
1148            let target = dest.join(&filename);
1149            assert!(target.exists(), "Expected {} to exist", filename);
1150            let content = std::fs::read_to_string(&target).unwrap();
1151            assert!(content.contains(&format!("id: {}", id)));
1152        }
1153    }
1154
1155    #[test]
1156    fn test_run_init_skips_existing() {
1157        let dir = tempfile::tempdir().unwrap();
1158        let dest = dir.path().to_path_buf();
1159
1160        // Write an existing binance.yaml before init
1161        let existing_path = dest.join("binance.yaml");
1162        std::fs::create_dir_all(&dest).unwrap();
1163        let original_content = "id: binance\n# pre-existing file\n";
1164        std::fs::write(&existing_path, original_content).unwrap();
1165
1166        // Run init with force=false
1167        let args = InitArgs { force: false };
1168        let result = run_init_impl(args, dest.clone(), &quiet());
1169        assert!(result.is_ok());
1170
1171        // Verify binance.yaml was NOT overwritten (content unchanged)
1172        let content = std::fs::read_to_string(&existing_path).unwrap();
1173        assert_eq!(
1174            content, original_content,
1175            "Existing file should not be overwritten when force=false"
1176        );
1177    }
1178
1179    #[test]
1180    fn test_run_init_creates_directory_when_missing() {
1181        let dir = tempfile::tempdir().unwrap();
1182        let dest = dir.path().join("nested").join("venues");
1183        // Ensure parent doesn't exist
1184        assert!(!dest.exists());
1185        let args = InitArgs { force: true };
1186        let result = run_init_impl(args, dest.clone(), &quiet());
1187        assert!(result.is_ok());
1188        assert!(dest.exists());
1189        assert!(dest.is_dir());
1190    }
1191
1192    #[test]
1193    fn test_validate_file_template_missing_base() {
1194        let yaml = r#"
1195id: bad_template
1196name: Bad Template Exchange
1197base_url: https://api.test.com
1198symbol:
1199  template: "nobase{quote}"
1200  default_quote: USDT
1201capabilities:
1202  order_book:
1203    path: /depth
1204    params:
1205      symbol: "{pair}"
1206    response:
1207      asks_key: asks
1208      bids_key: bids
1209      level_format: positional
1210"#;
1211        let dir = tempfile::tempdir().unwrap();
1212        let path = dir.path().join("bad_template.yaml");
1213        std::fs::write(&path, yaml).unwrap();
1214
1215        let args = ValidateArgs { file: path };
1216        let result = run_validate(args, &quiet());
1217        assert!(result.is_ok()); // YAML parses; check_fail is printed for missing {base}
1218    }
1219
1220    #[test]
1221    fn test_validate_file_empty_capabilities() {
1222        let yaml = r#"
1223id: no_caps
1224name: No Capabilities
1225base_url: https://api.test.com
1226symbol:
1227  template: "{base}{quote}"
1228  default_quote: USDT
1229capabilities: {}
1230"#;
1231        let dir = tempfile::tempdir().unwrap();
1232        let path = dir.path().join("no_caps.yaml");
1233        std::fs::write(&path, yaml).unwrap();
1234
1235        let args = ValidateArgs { file: path };
1236        let result = run_validate(args, &quiet());
1237        assert!(result.is_ok());
1238    }
1239
1240    #[tokio::test]
1241    async fn test_run_venues_command_routes_to_subcommands() {
1242        let config = Config::default();
1243        let http = scope::http::test_transport();
1244        let result = run(
1245            VenuesCommands::List(ListArgs {
1246                format: ListFormat::Table,
1247            }),
1248            &config,
1249            http.clone(),
1250            &quiet(),
1251        )
1252        .await;
1253        assert!(result.is_ok());
1254        let result = run(
1255            VenuesCommands::Schema(SchemaArgs {
1256                format: SchemaFormat::Text,
1257            }),
1258            &config,
1259            http,
1260            &quiet(),
1261        )
1262        .await;
1263        assert!(result.is_ok());
1264    }
1265
1266    /// A catalog served by a local server: `index.json` and one venue.
1267    async fn catalog_server(venue: &str) -> mockito::ServerGuard {
1268        let mut server = mockito::Server::new_async().await;
1269        let index = serde_json::json!({
1270            "schema": 1,
1271            "venues": [{"id": "weex", "file": "weex.yaml", "sha256": catalog::checksum(venue)}],
1272        });
1273        server
1274            .mock("GET", "/c/index.json")
1275            .with_body(index.to_string())
1276            .create_async()
1277            .await;
1278        server
1279            .mock("GET", "/c/weex.yaml")
1280            .with_body(venue)
1281            .create_async()
1282            .await;
1283        server
1284    }
1285
1286    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";
1287
1288    #[tokio::test]
1289    async fn test_update_installs_the_catalog_and_reports_it() {
1290        let server = catalog_server(WEEX).await;
1291        let home = tempfile::tempdir().unwrap();
1292        let dest = home.path().join("catalog");
1293        let (out, cap) = Output::capture();
1294        run_update(
1295            &format!("{}/c", server.url()),
1296            scope::http::test_transport().as_ref(),
1297            &dest,
1298            &out,
1299        )
1300        .await
1301        .unwrap();
1302        let text = crate::cli::ansi::strip(&cap.out());
1303        assert!(
1304            text.contains("1 venues: 1 added (weex), 0 updated, 0 removed"),
1305            "{text}"
1306        );
1307        let registry = VenueRegistry::load_dirs(&dest, &home.path().join("none")).unwrap();
1308        // The file is the catalog's, byte for byte: nothing is re-serialised.
1309        assert_eq!(
1310            std::fs::read_to_string(dest.join("weex.yaml")).unwrap(),
1311            WEEX
1312        );
1313        assert!(registry.get("weex").unwrap().capabilities.ohlc.is_some());
1314    }
1315
1316    #[test]
1317    fn test_init_copies_the_catalog_files_verbatim() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let dest = dir.path().join("venues");
1320        run_init_impl(InitArgs { force: false }, dest.clone(), &quiet()).unwrap();
1321        let catalog = VenueRegistry::catalog_dir();
1322        for name in ["binance.yaml", "kraken.yaml"] {
1323            assert_eq!(
1324                std::fs::read(dest.join(name)).unwrap(),
1325                std::fs::read(catalog.join(name)).unwrap(),
1326                "{name} is copied as it is"
1327            );
1328        }
1329        // ohlc survives, which the old re-serialised copies lost.
1330        let copied = VenueRegistry::load_dirs(&dir.path().join("none"), &dest).unwrap();
1331        assert!(copied.get("binance").unwrap().capabilities.ohlc.is_some());
1332    }
1333
1334    #[test]
1335    fn test_list_marks_your_own_venues() {
1336        let (out, cap) = Output::capture();
1337        run_list(
1338            ListArgs {
1339                format: ListFormat::Table,
1340            },
1341            &out,
1342        )
1343        .unwrap();
1344        let text = crate::cli::ansi::strip(&cap.out());
1345        assert!(text.contains("from the catalog"), "{text}");
1346        assert!(text.contains("Catalog"), "{text}");
1347    }
1348
1349    #[tokio::test]
1350    async fn test_first_use_fetches_the_catalog_once_and_says_so() {
1351        let server = catalog_server(WEEX).await;
1352        let home = tempfile::tempdir().unwrap();
1353        let dest = home.path().join("catalog");
1354        let example = home.path().join(catalog::EXAMPLE_FILE);
1355        let tried = std::sync::atomic::AtomicBool::new(false);
1356        let http = scope::http::test_transport();
1357        let url = format!("{}/c", server.url());
1358        let (out, cap) = Output::capture();
1359        ensure_catalog_at(&url, http.as_ref(), &dest, &example, &tried, &out).await;
1360        let notes = cap.err();
1361        assert!(
1362            notes.contains("Fetching the venue catalog (first use)"),
1363            "{notes}"
1364        );
1365        assert!(notes.contains("1 added (weex)"), "{notes}");
1366        assert!(example.exists(), "the example is written");
1367        // Installed: nothing more, even with a fresh flag.
1368        let (out, cap) = Output::capture();
1369        let fresh = std::sync::atomic::AtomicBool::new(false);
1370        ensure_catalog_at(&url, http.as_ref(), &dest, &example, &fresh, &out).await;
1371        assert!(cap.err().is_empty(), "{}", cap.err());
1372    }
1373
1374    #[tokio::test]
1375    async fn test_first_use_offline_says_how_to_get_venues_and_tries_once() {
1376        let home = tempfile::tempdir().unwrap();
1377        let dest = home.path().join("catalog");
1378        let example = home.path().join(catalog::EXAMPLE_FILE);
1379        let tried = std::sync::atomic::AtomicBool::new(false);
1380        let http = scope::http::test_transport();
1381        let (out, cap) = Output::capture();
1382        ensure_catalog_at(
1383            "http://127.0.0.1:9/none",
1384            http.as_ref(),
1385            &dest,
1386            &example,
1387            &tried,
1388            &out,
1389        )
1390        .await;
1391        let notes = cap.err();
1392        assert!(
1393            notes.contains("Could not fetch the venue catalog"),
1394            "{notes}"
1395        );
1396        assert!(notes.contains("scope venues update"), "{notes}");
1397        assert!(notes.contains(catalog::EXAMPLE_FILE), "{notes}");
1398        // Once a process: the second command does not try again.
1399        let (out, cap) = Output::capture();
1400        ensure_catalog_at(
1401            "http://127.0.0.1:9/none",
1402            http.as_ref(),
1403            &dest,
1404            &example,
1405            &tried,
1406            &out,
1407        )
1408        .await;
1409        assert!(cap.err().is_empty(), "{}", cap.err());
1410    }
1411
1412    #[tokio::test]
1413    async fn test_update_from_an_unreachable_url_fails_and_keeps_the_catalog() {
1414        let before = catalog::installed(&VenueRegistry::catalog_dir());
1415        let result = run(
1416            VenuesCommands::Update(UpdateArgs {
1417                url: Some("http://127.0.0.1:9/none".into()),
1418            }),
1419            &Config::default(),
1420            scope::http::test_transport(),
1421            &quiet(),
1422        )
1423        .await;
1424        assert!(result.is_err());
1425        assert_eq!(catalog::installed(&VenueRegistry::catalog_dir()), before);
1426    }
1427
1428    #[test]
1429    fn test_list_with_no_venues_says_how_to_get_them_and_marks_yours() {
1430        let (out, cap) = Output::capture();
1431        run_list_with(
1432            &VenueRegistry::default(),
1433            ListArgs {
1434                format: ListFormat::Table,
1435            },
1436            &out,
1437        )
1438        .unwrap();
1439        let text = crate::cli::ansi::strip(&cap.out());
1440        assert!(
1441            text.contains("0 venues (0 from the catalog, 0 yours)"),
1442            "{text}"
1443        );
1444        assert!(text.contains("No exchange venues yet"), "{text}");
1445
1446        let user = tempfile::tempdir().unwrap();
1447        std::fs::write(user.path().join("weex.yaml"), WEEX).unwrap();
1448        let registry =
1449            VenueRegistry::load_dirs(&VenueRegistry::catalog_dir(), user.path()).unwrap();
1450        let (out, cap) = Output::capture();
1451        run_list_with(
1452            &registry,
1453            ListArgs {
1454                format: ListFormat::Table,
1455            },
1456            &out,
1457        )
1458        .unwrap();
1459        let text = crate::cli::ansi::strip(&cap.out());
1460        // This weex describes only candles: it hides the catalog's book,
1461        // ticker and trades, and the list says so (wrapped at any width).
1462        let words = text
1463            .split_whitespace()
1464            .filter(|w| *w != "│")
1465            .collect::<Vec<_>>()
1466            .join(" ");
1467        assert!(
1468            words.contains("ohlc (yours; the catalog's also has order_book, ticker, trades)"),
1469            "{text}"
1470        );
1471        assert!(text.contains(", 1 yours)"), "{text}");
1472    }
1473
1474    #[test]
1475    fn test_list_says_when_your_file_hides_catalog_capabilities() {
1476        let user = tempfile::tempdir().unwrap();
1477        let without_ohlc = &WEEX[..WEEX.find("  ohlc:").unwrap()];
1478        let weex_catalog = tempfile::tempdir().unwrap();
1479        std::fs::write(weex_catalog.path().join("weex.yaml"), WEEX).unwrap();
1480        std::fs::write(user.path().join("weex.yaml"), without_ohlc).unwrap();
1481        let registry = VenueRegistry::load_dirs(weex_catalog.path(), user.path()).unwrap();
1482        let (out, cap) = Output::capture();
1483        run_list_with(
1484            &registry,
1485            ListArgs {
1486                format: ListFormat::Table,
1487            },
1488            &out,
1489        )
1490        .unwrap();
1491        let text = crate::cli::ansi::strip(&cap.out());
1492        // The hint wraps under the box rail.
1493        let words = text
1494            .split_whitespace()
1495            .filter(|w| *w != "│")
1496            .collect::<Vec<_>>()
1497            .join(" ");
1498        assert!(
1499            words.contains("(yours; the catalog's also has ohlc)"),
1500            "{text}"
1501        );
1502        assert!(
1503            words.contains("overrides the catalog's weex, which also has ohlc"),
1504            "{text}"
1505        );
1506    }
1507
1508    #[test]
1509    fn test_override_answers_parse_like_the_prompt_says() {
1510        for (input, want) in [
1511            ("y", OverrideAnswer::SaveAndReplace),
1512            ("Yes\n", OverrideAnswer::SaveAndReplace),
1513            ("o", OverrideAnswer::Replace),
1514            (" Overwrite", OverrideAnswer::Replace),
1515            ("n", OverrideAnswer::Keep),
1516            ("", OverrideAnswer::Keep),
1517            ("maybe", OverrideAnswer::Keep),
1518        ] {
1519            assert_eq!(parse_override_answer(input), want, "{input:?}");
1520        }
1521    }
1522
1523    /// A catalog with `weex` (with ohlc) and its index, and a user folder
1524    /// with a weex that lacks it.
1525    fn stale_setup() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
1526        let home = tempfile::tempdir().unwrap();
1527        let catalog_dir = home.path().join("venues").join("catalog");
1528        let user = home.path().join("venues");
1529        std::fs::create_dir_all(&catalog_dir).unwrap();
1530        std::fs::write(catalog_dir.join("weex.yaml"), WEEX).unwrap();
1531        let index = serde_json::json!({"schema": 1, "venues": [
1532            {"id": "weex", "file": "weex.yaml", "sha256": catalog::checksum(WEEX)}
1533        ]});
1534        std::fs::write(catalog_dir.join("index.json"), index.to_string()).unwrap();
1535        std::fs::write(
1536            user.join("weex.yaml"),
1537            &WEEX[..WEEX.find("  ohlc:").unwrap()],
1538        )
1539        .unwrap();
1540        (home, catalog_dir, user)
1541    }
1542
1543    #[test]
1544    fn test_stale_copies_are_found_until_declined_for_that_catalog_version() {
1545        let (_home, catalog_dir, user) = stale_setup();
1546        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1547        let none = std::collections::HashMap::new();
1548        let stale = stale_copies(&registry, &catalog_dir, &none);
1549        assert_eq!(stale.len(), 1);
1550        assert_eq!(
1551            (stale[0].id.as_str(), stale[0].hidden.as_slice()),
1552            ("weex", &["ohlc"][..])
1553        );
1554        assert!(
1555            override_prompt(&stale).contains("weex"),
1556            "{}",
1557            override_prompt(&stale)
1558        );
1559        // Declined for this catalog version: not asked again.
1560        let declined =
1561            std::collections::HashMap::from([("weex".to_string(), catalog::checksum(WEEX))]);
1562        assert!(stale_copies(&registry, &catalog_dir, &declined).is_empty());
1563        // Declined for an older one: asked again.
1564        let old = std::collections::HashMap::from([("weex".to_string(), "old".to_string())]);
1565        assert_eq!(stale_copies(&registry, &catalog_dir, &old).len(), 1);
1566    }
1567
1568    #[test]
1569    fn test_yes_saves_and_replaces_overwrite_removes_no_remembers() {
1570        // Yes: the copy moves to a backup; the catalog's applies.
1571        let (home, catalog_dir, user) = stale_setup();
1572        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1573        let stale = stale_copies(&registry, &catalog_dir, &Default::default());
1574        let declined = home.path().join("state").join("declined.json");
1575        let done = apply_override_answer(
1576            OverrideAnswer::SaveAndReplace,
1577            &stale,
1578            home.path(),
1579            "t",
1580            &declined,
1581        )
1582        .unwrap();
1583        assert!(done.contains("Saved your copies of weex"), "{done}");
1584        assert!(
1585            home.path()
1586                .join("venues-backup-t")
1587                .join("weex.yaml")
1588                .exists()
1589        );
1590        let after = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1591        assert_eq!(after.source("weex"), Some(VenueSource::Catalog));
1592        assert!(after.get("weex").unwrap().has_ohlc());
1593
1594        // Overwrite: removed, no backup.
1595        let (home, catalog_dir, user) = stale_setup();
1596        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1597        let stale = stale_copies(&registry, &catalog_dir, &Default::default());
1598        apply_override_answer(OverrideAnswer::Replace, &stale, home.path(), "t", &declined)
1599            .unwrap();
1600        assert!(!user.join("weex.yaml").exists());
1601        assert!(!home.path().join("venues-backup-t").exists());
1602
1603        // No: kept, and the answer recorded for this catalog version.
1604        let (home, catalog_dir, user) = stale_setup();
1605        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1606        let stale = stale_copies(&registry, &catalog_dir, &Default::default());
1607        let declined = home.path().join("state").join("declined.json");
1608        let done = apply_override_answer(OverrideAnswer::Keep, &stale, home.path(), "t", &declined)
1609            .unwrap();
1610        assert!(
1611            done.contains("ask again when the catalog's change"),
1612            "{done}"
1613        );
1614        assert!(user.join("weex.yaml").exists());
1615        assert!(stale_copies(&registry, &catalog_dir, &read_declined(&declined)).is_empty());
1616    }
1617
1618    /// Answers the TUI's next list question with `choice`.
1619    fn answer_with(asker: crate::cli::output::Asker, choice: Option<usize>) {
1620        tokio::spawn(async move {
1621            loop {
1622                let ask = asker.lock().unwrap().take();
1623                if let Some(ask) = ask {
1624                    assert!(
1625                        ask.prompt
1626                            .contains("New version of the venues catalog found")
1627                    );
1628                    assert_eq!(ask.options.len(), 3);
1629                    let _ = ask.reply.send(choice);
1630                    return;
1631                }
1632                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1633            }
1634        });
1635    }
1636
1637    #[tokio::test]
1638    async fn test_the_tui_offer_saves_and_replaces_on_yes() {
1639        let (home, catalog_dir, user) = stale_setup();
1640        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1641        let declined = home.path().join("declined.json");
1642        let asker: crate::cli::output::Asker = Default::default();
1643        let (out, cap) = Output::capture();
1644        let out = out.with_asker(asker.clone());
1645        answer_with(asker, Some(0));
1646        assert!(offer_with(&registry, &catalog_dir, &declined, home.path(), &out).await);
1647        assert!(
1648            cap.err().contains("Saved your copies of weex"),
1649            "{}",
1650            cap.err()
1651        );
1652        assert!(!user.join("weex.yaml").exists());
1653    }
1654
1655    #[tokio::test]
1656    async fn test_the_tui_offer_overwrites_keeps_or_cancels() {
1657        // Overwrite.
1658        let (home, catalog_dir, user) = stale_setup();
1659        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1660        let declined = home.path().join("declined.json");
1661        let asker: crate::cli::output::Asker = Default::default();
1662        let (out, cap) = Output::capture();
1663        let out = out.with_asker(asker.clone());
1664        answer_with(asker, Some(2));
1665        assert!(offer_with(&registry, &catalog_dir, &declined, home.path(), &out).await);
1666        assert!(
1667            cap.err().contains("Removed your copies of weex"),
1668            "{}",
1669            cap.err()
1670        );
1671
1672        // No: kept and recorded.
1673        let (home, catalog_dir, user) = stale_setup();
1674        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1675        let declined = home.path().join("declined.json");
1676        let asker: crate::cli::output::Asker = Default::default();
1677        let (out, _) = Output::capture();
1678        let out = out.with_asker(asker.clone());
1679        answer_with(asker, Some(1));
1680        assert!(offer_with(&registry, &catalog_dir, &declined, home.path(), &out).await);
1681        assert!(user.join("weex.yaml").exists() && declined.exists());
1682
1683        // Cancelled: nothing changes.
1684        let (home, catalog_dir, user) = stale_setup();
1685        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1686        let declined = home.path().join("declined.json");
1687        let asker: crate::cli::output::Asker = Default::default();
1688        let (out, cap) = Output::capture();
1689        let out = out.with_asker(asker.clone());
1690        answer_with(asker, None);
1691        assert!(offer_with(&registry, &catalog_dir, &declined, home.path(), &out).await);
1692        assert!(cap.err().is_empty(), "{}", cap.err());
1693        assert!(user.join("weex.yaml").exists() && !declined.exists());
1694    }
1695
1696    #[tokio::test]
1697    async fn test_no_offer_without_stale_copies_or_a_way_to_ask() {
1698        let (home, catalog_dir, user) = stale_setup();
1699        let declined = home.path().join("declined.json");
1700        // Nothing stale: the catalog alone.
1701        let clean = VenueRegistry::load_dirs(&catalog_dir, &home.path().join("none")).unwrap();
1702        let (out, _) = Output::capture();
1703        assert!(!offer_with(&clean, &catalog_dir, &declined, home.path(), &out).await);
1704        // Stale, but no TUI and no terminal: not asked, nothing changes.
1705        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1706        assert!(!offer_with(&registry, &catalog_dir, &declined, home.path(), &out).await);
1707        assert!(user.join("weex.yaml").exists());
1708        // A scripted run returns before reading anything.
1709        offer_catalog_updates(true, &out).await;
1710    }
1711
1712    #[test]
1713    fn test_a_failed_move_or_removal_is_an_error_not_a_panic() {
1714        let (home, catalog_dir, user) = stale_setup();
1715        let registry = VenueRegistry::load_dirs(&catalog_dir, &user).unwrap();
1716        let stale = stale_copies(&registry, &catalog_dir, &Default::default());
1717        std::fs::remove_file(user.join("weex.yaml")).unwrap();
1718        let declined = home.path().join("declined.json");
1719        for answer in [OverrideAnswer::SaveAndReplace, OverrideAnswer::Replace] {
1720            let err = apply_override_answer(answer, &stale, home.path(), "t", &declined)
1721                .unwrap_err()
1722                .to_string();
1723            assert!(err.contains("weex.yaml"), "{err}");
1724        }
1725        // The answers file cannot be written where a folder is.
1726        std::fs::create_dir_all(&declined).unwrap();
1727        assert!(
1728            apply_override_answer(OverrideAnswer::Keep, &stale, home.path(), "t", &declined)
1729                .is_err()
1730        );
1731    }
1732}