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