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