1use 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#[derive(Debug, Subcommand)]
35pub enum VenuesCommands {
36 List(ListArgs),
49
50 Schema(SchemaArgs),
62
63 Update(UpdateArgs),
77
78 Init(InitArgs),
91
92 Validate(ValidateArgs),
104}
105
106#[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 #[arg(long, value_name = "URL")]
115 pub url: Option<String>,
116}
117
118#[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 #[arg(short, long, default_value = "table")]
126 pub format: ListFormat,
127}
128
129#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
131pub enum ListFormat {
132 #[default]
134 Table,
135 Json,
137}
138
139#[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 #[arg(short, long, default_value = "text")]
147 pub format: SchemaFormat,
148}
149
150#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
152pub enum SchemaFormat {
153 #[default]
155 Text,
156 Json,
158}
159
160#[derive(Debug, Args)]
162#[command(after_help = "\x1b[1mExamples:\x1b[0m
163 scope venues init
164 scope venues init --force")]
165pub struct InitArgs {
166 #[arg(long)]
168 pub force: bool,
169}
170
171#[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 pub file: std::path::PathBuf,
179}
180
181pub 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
211async 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
242pub 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
258async 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
284fn run_list(args: ListArgs, out: &Output) -> Result<()> {
289 run_list_with(&VenueRegistry::load()?, args, out)
290}
291
292fn 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 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
364fn 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 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 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
573fn run_init(args: InitArgs, out: &Output) -> Result<()> {
578 run_init_impl(args, VenueRegistry::user_venues_dir(), out)
579}
580
581fn run_init_impl(args: InitArgs, dest: std::path::PathBuf, out: &Output) -> Result<()> {
583 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 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
650fn 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 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 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#[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 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 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 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 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 let args = InitArgs { force: false };
906 let result = run_init_impl(args, dest.clone(), &quiet());
907 assert!(result.is_ok());
908
909 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 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()); }
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 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 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 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 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 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 ®istry,
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}