Skip to main content

scope_cli/cli/
interactive.rs

1//! # Interactive Mode
2//!
3//! `scope interactive` (also `scope tui` and `scope shell`) opens the
4//! unified TUI in [`crate::cli::tui`]. This module keeps its arguments and
5//! the [`SessionContext`] that persists between commands. The chain
6//! defaults to `auto`, meaning each command infers the chain from its
7//! input (e.g., `0x…` → Ethereum/EVM, `T…` → Tron, base58 → Solana).
8//! Users can pin a chain with `chain solana` and unlock with `chain auto`.
9//!
10//! ## Usage
11//!
12//! ```bash
13//! scope interactive
14//!
15//! scope:auto> address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
16//! # Chain: ethereum (auto-detected)
17//!
18//! scope:auto> address DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy
19//! # Chain: solana (auto-detected)
20//!
21//! scope:auto> chain solana
22//! # Chain pinned to: solana
23//!
24//! scope:solana> address 7xKXtg...
25//! # Uses solana chain
26//! ```
27
28use clap::Args;
29use scope::chains::ChainClientFactory;
30use scope::config::{Config, OutputFormat};
31use scope::error::Result;
32use serde::{Deserialize, Serialize};
33use std::fmt;
34use std::path::PathBuf;
35
36/// Arguments for the interactive command.
37#[derive(Debug, Clone, Args)]
38#[command(after_help = "\x1b[1mExamples:\x1b[0m
39  scope interactive
40  scope shell
41  scope interactive --no-banner")]
42pub struct InteractiveArgs {
43    /// Skip displaying the banner on startup.
44    #[arg(long)]
45    pub no_banner: bool,
46}
47
48/// Session context that persists between commands in interactive mode.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SessionContext {
51    /// Current blockchain network. `"auto"` means infer from input at command time.
52    pub chain: String,
53
54    /// Current output format.
55    pub format: OutputFormat,
56
57    /// Last analyzed address (for quick re-analysis).
58    pub last_address: Option<String>,
59
60    /// Last analyzed transaction hash.
61    pub last_tx: Option<String>,
62
63    /// Include token balances in address analysis.
64    pub include_tokens: bool,
65
66    /// Include transactions in address analysis.
67    pub include_txs: bool,
68
69    /// Include internal transactions in tx analysis.
70    pub trace: bool,
71
72    /// Decode transaction input data.
73    pub decode: bool,
74
75    /// Transaction limit for queries.
76    pub limit: u32,
77}
78
79impl SessionContext {
80    /// Returns `true` when chain is in auto-detect mode (not pinned to a specific chain).
81    pub fn is_auto_chain(&self) -> bool {
82        self.chain == "auto"
83    }
84}
85
86impl Default for SessionContext {
87    fn default() -> Self {
88        Self {
89            chain: "auto".to_string(),
90            format: OutputFormat::Table,
91            last_address: None,
92            last_tx: None,
93            include_tokens: false,
94            include_txs: false,
95            trace: false,
96            decode: false,
97            limit: 100,
98        }
99    }
100}
101
102impl fmt::Display for SessionContext {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        writeln!(f, "Current Context:")?;
105        if self.is_auto_chain() {
106            writeln!(f, "  Chain:          auto (inferred from input)")?;
107        } else {
108            writeln!(f, "  Chain:          {} (pinned)", self.chain)?;
109        }
110        writeln!(f, "  Format:         {:?}", self.format)?;
111        writeln!(f, "  Include Tokens: {}", self.include_tokens)?;
112        writeln!(f, "  Include TXs:    {}", self.include_txs)?;
113        writeln!(f, "  Trace:          {}", self.trace)?;
114        writeln!(f, "  Decode:         {}", self.decode)?;
115        writeln!(f, "  Limit:          {}", self.limit)?;
116        if let Some(ref addr) = self.last_address {
117            writeln!(f, "  Last Address:   {}", addr)?;
118        }
119        if let Some(ref tx) = self.last_tx {
120            writeln!(f, "  Last TX:        {}", tx)?;
121        }
122        Ok(())
123    }
124}
125
126impl SessionContext {
127    /// Returns the path to the session context file.
128    fn context_path() -> Option<PathBuf> {
129        scope::paths::data_dir().map(|p| p.join("scope").join("session.yaml"))
130    }
131
132    /// Loads session context from file, or returns default if not found.
133    pub fn load() -> Self {
134        Self::context_path()
135            .map(|path| Self::load_from(&path))
136            .unwrap_or_default()
137    }
138
139    /// Loads session context from `path`. A missing or unreadable file is
140    /// the default context.
141    pub fn load_from(path: &std::path::Path) -> Self {
142        std::fs::read_to_string(path)
143            .ok()
144            .and_then(|contents| serde_yaml::from_str(&contents).ok())
145            .unwrap_or_default()
146    }
147
148    /// Saves session context to file.
149    pub fn save(&self) -> Result<()> {
150        match Self::context_path() {
151            Some(path) => self.save_to(&path),
152            None => Ok(()),
153        }
154    }
155
156    /// Saves session context to `path`, creating its directory.
157    pub fn save_to(&self, path: &std::path::Path) -> Result<()> {
158        if let Some(parent) = path.parent() {
159            std::fs::create_dir_all(parent)?;
160        }
161        let contents = serde_yaml::to_string(self)
162            .map_err(|e| scope::error::ScopeError::Export(e.to_string()))?;
163        std::fs::write(path, contents)?;
164        Ok(())
165    }
166}
167
168/// Opens the unified TUI. Returns how it ended.
169///
170/// # Errors
171///
172/// Returns the TUI's terminal or save error.
173pub async fn run(
174    args: InteractiveArgs,
175    config: &Config,
176    clients: &dyn ChainClientFactory,
177) -> Result<crate::cli::tui::TuiExit> {
178    crate::cli::tui::run(args.no_banner, config, clients).await
179}
180
181// ============================================================================
182// Unit Tests
183// ============================================================================
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    #[test]
189    fn test_session_context_default() {
190        let ctx = SessionContext::default();
191        assert_eq!(ctx.chain, "auto");
192        assert_eq!(ctx.format, OutputFormat::Table);
193        assert!(!ctx.include_tokens);
194        assert!(!ctx.include_txs);
195        assert!(!ctx.trace);
196        assert!(!ctx.decode);
197        assert_eq!(ctx.limit, 100);
198        assert!(ctx.last_address.is_none());
199        assert!(ctx.last_tx.is_none());
200    }
201    #[test]
202    fn test_session_context_display() {
203        let ctx = SessionContext::default();
204        let display = format!("{}", ctx);
205        assert!(display.contains("auto"));
206        assert!(display.contains("Table"));
207    }
208    #[test]
209    fn test_interactive_args_default() {
210        let args = InteractiveArgs { no_banner: false };
211        assert!(!args.no_banner);
212    }
213    #[test]
214    fn test_session_context_serialization() {
215        let ctx = SessionContext {
216            chain: "polygon".to_string(),
217            format: OutputFormat::Json,
218            last_address: Some("0xabc".to_string()),
219            last_tx: Some("0xdef".to_string()),
220            include_tokens: true,
221            include_txs: true,
222            trace: true,
223            decode: true,
224            limit: 50,
225        };
226
227        let yaml = serde_yaml::to_string(&ctx).unwrap();
228        let deserialized: SessionContext = serde_yaml::from_str(&yaml).unwrap();
229        assert_eq!(deserialized.chain, "polygon");
230        assert!(!deserialized.is_auto_chain());
231        assert_eq!(deserialized.format, OutputFormat::Json);
232        assert_eq!(deserialized.last_address.as_deref(), Some("0xabc"));
233        assert_eq!(deserialized.last_tx.as_deref(), Some("0xdef"));
234        assert!(deserialized.include_tokens);
235        assert!(deserialized.include_txs);
236        assert!(deserialized.trace);
237        assert!(deserialized.decode);
238        assert_eq!(deserialized.limit, 50);
239    }
240    #[test]
241    fn test_session_context_display_with_address_and_tx() {
242        let ctx = SessionContext {
243            chain: "polygon".to_string(),
244            last_address: Some("0x1234".to_string()),
245            last_tx: Some("0xabcd".to_string()),
246            ..Default::default()
247        };
248        let display = format!("{}", ctx);
249        assert!(display.contains("0x1234"));
250        assert!(display.contains("0xabcd"));
251        assert!(display.contains("(pinned)"));
252    }
253    #[test]
254    fn test_session_context_display_auto_chain() {
255        let ctx = SessionContext::default();
256        let display = format!("{}", ctx);
257        assert!(display.contains("auto"));
258        assert!(display.contains("inferred from input"));
259    }
260    #[test]
261    fn test_session_context_serialization_roundtrip() {
262        let ctx = SessionContext {
263            chain: "solana".to_string(),
264            include_tokens: true,
265            limit: 25,
266            last_address: Some("0xtest".to_string()),
267            ..Default::default()
268        };
269
270        let yaml = serde_yaml::to_string(&ctx).unwrap();
271        let deserialized: SessionContext = serde_yaml::from_str(&yaml).unwrap();
272        assert_eq!(deserialized.chain, "solana");
273        assert!(deserialized.include_tokens);
274        assert_eq!(deserialized.limit, 25);
275        assert_eq!(deserialized.last_address, Some("0xtest".to_string()));
276    }
277    #[test]
278    fn test_session_context_is_auto_chain() {
279        let auto_ctx = SessionContext::default();
280        assert!(auto_ctx.is_auto_chain());
281        let pinned_ctx = SessionContext {
282            chain: "ethereum".to_string(),
283            ..Default::default()
284        };
285        assert!(!pinned_ctx.is_auto_chain());
286    }
287
288    #[test]
289    fn test_session_context_save_to_and_load_from() {
290        // The TUI restores the pinned chain and toggles on the next start.
291        let dir = tempfile::tempdir().unwrap();
292        let path = dir.path().join("nested").join("session.yaml");
293        let ctx = SessionContext {
294            chain: "solana".into(),
295            include_txs: true,
296            last_address: Some("addr".into()),
297            ..Default::default()
298        };
299        ctx.save_to(&path).unwrap();
300        let back = SessionContext::load_from(&path);
301        assert_eq!(back.chain, "solana");
302        assert!(back.include_txs);
303        assert_eq!(back.last_address.as_deref(), Some("addr"));
304    }
305
306    #[test]
307    fn test_session_save_and_load_use_the_test_sandbox() {
308        // save() and load() resolve through scope::paths, which test builds
309        // sandbox: this writes no user file (an old test that called save()
310        // pinned a user's real TUI session to solana).
311        let path = SessionContext::context_path().unwrap();
312        assert!(path.starts_with(std::env::temp_dir()), "{}", path.display());
313        let ctx = SessionContext {
314            chain: "base".into(),
315            limit: 7,
316            ..Default::default()
317        };
318        ctx.save().unwrap();
319        let back = SessionContext::load();
320        assert_eq!(back.chain, "base");
321        assert_eq!(back.limit, 7);
322    }
323
324    #[test]
325    fn test_session_context_load_from_missing_or_corrupt_is_default() {
326        let dir = tempfile::tempdir().unwrap();
327        assert!(SessionContext::load_from(&dir.path().join("none.yaml")).is_auto_chain());
328        let bad = dir.path().join("bad.yaml");
329        std::fs::write(&bad, "chain: [unclosed").unwrap();
330        assert!(SessionContext::load_from(&bad).is_auto_chain());
331    }
332}