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