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 ~/.scope-bca/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 {
518 let mut lines = Vec::new();
520 lines.push(format!("id: {}", desc.id));
521 lines.push(format!("name: \"{}\"", desc.name));
522 lines.push(format!("base_url: \"{}\"", desc.base_url));
523
524 lines.push("symbol:".to_string());
525 lines.push(format!(" template: \"{}\"", desc.symbol.template));
526 lines.push(format!(" default_quote: {}", desc.symbol.default_quote));
527 let case_str = match desc.symbol.case {
528 scope::market::descriptor::SymbolCase::Upper => "upper",
529 scope::market::descriptor::SymbolCase::Lower => "lower",
530 };
531 lines.push(format!(" case: {}", case_str));
532
533 lines.push("capabilities:".to_string());
534
535 if let Some(ref ep) = desc.capabilities.order_book {
536 lines.push(" order_book:".to_string());
537 append_endpoint_yaml(&mut lines, ep, " ");
538 }
539 if let Some(ref ep) = desc.capabilities.ticker {
540 lines.push(" ticker:".to_string());
541 append_endpoint_yaml(&mut lines, ep, " ");
542 }
543 if let Some(ref ep) = desc.capabilities.trades {
544 lines.push(" trades:".to_string());
545 append_endpoint_yaml(&mut lines, ep, " ");
546 }
547
548 lines.join("\n")
549}
550
551fn append_endpoint_yaml(
552 lines: &mut Vec<String>,
553 ep: &scope::market::descriptor::EndpointDescriptor,
554 indent: &str,
555) {
556 let method = match ep.method {
557 scope::market::descriptor::HttpMethod::GET => "GET",
558 scope::market::descriptor::HttpMethod::POST => "POST",
559 };
560 if method != "GET" {
561 lines.push(format!("{indent}method: {method}"));
562 }
563 lines.push(format!("{indent}path: \"{}\"", ep.path));
564
565 if !ep.params.is_empty() {
566 lines.push(format!("{indent}params:"));
567 let mut params: Vec<_> = ep.params.iter().collect();
568 params.sort_by_key(|(k, _)| (*k).clone());
569 for (k, v) in params {
570 lines.push(format!("{indent} {k}: \"{v}\""));
571 }
572 }
573
574 if let Some(ref body) = ep.request_body {
575 lines.push(format!(
576 "{indent}request_body: {}",
577 serde_json::to_string(body).unwrap_or_default()
578 ));
579 }
580
581 if let Some(ref root) = ep.response_root {
582 lines.push(format!("{indent}response_root: \"{}\"", root));
583 }
584
585 lines.push(format!("{indent}response:"));
586 let r = &ep.response;
587 let resp_indent = format!("{indent} ");
588 if let Some(ref v) = r.asks_key {
589 lines.push(format!("{resp_indent}asks_key: {v}"));
590 }
591 if let Some(ref v) = r.bids_key {
592 lines.push(format!("{resp_indent}bids_key: {v}"));
593 }
594 if let Some(ref v) = r.level_format {
595 lines.push(format!("{resp_indent}level_format: {v}"));
596 }
597 if let Some(ref v) = r.level_price_field {
598 lines.push(format!("{resp_indent}level_price_field: {v}"));
599 }
600 if let Some(ref v) = r.level_size_field {
601 lines.push(format!("{resp_indent}level_size_field: {v}"));
602 }
603 if let Some(ref v) = r.last_price {
604 lines.push(format!("{resp_indent}last_price: {v}"));
605 }
606 if let Some(ref v) = r.high_24h {
607 lines.push(format!("{resp_indent}high_24h: {v}"));
608 }
609 if let Some(ref v) = r.low_24h {
610 lines.push(format!("{resp_indent}low_24h: {v}"));
611 }
612 if let Some(ref v) = r.volume_24h {
613 lines.push(format!("{resp_indent}volume_24h: {v}"));
614 }
615 if let Some(ref v) = r.quote_volume_24h {
616 lines.push(format!("{resp_indent}quote_volume_24h: {v}"));
617 }
618 if let Some(ref v) = r.best_bid {
619 lines.push(format!("{resp_indent}best_bid: {v}"));
620 }
621 if let Some(ref v) = r.best_ask {
622 lines.push(format!("{resp_indent}best_ask: {v}"));
623 }
624 if let Some(ref v) = r.items_key {
625 lines.push(format!("{resp_indent}items_key: {v}"));
626 }
627 if let Some(ref f) = r.filter {
628 lines.push(format!("{resp_indent}filter:"));
629 lines.push(format!("{resp_indent} field: \"{}\"", f.field));
630 lines.push(format!("{resp_indent} value: \"{}\"", f.value));
631 }
632 if let Some(ref v) = r.price {
633 lines.push(format!("{resp_indent}price: {v}"));
634 }
635 if let Some(ref v) = r.quantity {
636 lines.push(format!("{resp_indent}quantity: {v}"));
637 }
638 if let Some(ref v) = r.quote_quantity {
639 lines.push(format!("{resp_indent}quote_quantity: {v}"));
640 }
641 if let Some(ref v) = r.timestamp_ms {
642 lines.push(format!("{resp_indent}timestamp_ms: {v}"));
643 }
644 if let Some(ref v) = r.id {
645 lines.push(format!("{resp_indent}id: {v}"));
646 }
647 if let Some(ref sm) = r.side {
648 lines.push(format!("{resp_indent}side:"));
649 lines.push(format!("{resp_indent} field: \"{}\"", sm.field));
650 if !sm.mapping.is_empty() {
651 lines.push(format!("{resp_indent} mapping:"));
652 let mut entries: Vec<_> = sm.mapping.iter().collect();
653 entries.sort_by_key(|(k, _)| (*k).clone());
654 for (k, v) in entries {
655 lines.push(format!("{resp_indent} \"{k}\": \"{v}\""));
656 }
657 }
658 }
659}
660
661fn run_validate(args: ValidateArgs, out: &Output) -> Result<()> {
666 let path = &args.file;
667
668 if !path.exists() {
669 outln!(
670 out,
671 "{}",
672 check_fail(&format!("File not found: {}", path.display()))
673 )?;
674 return Err(scope::error::ScopeError::Chain(format!(
675 "File not found: {}",
676 path.display()
677 )));
678 }
679
680 let content = std::fs::read_to_string(path).map_err(|e| {
681 scope::error::ScopeError::io_context(e, format!("Failed to read {}", path.display()))
682 })?;
683
684 outln!(out, "{}", section_header("Venue Validation"))?;
685 outln!(out, "{}", kv_row("File", &path.display().to_string()))?;
686 outln!(out, "{}", separator())?;
687
688 match VenueRegistry::validate_yaml(&content) {
689 Ok(desc) => {
690 outln!(out, "{}", check_pass("Valid YAML syntax"))?;
691 outln!(out, "{}", check_pass(&format!("Venue ID: {}", desc.id)))?;
692 outln!(out, "{}", check_pass(&format!("Name: {}", desc.name)))?;
693 outln!(
694 out,
695 "{}",
696 check_pass(&format!("Base URL: {}", desc.base_url))
697 )?;
698
699 let caps = desc.capability_names();
701 if caps.is_empty() {
702 outln!(
703 out,
704 "{}",
705 check_fail(
706 "No capabilities defined (need at least one of: order_book, ticker, trades)"
707 )
708 )?;
709 } else {
710 for cap in &caps {
711 outln!(out, "{}", check_pass(&format!("Capability: {}", cap)))?;
712 }
713 }
714
715 if desc.symbol.template.contains("{base}") {
717 outln!(
718 out,
719 "{}",
720 check_pass("Symbol template contains {base} placeholder")
721 )?;
722 } else {
723 outln!(
724 out,
725 "{}",
726 check_fail("Symbol template missing {base} placeholder")
727 )?;
728 }
729
730 outln!(out, "{}", separator())?;
731 if caps.is_empty() {
732 outln!(out, "{}", check_fail("Validation completed with warnings"))?;
733 } else {
734 outln!(out, "{}", check_pass("Validation passed"))?;
735 }
736 outln!(out, "{}", section_footer())?;
737 Ok(())
738 }
739 Err(e) => {
740 outln!(out, "{}", check_fail("Invalid YAML"))?;
741 outln!(out, "{}", check_fail(&format!("Error: {}", e)))?;
742 outln!(out, "{}", separator())?;
743 outln!(
744 out,
745 "{}",
746 kv_row(
747 "Hint",
748 "Run `scope venues schema` to see the expected format"
749 )
750 )?;
751 outln!(out, "{}", section_footer())?;
752 Err(e)
753 }
754 }
755}
756
757#[cfg(test)]
762mod tests {
763 use super::*;
764
765 fn quiet() -> Output {
766 Output::capture().0
767 }
768
769 #[test]
770 fn test_list_format_default() {
771 let fmt = ListFormat::default();
772 assert!(matches!(fmt, ListFormat::Table));
773 }
774
775 #[test]
776 fn test_schema_format_default() {
777 let fmt = SchemaFormat::default();
778 assert!(matches!(fmt, SchemaFormat::Text));
779 }
780
781 #[test]
782 fn test_run_list_table() {
783 let args = ListArgs {
784 format: ListFormat::Table,
785 };
786 let result = run_list(args, &quiet());
787 assert!(result.is_ok());
788 }
789
790 #[test]
791 fn test_run_list_json() {
792 let args = ListArgs {
793 format: ListFormat::Json,
794 };
795 let (o, cap) = Output::capture();
796 run_list(args, &o).unwrap();
797 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
799 assert!(v["venues"].as_array().is_some_and(|a| !a.is_empty()));
800 assert_eq!(
801 v["total"].as_u64().unwrap() as usize,
802 v["venues"].as_array().unwrap().len()
803 );
804 assert!(
806 v["venues"]
807 .as_array()
808 .unwrap()
809 .iter()
810 .any(|venue| venue["kind"] == "dex")
811 );
812 }
813
814 #[test]
815 fn test_run_schema_text() {
816 let args = SchemaArgs {
817 format: SchemaFormat::Text,
818 };
819 let result = run_schema(args, &quiet());
820 assert!(result.is_ok());
821 }
822
823 #[test]
824 fn test_run_schema_json() {
825 let args = SchemaArgs {
826 format: SchemaFormat::Json,
827 };
828 let (o, cap) = Output::capture();
829 run_schema(args, &o).unwrap();
830 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
831 assert!(v.is_object());
832 }
833
834 #[test]
835 fn test_validate_missing_file() {
836 let args = ValidateArgs {
837 file: std::path::PathBuf::from("/tmp/nonexistent_venue_test.yaml"),
838 };
839 let result = run_validate(args, &quiet());
840 assert!(result.is_err());
841 }
842
843 #[test]
844 fn test_validate_valid_file() {
845 let yaml = r#"
846id: test_venue
847name: Test Exchange
848base_url: https://api.test.com
849symbol:
850 template: "{base}{quote}"
851 default_quote: USDT
852capabilities:
853 order_book:
854 path: /depth
855 params:
856 symbol: "{pair}"
857 response:
858 asks_key: asks
859 bids_key: bids
860 level_format: positional
861"#;
862 let dir = tempfile::tempdir().unwrap();
863 let path = dir.path().join("test.yaml");
864 std::fs::write(&path, yaml).unwrap();
865
866 let args = ValidateArgs { file: path };
867 let (o, cap) = Output::capture();
868 run_validate(args, &o).unwrap();
869 assert!(cap.out().contains("Venue ID: test_venue"));
870 assert!(cap.out().contains("Validation passed"));
871 }
872
873 #[test]
874 fn test_validate_invalid_file() {
875 let yaml = "this is not valid yaml: [";
876 let dir = tempfile::tempdir().unwrap();
877 let path = dir.path().join("bad.yaml");
878 std::fs::write(&path, yaml).unwrap();
879
880 let args = ValidateArgs { file: path };
881 let result = run_validate(args, &quiet());
882 assert!(result.is_err());
883 }
884
885 #[test]
886 fn test_count_user_venues_nonexistent() {
887 let count = count_user_venues(std::path::Path::new("/tmp/nonexistent_dir_test"));
888 assert_eq!(count, 0);
889 }
890
891 #[test]
892 fn test_count_user_venues_with_files() {
893 let dir = tempfile::tempdir().unwrap();
894 std::fs::write(dir.path().join("a.yaml"), "").unwrap();
895 std::fs::write(dir.path().join("b.yml"), "").unwrap();
896 std::fs::write(dir.path().join("c.txt"), "").unwrap();
897 assert_eq!(count_user_venues(dir.path()), 2);
898 }
899
900 #[test]
901 fn test_serialize_descriptor_yaml_roundtrip() {
902 let yaml = r#"
903id: roundtrip_test
904name: Roundtrip Exchange
905base_url: https://api.roundtrip.com
906symbol:
907 template: "{base}_{quote}"
908 default_quote: USDT
909 case: lower
910capabilities:
911 order_book:
912 path: /api/depth
913 params:
914 symbol: "{pair}"
915 response:
916 asks_key: asks
917 bids_key: bids
918 level_format: positional
919"#;
920 let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
921 let serialized = serialize_descriptor_yaml(&desc);
922 assert!(serialized.contains("roundtrip_test"));
923 assert!(serialized.contains("Roundtrip Exchange"));
924 assert!(serialized.contains("/api/depth"));
925 assert!(serialized.contains("case: lower"));
926 }
927
928 #[test]
929 fn test_run_init_to_temp_dir() {
930 let dir = tempfile::tempdir().unwrap();
931 let dest = dir.path().to_path_buf();
932 let args = InitArgs { force: true };
933 let result = run_init_impl(args, dest.clone(), &quiet());
934 assert!(result.is_ok());
935
936 let registry = VenueRegistry::load().unwrap();
938 for id in registry.list() {
939 let filename = format!("{}.yaml", id);
940 let target = dest.join(&filename);
941 assert!(target.exists(), "Expected {} to exist", filename);
942 let content = std::fs::read_to_string(&target).unwrap();
943 assert!(content.contains(&format!("id: {}", id)));
944 }
945 }
946
947 #[test]
948 fn test_serialize_full_descriptor() {
949 let yaml = r#"
950id: full_caps
951name: Full Capabilities Exchange
952base_url: https://api.full.com
953symbol:
954 template: "{base}{quote}"
955 default_quote: USDT
956capabilities:
957 order_book:
958 path: /api/depth
959 params:
960 symbol: "{pair}"
961 response:
962 asks_key: asks
963 bids_key: bids
964 level_format: positional
965 ticker:
966 path: /api/ticker
967 params:
968 symbol: "{pair}"
969 response:
970 last_price: lastPrice
971 high_24h: high
972 low_24h: low
973 trades:
974 path: /api/trades
975 params:
976 symbol: "{pair}"
977 limit: "{limit}"
978 response:
979 items_key: data
980 price: price
981 quantity: qty
982 timestamp_ms: time
983 side:
984 field: side
985 mapping:
986 buy: buy
987 sell: sell
988"#;
989 let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
990 let serialized = serialize_descriptor_yaml(&desc);
991 assert!(serialized.contains("order_book:"));
992 assert!(serialized.contains("ticker:"));
993 assert!(serialized.contains("trades:"));
994 assert!(serialized.contains("asks_key"));
995 assert!(serialized.contains("last_price"));
996 assert!(serialized.contains("items_key"));
997 }
998
999 #[test]
1000 fn test_serialize_descriptor_with_post_and_request_body() {
1001 let yaml = r#"
1002id: post_venue
1003name: POST Exchange
1004base_url: https://api.post.com
1005symbol:
1006 template: "{base}{quote}"
1007 default_quote: USDT
1008capabilities:
1009 order_book:
1010 method: POST
1011 path: /api/depth
1012 request_body: {"symbol":"{{pair}}"}
1013 response_root: result
1014 params: {}
1015 response:
1016 asks_key: asks
1017 bids_key: bids
1018 level_format: positional
1019"#;
1020 let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
1021 let serialized = serialize_descriptor_yaml(&desc);
1022 assert!(serialized.contains("method: POST"));
1023 assert!(serialized.contains("response_root"));
1024 assert!(serialized.contains("request_body"));
1025 }
1026
1027 #[test]
1028 fn test_serialize_descriptor_with_filter_and_side() {
1029 let yaml = r#"
1030id: filter_venue
1031name: Filter Exchange
1032base_url: https://api.filter.com
1033symbol:
1034 template: "{base}{quote}"
1035 default_quote: USDT
1036capabilities:
1037 trades:
1038 path: /api/trades
1039 params:
1040 symbol: "{pair}"
1041 response:
1042 items_key: trades
1043 filter:
1044 field: symbol
1045 value: "{pair}"
1046 side:
1047 field: side
1048 mapping:
1049 buy: B
1050 sell: S
1051"#;
1052 let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
1053 let serialized = serialize_descriptor_yaml(&desc);
1054 assert!(serialized.contains("filter:"));
1055 assert!(serialized.contains("field:"));
1056 assert!(serialized.contains("side:"));
1057 assert!(serialized.contains("mapping:"));
1058 }
1059
1060 #[test]
1061 fn test_run_init_skips_existing() {
1062 let dir = tempfile::tempdir().unwrap();
1063 let dest = dir.path().to_path_buf();
1064
1065 let existing_path = dest.join("binance.yaml");
1067 std::fs::create_dir_all(&dest).unwrap();
1068 let original_content = "id: binance\n# pre-existing file\n";
1069 std::fs::write(&existing_path, original_content).unwrap();
1070
1071 let args = InitArgs { force: false };
1073 let result = run_init_impl(args, dest.clone(), &quiet());
1074 assert!(result.is_ok());
1075
1076 let content = std::fs::read_to_string(&existing_path).unwrap();
1078 assert_eq!(
1079 content, original_content,
1080 "Existing file should not be overwritten when force=false"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_run_init_creates_directory_when_missing() {
1086 let dir = tempfile::tempdir().unwrap();
1087 let dest = dir.path().join("nested").join("venues");
1088 assert!(!dest.exists());
1090 let args = InitArgs { force: true };
1091 let result = run_init_impl(args, dest.clone(), &quiet());
1092 assert!(result.is_ok());
1093 assert!(dest.exists());
1094 assert!(dest.is_dir());
1095 }
1096
1097 #[test]
1098 fn test_validate_file_template_missing_base() {
1099 let yaml = r#"
1100id: bad_template
1101name: Bad Template Exchange
1102base_url: https://api.test.com
1103symbol:
1104 template: "nobase{quote}"
1105 default_quote: USDT
1106capabilities:
1107 order_book:
1108 path: /depth
1109 params:
1110 symbol: "{pair}"
1111 response:
1112 asks_key: asks
1113 bids_key: bids
1114 level_format: positional
1115"#;
1116 let dir = tempfile::tempdir().unwrap();
1117 let path = dir.path().join("bad_template.yaml");
1118 std::fs::write(&path, yaml).unwrap();
1119
1120 let args = ValidateArgs { file: path };
1121 let result = run_validate(args, &quiet());
1122 assert!(result.is_ok()); }
1124
1125 #[test]
1126 fn test_validate_file_empty_capabilities() {
1127 let yaml = r#"
1128id: no_caps
1129name: No Capabilities
1130base_url: https://api.test.com
1131symbol:
1132 template: "{base}{quote}"
1133 default_quote: USDT
1134capabilities: {}
1135"#;
1136 let dir = tempfile::tempdir().unwrap();
1137 let path = dir.path().join("no_caps.yaml");
1138 std::fs::write(&path, yaml).unwrap();
1139
1140 let args = ValidateArgs { file: path };
1141 let result = run_validate(args, &quiet());
1142 assert!(result.is_ok());
1143 }
1144
1145 #[test]
1146 fn test_run_venues_command_routes_to_subcommands() {
1147 let result = run(
1148 VenuesCommands::List(ListArgs {
1149 format: ListFormat::Table,
1150 }),
1151 &quiet(),
1152 );
1153 assert!(result.is_ok());
1154 let result = run(
1155 VenuesCommands::Schema(SchemaArgs {
1156 format: SchemaFormat::Text,
1157 }),
1158 &quiet(),
1159 );
1160 assert!(result.is_ok());
1161 }
1162}