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