Skip to main content

scope_cli/cli/
resolve.rs

1//! One resolver for every command that takes a token symbol
2//! (docs/design/2026-10-15-token-disambiguation-design.md).
3//!
4//! Steps: `--token` → an address → a saved binding (checked against a CEX
5//! venue's coin list) → the candidates → 0 stops, 1 is used, more go to
6//! the picker → the save question. It never chooses silently.
7
8use crate::cli::output::Output;
9use crate::note;
10use scope::error::{Result, ScopeError};
11use scope::market::assets::chain_for_network;
12use scope::tokens::{TokenAliases, TokenInfo};
13use std::path::Path;
14
15/// One token a symbol could mean.
16#[derive(Debug, Clone, PartialEq)]
17pub struct Candidate {
18    pub symbol: String,
19    pub name: String,
20    /// The chain, or the venue's network name for a CEX candidate.
21    pub chain: String,
22    /// The contract; `None` for a native coin or an unknown contract.
23    pub address: Option<String>,
24    pub price_usd: Option<f64>,
25    pub liquidity_usd: Option<f64>,
26}
27
28/// Where the command reads its market data.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum VenueKind {
31    Dex,
32    /// A CEX venue, by id.
33    Cex(String),
34}
35
36/// What the command knows before the lookup.
37#[derive(Debug, Clone)]
38pub struct ResolveContext {
39    /// The chain filter, when the user set one.
40    pub chain: Option<String>,
41    pub venue: VenueKind,
42    /// `--token <contract>`.
43    pub token_flag: Option<String>,
44    /// `--yes`: no save question; an ambiguous symbol stops.
45    pub yes: bool,
46    /// `--save`: save the choice without a question.
47    pub save: bool,
48}
49
50/// How the token was chosen.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Source {
53    Flag,
54    Address,
55    Binding,
56    Single,
57    Picked {
58        saved: bool,
59    },
60    /// A CEX venue that does not list contracts: the symbol is used as is.
61    Unchecked,
62}
63
64/// The token a command uses.
65#[derive(Debug, Clone, PartialEq)]
66pub struct Resolved {
67    pub symbol: String,
68    pub name: String,
69    pub chain: String,
70    pub address: Option<String>,
71    pub source: Source,
72}
73
74/// Where candidates come from: a DEX search or a venue's coin list.
75#[async_trait::async_trait]
76pub trait CandidateSource: Send + Sync {
77    async fn candidates(&self, symbol: &str, chain: Option<&str>) -> Result<Vec<Candidate>>;
78    /// What was searched, for a "not found" error.
79    fn searched(&self, chain: Option<&str>) -> String;
80    /// False for a CEX venue without a coin list: its symbols cannot be
81    /// checked against contracts.
82    fn lists_contracts(&self) -> bool {
83        true
84    }
85}
86
87/// Asks the user to choose.
88#[async_trait::async_trait]
89pub trait Picker: Send + Sync {
90    /// The index of the chosen candidate.
91    async fn pick(&self, input: &str, candidates: &[Candidate]) -> Result<usize>;
92    /// The answer to a yes/no question.
93    async fn confirm(&self, question: &str) -> Result<bool>;
94}
95
96/// The saved bindings.
97pub trait BindingStore: Send {
98    fn get(&self, symbol: &str, chain: Option<&str>) -> Option<TokenInfo>;
99    /// Every saved binding for `symbol`, on any chain.
100    fn all(&self, symbol: &str) -> Vec<TokenInfo>;
101    fn save(&mut self, candidate: &Candidate, venue: Option<&str>) -> Result<()>;
102}
103
104/// One line for a candidate in lists and errors: chain, contract, name.
105pub fn candidate_line(c: &Candidate) -> String {
106    format!(
107        "{} {} {}",
108        c.chain,
109        c.address.as_deref().unwrap_or("native"),
110        c.name
111    )
112}
113
114/// Two contracts are the same token: EVM hex compares without case.
115fn same_contract(a: &str, b: &str) -> bool {
116    if a.starts_with("0x") && b.starts_with("0x") {
117        a.eq_ignore_ascii_case(b)
118    } else {
119        a == b
120    }
121}
122
123/// A DexScreener search result as a candidate. An empty address (the CEX
124/// ticker fallback) is a token without a contract.
125pub(crate) fn dex_candidate(r: &scope::chains::dex::TokenSearchResult) -> Candidate {
126    Candidate {
127        symbol: r.symbol.clone(),
128        name: r.name.clone(),
129        chain: r.chain.clone(),
130        address: (!r.address.is_empty()).then(|| r.address.clone()),
131        price_usd: r.price_usd,
132        liquidity_usd: Some(r.liquidity_usd),
133    }
134}
135
136/// The venue's networks for `symbol`, one candidate each.
137pub(crate) fn venue_candidates_from(
138    symbol: &str,
139    assets: &[scope::market::assets::AssetInfo],
140) -> Vec<Candidate> {
141    assets
142        .iter()
143        .filter(|a| a.symbol.eq_ignore_ascii_case(symbol))
144        .flat_map(|a| {
145            a.networks.iter().map(|n| Candidate {
146                symbol: a.symbol.clone(),
147                name: a.symbol.clone(),
148                chain: n.network.clone(),
149                address: n.contract.clone(),
150                price_usd: None,
151                liquidity_usd: None,
152            })
153        })
154        .collect()
155}
156
157/// Candidates from a DexScreener search, with the CEX ticker fallback when
158/// `fallback` (its transport) is set and the search finds nothing.
159pub struct DexCandidates<'a> {
160    pub dex: &'a dyn scope::chains::DexDataSource,
161    pub out: &'a Output,
162    pub fallback: Option<std::sync::Arc<dyn scope::http::HttpClient>>,
163}
164
165#[async_trait::async_trait]
166impl CandidateSource for DexCandidates<'_> {
167    async fn candidates(&self, symbol: &str, chain: Option<&str>) -> Result<Vec<Candidate>> {
168        let mut found = self.dex.search_tokens(symbol, chain).await?;
169        // A set chain is a guarantee: never offer a token from another one.
170        if let Some(chain) = chain {
171            found.retain(|r| chain_for_network(&r.chain) == chain_for_network(chain));
172        }
173        if found.is_empty()
174            && let Some(http) = &self.fallback
175            && let Some(f) = crate::cli::token_pick::cex_fallback(
176                symbol,
177                chain.unwrap_or("ethereum"),
178                None,
179                http.clone(),
180                self.out,
181            )
182            .await
183        {
184            found.push(f);
185        }
186        Ok(found.iter().map(dex_candidate).collect())
187    }
188
189    fn searched(&self, chain: Option<&str>) -> String {
190        format!(
191            "DexScreener on {}{}",
192            chain.unwrap_or("all chains"),
193            if self.fallback.is_some() {
194                " and the CEX ticker fallback"
195            } else {
196                ""
197            }
198        )
199    }
200}
201
202/// Candidates from a CEX venue's coin list (the `assets` capability).
203pub struct VenueCandidates<'a> {
204    pub client: &'a scope::market::ExchangeClient,
205}
206
207#[async_trait::async_trait]
208impl CandidateSource for VenueCandidates<'_> {
209    async fn candidates(&self, symbol: &str, _chain: Option<&str>) -> Result<Vec<Candidate>> {
210        match self.client.fetch_assets().await {
211            Some(assets) => Ok(venue_candidates_from(symbol, &assets?)),
212            // No `assets` capability: the venue symbol is all that is known.
213            None => Ok(vec![Candidate {
214                symbol: symbol.to_uppercase(),
215                name: symbol.to_uppercase(),
216                chain: String::new(),
217                address: None,
218                price_usd: None,
219                liquidity_usd: None,
220            }]),
221        }
222    }
223
224    fn searched(&self, _chain: Option<&str>) -> String {
225        format!("the {} coin list", self.client.venue_id())
226    }
227
228    fn lists_contracts(&self) -> bool {
229        self.client.lists_assets()
230    }
231}
232
233impl BindingStore for TokenAliases {
234    fn get(&self, symbol: &str, chain: Option<&str>) -> Option<TokenInfo> {
235        TokenAliases::get(self, symbol, chain).cloned()
236    }
237
238    fn all(&self, symbol: &str) -> Vec<TokenInfo> {
239        let mut chains = self.get_chains_for_alias(symbol);
240        chains.sort_unstable();
241        chains
242            .into_iter()
243            .filter_map(|c| TokenAliases::get(self, symbol, Some(c)).cloned())
244            .collect()
245    }
246
247    fn save(&mut self, c: &Candidate, venue: Option<&str>) -> Result<()> {
248        let address = c.address.as_deref().unwrap_or("");
249        self.add_binding(&c.symbol, &c.chain, address, &c.name, venue);
250        TokenAliases::save(self)
251    }
252}
253
254/// Asks on the terminal: the numbered token list, then yes/no.
255pub struct TerminalPicker<'a> {
256    pub out: &'a Output,
257}
258
259#[async_trait::async_trait]
260impl Picker for TerminalPicker<'_> {
261    async fn pick(&self, _input: &str, cs: &[Candidate]) -> Result<usize> {
262        let table = crate::cli::token_pick::candidate_table(cs);
263        let mut input = crate::cli::prompt::stdin();
264        crate::cli::prompt::pick(&mut input, self.out, &table, "token", cs.len())
265    }
266
267    async fn confirm(&self, question: &str) -> Result<bool> {
268        confirm_from(&mut crate::cli::prompt::stdin(), self.out, question)
269    }
270}
271
272/// Asks `question` and reads the answer from `reader`: yes for `y` or
273/// `yes` in any case, no for anything else.
274fn confirm_from(reader: &mut impl std::io::BufRead, out: &Output, question: &str) -> Result<bool> {
275    let answer = crate::cli::prompt::ask(reader, out, question)?;
276    Ok(matches!(
277        answer.trim().to_ascii_lowercase().as_str(),
278        "y" | "yes"
279    ))
280}
281
282/// Asks in the TUI: puts the question in the pane's ask slot and waits for
283/// the list's answer.
284pub struct TuiPicker {
285    pub asker: crate::cli::output::Asker,
286}
287
288impl TuiPicker {
289    async fn ask(&self, prompt: String, options: Vec<String>) -> Option<usize> {
290        let (reply, answer) = tokio::sync::oneshot::channel();
291        let mut ask = Some(crate::cli::output::Ask {
292            prompt,
293            options,
294            reply,
295        });
296        // One question at a time: wait until the slot is free. The lock
297        // is held only inside the block, never across an await.
298        while ask.is_some() {
299            let placed = {
300                let mut slot = self.asker.lock().unwrap_or_else(|e| e.into_inner());
301                if slot.is_none() {
302                    *slot = ask.take();
303                    true
304                } else {
305                    false
306                }
307            };
308            if !placed {
309                // A pause, not a yield: a yield wakes the TUI's task at
310                // once, and it would spin until the open question is
311                // answered.
312                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
313            }
314        }
315        answer.await.ok().flatten()
316    }
317}
318
319#[async_trait::async_trait]
320impl Picker for TuiPicker {
321    async fn pick(&self, input: &str, cs: &[Candidate]) -> Result<usize> {
322        let prompt = format!("'{input}' matches more than one token. Choose one:");
323        self.ask(prompt, cs.iter().map(candidate_line).collect())
324            .await
325            .ok_or_else(|| {
326                ScopeError::Other(format!("Choosing a token for '{input}' was cancelled."))
327            })
328    }
329
330    async fn confirm(&self, question: &str) -> Result<bool> {
331        // The list is the answer: the terminal's "[y/N]: " does not apply.
332        let prompt = question
333            .trim_end_matches([' ', ':'])
334            .trim_end_matches("[y/N]")
335            .trim_end()
336            .to_string();
337        let options = vec!["No".to_string(), "Yes".to_string()];
338        Ok(self.ask(prompt, options).await == Some(1))
339    }
340}
341
342/// Never asks: a choice is an error that lists the candidates.
343pub struct NoTerminalPicker;
344
345#[async_trait::async_trait]
346impl Picker for NoTerminalPicker {
347    async fn pick(&self, input: &str, cs: &[Candidate]) -> Result<usize> {
348        Err(ScopeError::AmbiguousToken {
349            input: input.to_string(),
350            candidates: cs.iter().map(candidate_line).collect(),
351        })
352    }
353
354    async fn confirm(&self, _question: &str) -> Result<bool> {
355        Ok(false)
356    }
357}
358
359/// The picker for this run: never for JSON output; the TUI's list when
360/// the TUI pane shows the output; no prompt in a server or without a
361/// terminal; else the terminal.
362pub fn picker_for<'a>(out: &'a Output, json: bool) -> Box<dyn Picker + 'a> {
363    use std::io::IsTerminal as _;
364    if json {
365        return Box::new(NoTerminalPicker);
366    }
367    if let Some(asker) = out.asker() {
368        return Box::new(TuiPicker { asker });
369    }
370    let terminal = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
371    if !out.prompts_allowed() || !terminal {
372        Box::new(NoTerminalPicker)
373    } else {
374        Box::new(TerminalPicker { out })
375    }
376}
377
378/// [`resolve_logged`] with the log in the scope state folder.
379pub async fn resolve(
380    input: &str,
381    ctx: &ResolveContext,
382    source: &dyn CandidateSource,
383    picker: &dyn Picker,
384    store: &mut dyn BindingStore,
385    out: &Output,
386) -> Result<Resolved> {
387    let log = scope::paths::state_dir().join("resolutions.jsonl");
388    resolve_logged(input, ctx, source, picker, store, out, &log).await
389}
390
391/// Resolves `input`, then appends one line to `log` (the RCA trail). A
392/// log failure is a warning.
393pub async fn resolve_logged(
394    input: &str,
395    ctx: &ResolveContext,
396    source: &dyn CandidateSource,
397    picker: &dyn Picker,
398    store: &mut dyn BindingStore,
399    out: &Output,
400    log: &Path,
401) -> Result<Resolved> {
402    let mut seen = Vec::new();
403    let result = steps(input, ctx, source, picker, store, out, &mut seen).await;
404    if let Err(e) = write_log(log, input, ctx, &seen, &result) {
405        tracing::warn!("cannot write {}: {e}", log.display());
406    }
407    result
408}
409
410async fn steps(
411    input: &str,
412    ctx: &ResolveContext,
413    source: &dyn CandidateSource,
414    picker: &dyn Picker,
415    store: &mut dyn BindingStore,
416    out: &Output,
417    seen: &mut Vec<Candidate>,
418) -> Result<Resolved> {
419    let symbol = input.trim().to_uppercase();
420    // With no chain set, an address names its chain where its format
421    // does, for `--token` as for a positional address (#108).
422    let direct = |address: &str, source: Source| Resolved {
423        symbol: symbol.clone(),
424        name: symbol.clone(),
425        chain: ctx.chain.clone().unwrap_or_else(|| {
426            scope::chains::infer_chain_from_address(address)
427                .unwrap_or_default()
428                .to_string()
429        }),
430        address: Some(address.to_string()),
431        source,
432    };
433    // 1. --token, 2. an address
434    if let Some(flag) = &ctx.token_flag {
435        return Ok(direct(flag, Source::Flag));
436    }
437    if TokenAliases::is_address(input.trim()) {
438        return Ok(direct(input.trim(), Source::Address));
439    }
440    // 3. a saved binding, checked against a CEX venue's coin list. With
441    // no chain and bindings on several chains, they are the choice.
442    let saved = match ctx.chain.as_deref() {
443        None => {
444            let all = store.all(&symbol);
445            if all.len() > 1 {
446                let options: Vec<Candidate> = all.iter().map(binding_candidate).collect();
447                *seen = options.clone();
448                if ctx.yes {
449                    return Err(ScopeError::AmbiguousToken {
450                        input: input.to_string(),
451                        candidates: options.iter().map(candidate_line).collect(),
452                    });
453                }
454                Some(all[picker.pick(input, &options).await?].clone())
455            } else {
456                all.into_iter().next()
457            }
458        }
459        Some(chain) => store.get(&symbol, Some(chain)),
460    };
461    if let Some(saved) = saved {
462        if let VenueKind::Cex(venue) = &ctx.venue {
463            check_binding(&symbol, &saved, venue, source, out, seen).await?;
464        }
465        note!(
466            out,
467            "Using your saved {symbol}: {} {}",
468            saved.chain,
469            binding_label(&saved)
470        );
471        let address = binding_contract(&saved).map(str::to_string);
472        return Ok(Resolved {
473            symbol,
474            name: saved.name,
475            chain: saved.chain,
476            address,
477            source: Source::Binding,
478        });
479    }
480    // A venue without a coin list: nothing to choose from or check.
481    if !source.lists_contracts() {
482        return Ok(Resolved {
483            symbol: symbol.clone(),
484            name: symbol,
485            chain: ctx.chain.clone().unwrap_or_default(),
486            address: None,
487            source: Source::Unchecked,
488        });
489    }
490    // 4. the candidates: exact symbols, or the exact contract when the
491    // input is one that is not in a known address format
492    let candidates: Vec<Candidate> = source
493        .candidates(&symbol, ctx.chain.as_deref())
494        .await?
495        .into_iter()
496        .filter(|c| {
497            c.symbol.eq_ignore_ascii_case(&symbol)
498                || c.address
499                    .as_deref()
500                    .is_some_and(|a| a.eq_ignore_ascii_case(input.trim()))
501        })
502        .collect();
503    *seen = candidates.clone();
504    // 5. none, one, or a choice
505    let chosen = match candidates.len() {
506        0 => {
507            return Err(ScopeError::TokenNotFound {
508                input: input.to_string(),
509                searched: source.searched(ctx.chain.as_deref()),
510            });
511        }
512        1 => {
513            note!(out, "Using {}", candidate_line(&candidates[0]));
514            if ctx.save {
515                save_choice(store, &candidates[0], &ctx.venue, &symbol, out);
516            }
517            return Ok(resolved_from(&candidates[0], Source::Single));
518        }
519        _ if ctx.yes => {
520            return Err(ScopeError::AmbiguousToken {
521                input: input.to_string(),
522                candidates: candidates.iter().map(candidate_line).collect(),
523            });
524        }
525        _ => &candidates[picker.pick(input, &candidates).await?],
526    };
527    // 6. the save question
528    let chain = chain_for_network(&chosen.chain);
529    let contract = chosen.address.as_deref().unwrap_or(NATIVE_COIN);
530    let save = ctx.save
531        || picker
532            .confirm(&format!(
533                "Save so {symbol} always means {contract} on {chain}? [y/N]: "
534            ))
535            .await?;
536    let saved = save && save_choice(store, chosen, &ctx.venue, &symbol, out);
537    Ok(resolved_from(chosen, Source::Picked { saved }))
538}
539
540/// Saves `chosen` under its scope chain name. A failure is a note: the
541/// token is already chosen, so the command goes on.
542fn save_choice(
543    store: &mut dyn BindingStore,
544    chosen: &Candidate,
545    venue: &VenueKind,
546    symbol: &str,
547    out: &Output,
548) -> bool {
549    let mut binding = chosen.clone();
550    binding.chain = chain_for_network(&chosen.chain);
551    let venue = match venue {
552        VenueKind::Cex(v) => Some(v.as_str()),
553        VenueKind::Dex => None,
554    };
555    match store.save(&binding, venue) {
556        Ok(()) => {
557            note!(
558                out,
559                "Saved: {symbol} means {} on {}.",
560                binding.address.as_deref().unwrap_or(NATIVE_COIN),
561                binding.chain
562            );
563            true
564        }
565        Err(e) => {
566            note!(out, "{symbol}: your choice was not saved ({e}).");
567            false
568        }
569    }
570}
571
572/// A saved binding's contract. A native coin (a CEX pick with no
573/// contract) is saved with an empty address and has none (#108).
574fn binding_contract(t: &TokenInfo) -> Option<&str> {
575    (!t.address.is_empty()).then_some(t.address.as_str())
576}
577
578/// A saved binding's contract for a message, or "its native coin".
579fn binding_label(t: &TokenInfo) -> &str {
580    binding_contract(t).unwrap_or(NATIVE_COIN)
581}
582
583/// How a message names a token with no contract.
584const NATIVE_COIN: &str = "its native coin";
585
586/// A saved binding as a candidate, for a choice between bindings.
587fn binding_candidate(t: &TokenInfo) -> Candidate {
588    Candidate {
589        symbol: t.symbol.clone(),
590        name: t.name.clone(),
591        chain: t.chain.clone(),
592        address: binding_contract(t).map(str::to_string),
593        price_usd: None,
594        liquidity_usd: None,
595    }
596}
597
598/// Checks `saved` against a CEX venue's coin list: a different contract,
599/// or the symbol listed only on other networks, stops. A venue without a
600/// list, or a failed fetch, gives a note.
601async fn check_binding(
602    symbol: &str,
603    saved: &TokenInfo,
604    venue: &str,
605    source: &dyn CandidateSource,
606    out: &Output,
607    seen: &mut Vec<Candidate>,
608) -> Result<()> {
609    if !source.lists_contracts() {
610        note!(
611            out,
612            "{symbol}: {venue} does not list contracts, so your saved token cannot be checked."
613        );
614        return Ok(());
615    }
616    let listed = match source.candidates(symbol, None).await {
617        Ok(listed) => listed,
618        Err(e) => {
619            note!(
620                out,
621                "{symbol}: {venue} could not be checked ({e}); using your saved token."
622            );
623            return Ok(());
624        }
625    };
626    *seen = listed.clone();
627    if listed.is_empty() {
628        return Ok(());
629    }
630    let mismatch = |venue_contract: String| ScopeError::TokenMismatch {
631        symbol: symbol.to_string(),
632        chain: saved.chain.clone(),
633        saved: binding_label(saved).to_string(),
634        venue_contract,
635        venue: venue.to_string(),
636    };
637    match listed
638        .iter()
639        .find(|c| chain_for_network(&c.chain) == saved.chain)
640    {
641        Some(c) => match (&c.address, binding_contract(saved)) {
642            (Some(contract), Some(saved)) if !same_contract(contract, saved) => {
643                Err(mismatch(contract.clone()))
644            }
645            // A saved native coin that the venue lists as a contract.
646            (Some(contract), None) => Err(mismatch(contract.clone())),
647            _ => Ok(()),
648        },
649        None => {
650            let networks: Vec<&str> = listed.iter().map(|c| c.chain.as_str()).collect();
651            Err(mismatch(format!(
652                "no {symbol} on {} (it lists {})",
653                saved.chain,
654                networks.join(", ")
655            )))
656        }
657    }
658}
659
660fn resolved_from(c: &Candidate, source: Source) -> Resolved {
661    Resolved {
662        symbol: c.symbol.to_uppercase(),
663        name: c.name.clone(),
664        chain: chain_for_network(&c.chain),
665        address: c.address.clone(),
666        source,
667    }
668}
669
670fn write_log(
671    log: &Path,
672    input: &str,
673    ctx: &ResolveContext,
674    seen: &[Candidate],
675    result: &Result<Resolved>,
676) -> std::io::Result<()> {
677    use std::io::Write as _;
678    if let Some(dir) = log.parent() {
679        std::fs::create_dir_all(dir)?;
680    }
681    let venue = match &ctx.venue {
682        VenueKind::Dex => "dex".to_string(),
683        VenueKind::Cex(v) => v.clone(),
684    };
685    let mut line = serde_json::json!({
686        "at": chrono::Utc::now().to_rfc3339(),
687        "input": input,
688        "chain": ctx.chain,
689        "venue": venue,
690        "candidates": seen.iter().map(candidate_line).collect::<Vec<_>>(),
691    });
692    match result {
693        Ok(r) => {
694            line["chosen"] = serde_json::json!(r.address);
695            line["source"] = serde_json::json!(match r.source {
696                Source::Flag => "flag",
697                Source::Address => "address",
698                Source::Binding => "binding",
699                Source::Single => "single",
700                Source::Picked { .. } => "picked",
701                Source::Unchecked => "unchecked",
702            });
703            line["saved"] = serde_json::json!(matches!(r.source, Source::Picked { saved: true }));
704        }
705        Err(e) => line["error"] = serde_json::json!(e.to_string()),
706    }
707    let mut f = std::fs::OpenOptions::new()
708        .create(true)
709        .append(true)
710        .open(log)?;
711    writeln!(f, "{line}")
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use std::sync::Mutex;
718
719    fn c(chain: &str, address: &str) -> Candidate {
720        Candidate {
721            symbol: "USDN".into(),
722            name: "USDN".into(),
723            chain: chain.into(),
724            address: Some(address.into()),
725            price_usd: None,
726            liquidity_usd: None,
727        }
728    }
729
730    struct Fake(std::result::Result<Vec<Candidate>, String>);
731    #[async_trait::async_trait]
732    impl CandidateSource for Fake {
733        async fn candidates(&self, _s: &str, _c: Option<&str>) -> Result<Vec<Candidate>> {
734            match &self.0 {
735                Ok(v) => Ok(v.clone()),
736                Err(e) => Err(ScopeError::Network(e.clone())),
737            }
738        }
739        fn searched(&self, _c: Option<&str>) -> String {
740            "the fake source".into()
741        }
742    }
743
744    /// Answers with `pick` and `confirm`, and records what it was asked.
745    struct Scripted {
746        pick: Option<usize>,
747        confirm: bool,
748        asked: Mutex<Vec<String>>,
749    }
750    impl Scripted {
751        fn new(pick: Option<usize>, confirm: bool) -> Self {
752            Self {
753                pick,
754                confirm,
755                asked: Mutex::new(Vec::new()),
756            }
757        }
758        fn asked(&self) -> Vec<String> {
759            self.asked.lock().unwrap().clone()
760        }
761    }
762    #[async_trait::async_trait]
763    impl Picker for Scripted {
764        async fn pick(&self, input: &str, cs: &[Candidate]) -> Result<usize> {
765            self.asked
766                .lock()
767                .unwrap()
768                .push(format!("pick {input} {}", cs.len()));
769            self.pick.ok_or_else(|| ScopeError::AmbiguousToken {
770                input: input.into(),
771                candidates: cs.iter().map(candidate_line).collect(),
772            })
773        }
774        async fn confirm(&self, q: &str) -> Result<bool> {
775            self.asked.lock().unwrap().push(format!("confirm {q}"));
776            Ok(self.confirm)
777        }
778    }
779
780    #[derive(Default)]
781    struct Store(Vec<TokenInfo>);
782    impl BindingStore for Store {
783        fn get(&self, s: &str, chain: Option<&str>) -> Option<TokenInfo> {
784            self.0
785                .iter()
786                .find(|t| t.symbol.eq_ignore_ascii_case(s) && chain.is_none_or(|ch| t.chain == ch))
787                .cloned()
788        }
789        fn all(&self, s: &str) -> Vec<TokenInfo> {
790            self.0
791                .iter()
792                .filter(|t| t.symbol.eq_ignore_ascii_case(s))
793                .cloned()
794                .collect()
795        }
796        fn save(&mut self, c: &Candidate, venue: Option<&str>) -> Result<()> {
797            self.0
798                .push(binding(&c.chain, c.address.as_deref().unwrap_or(""), venue));
799            Ok(())
800        }
801    }
802
803    fn binding(chain: &str, address: &str, venue: Option<&str>) -> TokenInfo {
804        TokenInfo {
805            address: address.into(),
806            symbol: "USDN".into(),
807            name: "USDN".into(),
808            chain: chain.into(),
809            last_used: None,
810            venue: venue.map(str::to_string),
811        }
812    }
813
814    fn ctx(venue: VenueKind) -> ResolveContext {
815        ResolveContext {
816            chain: Some("ethereum".into()),
817            venue,
818            token_flag: None,
819            yes: false,
820            save: false,
821        }
822    }
823
824    fn cex() -> VenueKind {
825        VenueKind::Cex("weex".into())
826    }
827
828    fn out() -> Output {
829        Output::capture().0
830    }
831
832    #[tokio::test]
833    async fn test_the_token_flag_wins_over_everything() {
834        let mut store = Store(vec![binding("ethereum", "0xSAVED", None)]);
835        let mut cx = ctx(VenueKind::Dex);
836        cx.token_flag = Some("0xFLAG".into());
837        let picker = Scripted::new(None, false);
838        let r = resolve("USDN", &cx, &Fake(Ok(vec![])), &picker, &mut store, &out())
839            .await
840            .unwrap();
841        assert_eq!(r.address.as_deref(), Some("0xFLAG"));
842        assert!(matches!(r.source, Source::Flag));
843        assert!(picker.asked().is_empty());
844    }
845
846    #[tokio::test]
847    async fn test_an_address_passes_through() {
848        let addr = "0xeC747DAf41A4Ca4ebE72356592F8301924132907";
849        let picker = Scripted::new(None, false);
850        let r = resolve(
851            addr,
852            &ctx(VenueKind::Dex),
853            &Fake(Ok(vec![])),
854            &picker,
855            &mut Store::default(),
856            &out(),
857        )
858        .await
859        .unwrap();
860        assert_eq!(r.address.as_deref(), Some(addr));
861        assert!(matches!(r.source, Source::Address));
862    }
863
864    #[tokio::test]
865    async fn test_a_saved_binding_is_used_without_a_question() {
866        let mut store = Store(vec![binding("ethereum", "0xSAVED", None)]);
867        let picker = Scripted::new(None, false);
868        let two = Fake(Ok(vec![c("ethereum", "0xA"), c("ethereum", "0xB")]));
869        let r = resolve(
870            "usdn",
871            &ctx(VenueKind::Dex),
872            &two,
873            &picker,
874            &mut store,
875            &out(),
876        )
877        .await
878        .unwrap();
879        assert_eq!(r.address.as_deref(), Some("0xSAVED"));
880        assert!(matches!(r.source, Source::Binding));
881        assert!(picker.asked().is_empty());
882    }
883
884    #[tokio::test]
885    async fn test_a_binding_the_venue_contradicts_stops_with_both_contracts() {
886        let mut store = Store(vec![binding("ethereum", "0xAAA", Some("weex"))]);
887        let venue = Fake(Ok(vec![c("ETH", "0xBBB")]));
888        let err = resolve(
889            "USDN",
890            &ctx(cex()),
891            &venue,
892            &Scripted::new(None, false),
893            &mut store,
894            &out(),
895        )
896        .await
897        .unwrap_err();
898        let text = err.to_string();
899        assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{text}");
900        assert!(text.contains("0xAAA") && text.contains("0xBBB"), "{text}");
901    }
902
903    #[tokio::test]
904    async fn test_a_binding_matches_a_venue_network_by_chain() {
905        // Review focus 2: the venue calls it ERC20; the binding says ethereum.
906        let mut store = Store(vec![binding("ethereum", "0xAAA", Some("weex"))]);
907        let venue = Fake(Ok(vec![c("BEP20", "0xOTHER"), c("ERC20", "0xAAA")]));
908        let r = resolve(
909            "USDN",
910            &ctx(cex()),
911            &venue,
912            &Scripted::new(None, false),
913            &mut store,
914            &out(),
915        )
916        .await
917        .unwrap();
918        assert_eq!(r.address.as_deref(), Some("0xAAA"));
919    }
920
921    #[tokio::test]
922    async fn test_evm_contracts_compare_without_case() {
923        // Review focus 3.
924        let mut store = Store(vec![binding(
925            "ethereum",
926            "0xeC747DAf41A4Ca4ebE72356592F8301924132907",
927            None,
928        )]);
929        let venue = Fake(Ok(vec![c(
930            "ETH",
931            "0xec747daf41a4ca4ebe72356592f8301924132907",
932        )]));
933        assert!(
934            resolve(
935                "USDN",
936                &ctx(cex()),
937                &venue,
938                &Scripted::new(None, false),
939                &mut store,
940                &out()
941            )
942            .await
943            .is_ok()
944        );
945    }
946
947    #[tokio::test]
948    async fn test_a_binding_is_used_when_the_venue_check_fails() {
949        // Review focus 4: a network error must not block a saved binding.
950        let (o, cap) = Output::capture();
951        let mut store = Store(vec![binding("ethereum", "0xAAA", None)]);
952        let venue = Fake(Err("down".into()));
953        let r = resolve(
954            "USDN",
955            &ctx(cex()),
956            &venue,
957            &Scripted::new(None, false),
958            &mut store,
959            &o,
960        )
961        .await
962        .unwrap();
963        assert_eq!(r.address.as_deref(), Some("0xAAA"));
964        assert!(cap.err().contains("could not be checked"), "{}", cap.err());
965    }
966
967    #[tokio::test]
968    async fn test_no_candidates_stops_and_names_the_source() {
969        let err = resolve(
970            "USDN",
971            &ctx(VenueKind::Dex),
972            &Fake(Ok(vec![])),
973            &Scripted::new(None, false),
974            &mut Store::default(),
975            &out(),
976        )
977        .await
978        .unwrap_err();
979        assert!(matches!(err, ScopeError::TokenNotFound { .. }));
980        assert!(err.to_string().contains("the fake source"));
981    }
982
983    #[tokio::test]
984    async fn test_one_candidate_is_used_without_a_question() {
985        let picker = Scripted::new(None, false);
986        let r = resolve(
987            "USDN",
988            &ctx(VenueKind::Dex),
989            &Fake(Ok(vec![c("ethereum", "0xA")])),
990            &picker,
991            &mut Store::default(),
992            &out(),
993        )
994        .await
995        .unwrap();
996        assert_eq!(r.address.as_deref(), Some("0xA"));
997        assert!(matches!(r.source, Source::Single));
998        assert!(picker.asked().is_empty());
999    }
1000
1001    #[tokio::test]
1002    async fn test_only_exact_symbols_are_candidates() {
1003        // Review focus 1: USDNX is not USDN.
1004        let mut other = c("ethereum", "0xX");
1005        other.symbol = "USDNX".into();
1006        let picker = Scripted::new(None, false);
1007        let r = resolve(
1008            "USDN",
1009            &ctx(VenueKind::Dex),
1010            &Fake(Ok(vec![other, c("ethereum", "0xA")])),
1011            &picker,
1012            &mut Store::default(),
1013            &out(),
1014        )
1015        .await
1016        .unwrap();
1017        assert_eq!(r.address.as_deref(), Some("0xA"));
1018        assert!(picker.asked().is_empty(), "one exact match: no question");
1019    }
1020
1021    #[tokio::test]
1022    async fn test_a_pasted_contract_matches_its_candidate() {
1023        // An input that is a contract in no known address format still
1024        // means the candidate with that contract, not a symbol search miss.
1025        let mut t = c("ethereum", "0xTEST123");
1026        t.symbol = "TEST".into();
1027        let r = resolve(
1028            "0xTEST123",
1029            &ctx(VenueKind::Dex),
1030            &Fake(Ok(vec![t])),
1031            &Scripted::new(None, false),
1032            &mut Store::default(),
1033            &out(),
1034        )
1035        .await
1036        .unwrap();
1037        assert_eq!(r.address.as_deref(), Some("0xTEST123"));
1038    }
1039
1040    #[tokio::test]
1041    async fn test_two_candidates_ask_then_save_on_yes() {
1042        let mut store = Store::default();
1043        let picker = Scripted::new(Some(1), true);
1044        let r = resolve(
1045            "USDN",
1046            &ctx(cex()),
1047            &Fake(Ok(vec![c("ETH", "0xA"), c("BSC", "0xB")])),
1048            &picker,
1049            &mut store,
1050            &out(),
1051        )
1052        .await
1053        .unwrap();
1054        assert_eq!(r.address.as_deref(), Some("0xB"));
1055        assert!(matches!(r.source, Source::Picked { saved: true }));
1056        assert_eq!(store.0.len(), 1);
1057        assert_eq!(store.0[0].chain, "bsc", "saved under the scope chain name");
1058        assert_eq!(store.0[0].venue.as_deref(), Some("weex"));
1059        let asked = picker.asked();
1060        assert!(asked[1].contains("always means 0xB on bsc"), "{asked:?}");
1061    }
1062
1063    #[tokio::test]
1064    async fn test_no_saves_nothing_and_the_save_flag_skips_the_question() {
1065        let two = || Fake(Ok(vec![c("ethereum", "0xA"), c("bsc", "0xB")]));
1066        let mut store = Store::default();
1067        let picker = Scripted::new(Some(0), false);
1068        resolve(
1069            "USDN",
1070            &ctx(VenueKind::Dex),
1071            &two(),
1072            &picker,
1073            &mut store,
1074            &out(),
1075        )
1076        .await
1077        .unwrap();
1078        assert!(store.0.is_empty());
1079
1080        // --save answers yes without a question.
1081        let mut cx = ctx(VenueKind::Dex);
1082        cx.save = true;
1083        let picker = Scripted::new(Some(0), false);
1084        resolve("USDN", &cx, &two(), &picker, &mut store, &out())
1085            .await
1086            .unwrap();
1087        assert_eq!(store.0.len(), 1);
1088        assert!(!picker.asked().iter().any(|a| a.starts_with("confirm")));
1089    }
1090
1091    #[tokio::test]
1092    async fn test_yes_with_an_ambiguous_symbol_stops_with_the_candidates() {
1093        // Jon, 2026-10-15: --yes never picks the first match.
1094        let mut cx = ctx(VenueKind::Dex);
1095        cx.yes = true;
1096        let err = resolve(
1097            "USDN",
1098            &cx,
1099            &Fake(Ok(vec![c("ethereum", "0xA"), c("bsc", "0xB")])),
1100            &Scripted::new(Some(0), true),
1101            &mut Store::default(),
1102            &out(),
1103        )
1104        .await
1105        .unwrap_err();
1106        let text = err.to_string();
1107        assert!(matches!(err, ScopeError::AmbiguousToken { .. }), "{text}");
1108        assert!(
1109            text.contains("ethereum 0xA") && text.contains("bsc 0xB"),
1110            "{text}"
1111        );
1112    }
1113
1114    #[tokio::test]
1115    async fn test_no_terminal_picker_lists_every_candidate_and_the_hint() {
1116        let err = NoTerminalPicker
1117            .pick("USDN", &[c("ethereum", "0xA"), c("bsc", "0xB")])
1118            .await
1119            .unwrap_err();
1120        let text = err.to_string();
1121        assert!(
1122            text.contains("ethereum 0xA") && text.contains("bsc 0xB"),
1123            "{text}"
1124        );
1125        assert!(text.contains("--token"), "{text}");
1126        assert!(!NoTerminalPicker.confirm("save?").await.unwrap());
1127    }
1128
1129    #[tokio::test]
1130    async fn test_pane_commands_never_use_the_terminal_picker() {
1131        // Review focus 5: in the TUI pane a prompt would read the TUI's
1132        // stdin and hang.
1133        let (o, _cap) = Output::capture_merged();
1134        assert!(o.is_tui());
1135        let p = picker_for(&o, false);
1136        let r = p
1137            .pick("USDN", &[c("ethereum", "0xA"), c("bsc", "0xB")])
1138            .await;
1139        assert!(matches!(r, Err(ScopeError::AmbiguousToken { .. })));
1140    }
1141
1142    #[tokio::test]
1143    async fn test_a_server_output_never_prompts() {
1144        // scope web passes stdio output; a request must not wait on stdin.
1145        let o = Output::stdio().without_prompts();
1146        let p = picker_for(&o, false);
1147        let r = p
1148            .pick("USDN", &[c("ethereum", "0xA"), c("bsc", "0xB")])
1149            .await;
1150        assert!(matches!(r, Err(ScopeError::AmbiguousToken { .. })));
1151    }
1152
1153    #[tokio::test]
1154    async fn test_picker_for_json_never_prompts() {
1155        // A JSON run must not wait for stdin.
1156        let (o, _cap) = Output::capture();
1157        let p = picker_for(&o, true);
1158        let r = p
1159            .pick("USDN", &[c("ethereum", "0xA"), c("bsc", "0xB")])
1160            .await;
1161        assert!(matches!(r, Err(ScopeError::AmbiguousToken { .. })));
1162    }
1163
1164    #[test]
1165    fn test_venue_candidates_are_one_per_network() {
1166        use scope::market::assets::{AssetInfo, AssetNetwork};
1167        let assets = vec![
1168            AssetInfo {
1169                symbol: "USDN".into(),
1170                networks: vec![
1171                    AssetNetwork {
1172                        network: "ETH".into(),
1173                        contract: Some("0xA".into()),
1174                    },
1175                    AssetNetwork {
1176                        network: "BSC".into(),
1177                        contract: Some("0xB".into()),
1178                    },
1179                ],
1180            },
1181            AssetInfo {
1182                symbol: "BTC".into(),
1183                networks: vec![],
1184            },
1185        ];
1186        let cs = venue_candidates_from("usdn", &assets);
1187        assert_eq!(cs.len(), 2);
1188        assert_eq!(cs[0].address.as_deref(), Some("0xA"));
1189        assert_eq!(cs[0].chain, "ETH", "the venue's name; resolve maps it");
1190    }
1191
1192    #[test]
1193    fn test_dex_results_become_candidates_with_liquidity() {
1194        let r = scope::chains::dex::TokenSearchResult {
1195            address: "0xA".into(),
1196            symbol: "USDN".into(),
1197            name: "USDN".into(),
1198            chain: "ethereum".into(),
1199            price_usd: None,
1200            volume_24h: 0.0,
1201            liquidity_usd: 5.0,
1202            market_cap: None,
1203        };
1204        assert_eq!(dex_candidate(&r).liquidity_usd, Some(5.0));
1205        // The CEX ticker fallback has no address: no contract.
1206        let mut f = r.clone();
1207        f.address = String::new();
1208        assert_eq!(dex_candidate(&f).address, None);
1209    }
1210
1211    #[test]
1212    fn test_the_alias_file_is_a_binding_store() {
1213        // This writes the test process's one tokens.yaml, which other
1214        // tests read (a saved choice for their symbol settles an ambiguity
1215        // they expect), so it saves a symbol no other test uses.
1216        let symbol = "BINDING_STORE_TEST";
1217        let mut aliases = TokenAliases::default();
1218        let store: &mut dyn BindingStore = &mut aliases;
1219        let candidate = Candidate {
1220            symbol: symbol.into(),
1221            ..c("bsc", "0xB")
1222        };
1223        store.save(&candidate, Some("weex")).unwrap();
1224        let got = store.get(symbol, Some("bsc")).unwrap();
1225        assert_eq!(
1226            (got.address.as_str(), got.venue.as_deref()),
1227            ("0xB", Some("weex"))
1228        );
1229        let on_disk = TokenAliases::load().get(symbol, Some("bsc")).cloned();
1230        aliases.remove(symbol, None);
1231        let _ = TokenAliases::save(&aliases);
1232        assert_eq!(on_disk.map(|t| t.address).as_deref(), Some("0xB"));
1233    }
1234
1235    #[tokio::test]
1236    async fn test_save_flag_saves_a_single_candidate_too() {
1237        // Review I2: --save must save even when there was nothing to pick.
1238        let mut store = Store::default();
1239        let mut cx = ctx(VenueKind::Dex);
1240        cx.save = true;
1241        resolve(
1242            "USDN",
1243            &cx,
1244            &Fake(Ok(vec![c("ethereum", "0xA")])),
1245            &Scripted::new(None, false),
1246            &mut store,
1247            &out(),
1248        )
1249        .await
1250        .unwrap();
1251        assert_eq!(store.0.len(), 1);
1252    }
1253
1254    #[tokio::test]
1255    async fn test_bindings_on_several_chains_are_a_choice_without_a_chain() {
1256        // Review I3: no chain set, USDN saved on bsc and solana: ask, never
1257        // pick one by map order.
1258        let mut store = Store(vec![
1259            binding("bsc", "0xB", None),
1260            binding("solana", "So1", None),
1261        ]);
1262        let mut cx = ctx(VenueKind::Dex);
1263        cx.chain = None;
1264        let picker = Scripted::new(Some(1), true);
1265        let r = resolve("USDN", &cx, &Fake(Ok(vec![])), &picker, &mut store, &out())
1266            .await
1267            .unwrap();
1268        assert_eq!(r.address.as_deref(), Some("So1"));
1269        assert!(
1270            picker.asked().iter().any(|a| a.starts_with("pick USDN 2")),
1271            "{:?}",
1272            picker.asked()
1273        );
1274        assert_eq!(store.0.len(), 2, "no new binding: both are saved already");
1275    }
1276
1277    #[tokio::test]
1278    async fn test_a_binding_hit_says_which_token() {
1279        let (o, cap) = Output::capture();
1280        let mut store = Store(vec![binding("ethereum", "0xSAVED", None)]);
1281        resolve(
1282            "USDN",
1283            &ctx(VenueKind::Dex),
1284            &Fake(Ok(vec![])),
1285            &Scripted::new(None, false),
1286            &mut store,
1287            &o,
1288        )
1289        .await
1290        .unwrap();
1291        assert!(cap.err().contains("0xSAVED"), "{}", cap.err());
1292    }
1293
1294    #[tokio::test]
1295    async fn test_a_venue_without_the_bindings_chain_stops() {
1296        // Review I4: the venue lists USDN, but not on the saved chain.
1297        let mut store = Store(vec![binding("ethereum", "0xAAA", None)]);
1298        let venue = Fake(Ok(vec![c("TRC20", "TXYZ")]));
1299        let err = resolve(
1300            "USDN",
1301            &ctx(cex()),
1302            &venue,
1303            &Scripted::new(None, false),
1304            &mut store,
1305            &out(),
1306        )
1307        .await
1308        .unwrap_err();
1309        assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{err}");
1310        assert!(err.to_string().contains("TRC20"), "{err}");
1311    }
1312
1313    #[tokio::test]
1314    async fn test_a_token_flag_with_no_chain_takes_the_address_chain() {
1315        // #108 1: `--token <address>` infers the chain, as a positional
1316        // address does.
1317        let ctx = |token: &str| ResolveContext {
1318            chain: None,
1319            token_flag: Some(token.into()),
1320            ..ctx(VenueKind::Dex)
1321        };
1322        for (token, chain) in [
1323            ("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum"),
1324            ("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "tron"),
1325        ] {
1326            let r = resolve(
1327                "USDC",
1328                &ctx(token),
1329                &Fake(Ok(vec![])),
1330                &Scripted::new(None, false),
1331                &mut Store::default(),
1332                &out(),
1333            )
1334            .await
1335            .unwrap();
1336            assert_eq!(r.chain, chain, "{token}");
1337        }
1338        // A chain the user set still wins.
1339        let r = resolve(
1340            "USDC",
1341            &ResolveContext {
1342                chain: Some("base".into()),
1343                ..ctx("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
1344            },
1345            &Fake(Ok(vec![])),
1346            &Scripted::new(None, false),
1347            &mut Store::default(),
1348            &out(),
1349        )
1350        .await
1351        .unwrap();
1352        assert_eq!(r.chain, "base");
1353    }
1354
1355    #[tokio::test]
1356    async fn test_a_saved_native_coin_has_no_contract_and_says_so() {
1357        // #108 2: a native-coin binding is saved with an empty address.
1358        let native = |chain: &str| Candidate {
1359            address: None,
1360            ..c(chain, "")
1361        };
1362        let (o, cap) = Output::capture();
1363        let mut store = Store(vec![binding("ethereum", "", Some("weex"))]);
1364        let r = resolve(
1365            "USDN",
1366            &ctx(cex()),
1367            &Fake(Ok(vec![native("ETH")])),
1368            &Scripted::new(None, false),
1369            &mut store,
1370            &o,
1371        )
1372        .await
1373        .unwrap();
1374        assert_eq!(r.address, None, "no empty contract");
1375        assert!(
1376            cap.err().contains("ethereum its native coin"),
1377            "{}",
1378            cap.err()
1379        );
1380
1381        // The venue now lists a contract on that chain: a mismatch that
1382        // names the native coin, never an empty contract.
1383        let mut store = Store(vec![binding("ethereum", "", Some("weex"))]);
1384        let err = resolve(
1385            "USDN",
1386            &ctx(cex()),
1387            &Fake(Ok(vec![c("ETH", "0xAAA")])),
1388            &Scripted::new(None, false),
1389            &mut store,
1390            &out(),
1391        )
1392        .await
1393        .unwrap_err();
1394        assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{err}");
1395        assert!(err.to_string().contains("its native coin"), "{err}");
1396        assert!(!err.to_string().contains("is , "), "{err}");
1397    }
1398
1399    #[test]
1400    fn test_the_terminal_confirm_takes_y_and_yes_in_any_case() {
1401        // #108 4.
1402        for (answer, yes) in [
1403            ("y\n", true),
1404            ("yes\n", true),
1405            ("Y\n", true),
1406            (" YES \n", true),
1407            ("\n", false),
1408            ("n\n", false),
1409            ("yep\n", false),
1410            ("", false),
1411        ] {
1412            let mut reader = std::io::Cursor::new(answer.as_bytes());
1413            assert_eq!(
1414                confirm_from(&mut reader, &out(), "Save? [y/N]: ").unwrap(),
1415                yes,
1416                "{answer:?}"
1417            );
1418        }
1419    }
1420
1421    /// A CEX venue that does not list contracts (no `assets`).
1422    struct Unlisted;
1423    #[async_trait::async_trait]
1424    impl CandidateSource for Unlisted {
1425        async fn candidates(&self, _s: &str, _c: Option<&str>) -> Result<Vec<Candidate>> {
1426            Ok(vec![])
1427        }
1428        fn searched(&self, _c: Option<&str>) -> String {
1429            "nothing".into()
1430        }
1431        fn lists_contracts(&self) -> bool {
1432            false
1433        }
1434    }
1435
1436    #[tokio::test]
1437    async fn test_a_venue_that_lists_no_contracts_is_not_checked_and_says_so() {
1438        // Review I5: no false "Using … native" line; a binding gets the
1439        // spec's "cannot be checked" note.
1440        let (o, cap) = Output::capture();
1441        let r = resolve(
1442            "USDC",
1443            &ctx(cex()),
1444            &Unlisted,
1445            &Scripted::new(None, false),
1446            &mut Store::default(),
1447            &o,
1448        )
1449        .await
1450        .unwrap();
1451        assert!(matches!(r.source, Source::Unchecked));
1452        assert!(!cap.err().contains("native"), "{}", cap.err());
1453        let (o, cap) = Output::capture();
1454        let mut store = Store(vec![binding("ethereum", "0xAAA", None)]);
1455        resolve(
1456            "USDN",
1457            &ctx(cex()),
1458            &Unlisted,
1459            &Scripted::new(None, false),
1460            &mut store,
1461            &o,
1462        )
1463        .await
1464        .unwrap();
1465        assert!(cap.err().contains("cannot be checked"), "{}", cap.err());
1466    }
1467
1468    /// A store whose save always fails.
1469    struct ReadOnly;
1470    impl BindingStore for ReadOnly {
1471        fn get(&self, _s: &str, _c: Option<&str>) -> Option<TokenInfo> {
1472            None
1473        }
1474        fn all(&self, _s: &str) -> Vec<TokenInfo> {
1475            Vec::new()
1476        }
1477        fn save(&mut self, _c: &Candidate, _v: Option<&str>) -> Result<()> {
1478            Err(ScopeError::Other("read-only".into()))
1479        }
1480    }
1481
1482    #[tokio::test]
1483    async fn test_a_failed_save_warns_and_keeps_the_choice() {
1484        // Review I7: the token was chosen; a save failure must not fail
1485        // the command.
1486        let (o, cap) = Output::capture();
1487        let r = resolve(
1488            "USDN",
1489            &ctx(VenueKind::Dex),
1490            &Fake(Ok(vec![c("ethereum", "0xA"), c("bsc", "0xB")])),
1491            &Scripted::new(Some(0), true),
1492            &mut ReadOnly,
1493            &o,
1494        )
1495        .await
1496        .unwrap();
1497        assert_eq!(r.address.as_deref(), Some("0xA"));
1498        assert!(matches!(r.source, Source::Picked { saved: false }));
1499        assert!(cap.err().contains("not saved"), "{}", cap.err());
1500    }
1501
1502    /// Answers the next question in `asker` with `answer`.
1503    fn answer(asker: &crate::cli::output::Asker, answer: Option<usize>) {
1504        let asker = asker.clone();
1505        tokio::spawn(async move {
1506            loop {
1507                let ask = asker.lock().unwrap().take();
1508                if let Some(ask) = ask {
1509                    let _ = ask.reply.send(answer);
1510                    return;
1511                }
1512                tokio::task::yield_now().await;
1513            }
1514        });
1515    }
1516
1517    #[tokio::test]
1518    async fn test_the_tui_picker_asks_through_the_slot() {
1519        let asker = crate::cli::output::Asker::default();
1520        let p = TuiPicker {
1521            asker: asker.clone(),
1522        };
1523        answer(&asker, Some(1));
1524        let cs = [c("ethereum", "0xA"), c("bsc", "0xB")];
1525        assert_eq!(p.pick("USDN", &cs).await.unwrap(), 1);
1526        answer(&asker, Some(1));
1527        assert!(
1528            p.confirm("Save so …? [y/N]: ").await.unwrap(),
1529            "option 1 is Yes"
1530        );
1531    }
1532
1533    #[tokio::test]
1534    async fn test_the_save_list_title_has_no_terminal_hint() {
1535        let asker = crate::cli::output::Asker::default();
1536        let p = TuiPicker {
1537            asker: asker.clone(),
1538        };
1539        let slot = asker.clone();
1540        let title = tokio::spawn(async move {
1541            loop {
1542                let ask = slot.lock().unwrap().take();
1543                if let Some(ask) = ask {
1544                    let _ = ask.reply.send(Some(0));
1545                    return ask.prompt;
1546                }
1547                tokio::task::yield_now().await;
1548            }
1549        });
1550        assert!(!p.confirm("Save so X? [y/N]: ").await.unwrap());
1551        assert_eq!(title.await.unwrap(), "Save so X?");
1552    }
1553
1554    #[tokio::test]
1555    async fn test_escape_on_the_choice_is_a_cancel() {
1556        let asker = crate::cli::output::Asker::default();
1557        let p = TuiPicker {
1558            asker: asker.clone(),
1559        };
1560        answer(&asker, None);
1561        let cs = [c("ethereum", "0xA"), c("bsc", "0xB")];
1562        let err = p.pick("USDN", &cs).await.unwrap_err();
1563        assert!(err.to_string().contains("cancelled"), "{err}");
1564        answer(&asker, None);
1565        assert!(
1566            !p.confirm("Save?").await.unwrap(),
1567            "Esc on the save question is No"
1568        );
1569    }
1570
1571    #[tokio::test]
1572    async fn test_a_pane_output_with_an_asker_gets_the_tui_picker() {
1573        let (o, _cap) = Output::capture_merged();
1574        let asker = crate::cli::output::Asker::default();
1575        let o = o.with_asker(asker.clone());
1576        let cs = [c("ethereum", "0xA"), c("bsc", "0xB")];
1577        let p = picker_for(&o, false);
1578        answer(&asker, Some(0));
1579        assert_eq!(p.pick("USDN", &cs).await.unwrap(), 0);
1580        // JSON still never asks.
1581        let p = picker_for(&o, true);
1582        assert!(p.pick("USDN", &cs).await.is_err());
1583    }
1584
1585    #[tokio::test]
1586    async fn test_each_resolution_writes_one_log_line() {
1587        let dir = tempfile::tempdir().unwrap();
1588        let log = dir.path().join("resolutions.jsonl");
1589        let picker = Scripted::new(None, false);
1590        resolve_logged(
1591            "USDN",
1592            &ctx(VenueKind::Dex),
1593            &Fake(Ok(vec![c("ethereum", "0xA")])),
1594            &picker,
1595            &mut Store::default(),
1596            &out(),
1597            &log,
1598        )
1599        .await
1600        .unwrap();
1601        let _ = resolve_logged(
1602            "NOPE",
1603            &ctx(VenueKind::Dex),
1604            &Fake(Ok(vec![])),
1605            &picker,
1606            &mut Store::default(),
1607            &out(),
1608            &log,
1609        )
1610        .await;
1611        let lines: Vec<serde_json::Value> = std::fs::read_to_string(&log)
1612            .unwrap()
1613            .lines()
1614            .map(|l| serde_json::from_str(l).unwrap())
1615            .collect();
1616        assert_eq!(lines.len(), 2);
1617        assert_eq!(lines[0]["source"], "single");
1618        assert_eq!(lines[0]["chosen"], "0xA");
1619        assert!(lines[1]["error"].as_str().unwrap().contains("NOPE"));
1620    }
1621}