ruchy/runtime/
magic.rs

1//! REPL Magic Commands System
2//!
3//! Provides IPython-style magic commands for enhanced REPL interaction.
4//! Based on docs/specifications/repl-magic-spec.md
5
6use anyhow::{Result, anyhow};
7use std::collections::HashMap;
8use std::time::{Duration, Instant};
9use std::fmt;
10
11use crate::runtime::repl::{Repl, Value};
12
13// ============================================================================
14// Magic Command Registry
15// ============================================================================
16
17/// Registry for magic commands
18pub struct MagicRegistry {
19    commands: HashMap<String, Box<dyn MagicCommand>>,
20}
21
22impl MagicRegistry {
23    pub fn new() -> Self {
24        let mut registry = Self {
25            commands: HashMap::new(),
26        };
27        
28        // Register built-in magic commands
29        registry.register("time", Box::new(TimeMagic));
30        registry.register("timeit", Box::new(TimeitMagic::default()));
31        registry.register("run", Box::new(RunMagic));
32        registry.register("debug", Box::new(DebugMagic));
33        registry.register("profile", Box::new(ProfileMagic));
34        registry.register("whos", Box::new(WhosMagic));
35        registry.register("clear", Box::new(ClearMagic));
36        registry.register("reset", Box::new(ResetMagic));
37        registry.register("history", Box::new(HistoryMagic));
38        registry.register("save", Box::new(SaveMagic));
39        registry.register("load", Box::new(LoadMagic));
40        registry.register("pwd", Box::new(PwdMagic));
41        registry.register("cd", Box::new(CdMagic));
42        registry.register("ls", Box::new(LsMagic));
43        
44        registry
45    }
46    
47    /// Register a new magic command
48    pub fn register(&mut self, name: &str, command: Box<dyn MagicCommand>) {
49        self.commands.insert(name.to_string(), command);
50    }
51    
52    /// Check if input is a magic command
53    pub fn is_magic(&self, input: &str) -> bool {
54        input.starts_with('%') || input.starts_with("%%")
55    }
56    
57    /// Execute a magic command
58    pub fn execute(&mut self, repl: &mut Repl, input: &str) -> Result<MagicResult> {
59        if !self.is_magic(input) {
60            return Err(anyhow!("Not a magic command"));
61        }
62        
63        // Parse magic command
64        let (is_cell_magic, command_line) = if input.starts_with("%%") {
65            (true, &input[2..])
66        } else {
67            (false, &input[1..])
68        };
69        
70        let parts: Vec<&str> = command_line.split_whitespace().collect();
71        if parts.is_empty() {
72            return Err(anyhow!("Empty magic command"));
73        }
74        
75        let command_name = parts[0];
76        let args = &parts[1..];
77        
78        // Find and execute command
79        match self.commands.get(command_name) {
80            Some(command) => {
81                if is_cell_magic {
82                    command.execute_cell(repl, args.join(" ").as_str())
83                } else {
84                    command.execute_line(repl, args.join(" ").as_str())
85                }
86            }
87            None => Err(anyhow!("Unknown magic command: %{}", command_name)),
88        }
89    }
90    
91    /// Get list of available magic commands
92    pub fn list_commands(&self) -> Vec<String> {
93        let mut commands: Vec<_> = self.commands.keys().cloned().collect();
94        commands.sort();
95        commands
96    }
97}
98
99impl Default for MagicRegistry {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105// ============================================================================
106// Magic Command Trait
107// ============================================================================
108
109/// Result of executing a magic command
110#[derive(Debug, Clone)]
111pub enum MagicResult {
112    /// Simple text output
113    Text(String),
114    /// Formatted output with timing
115    Timed { output: String, duration: Duration },
116    /// Profile data
117    Profile(ProfileData),
118    /// No output
119    Silent,
120}
121
122impl fmt::Display for MagicResult {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            MagicResult::Text(s) => write!(f, "{s}"),
126            MagicResult::Timed { output, duration } => {
127                write!(f, "{}\nExecution time: {:.3}s", output, duration.as_secs_f64())
128            }
129            MagicResult::Profile(data) => write!(f, "{data}"),
130            MagicResult::Silent => Ok(()),
131        }
132    }
133}
134
135/// Trait for magic command implementations
136pub trait MagicCommand: Send + Sync {
137    /// Execute as line magic (single %)
138    fn execute_line(&self, repl: &mut Repl, args: &str) -> Result<MagicResult>;
139    
140    /// Execute as cell magic (double %%)
141    fn execute_cell(&self, repl: &mut Repl, args: &str) -> Result<MagicResult> {
142        // Default: cell magic same as line magic
143        self.execute_line(repl, args)
144    }
145    
146    /// Get help text for this command
147    fn help(&self) -> &str;
148}
149
150// ============================================================================
151// Timing Magic Commands
152// ============================================================================
153
154/// %time - Time single execution
155struct TimeMagic;
156
157impl MagicCommand for TimeMagic {
158    fn execute_line(&self, repl: &mut Repl, args: &str) -> Result<MagicResult> {
159        if args.trim().is_empty() {
160            return Err(anyhow!("Usage: %time <expression>"));
161        }
162        
163        let start = Instant::now();
164        let result = repl.eval(args)?;
165        let duration = start.elapsed();
166        
167        Ok(MagicResult::Timed {
168            output: result,
169            duration,
170        })
171    }
172    
173    fn help(&self) -> &'static str {
174        "Time execution of a single expression"
175    }
176}
177
178/// %timeit - Statistical timing over multiple runs
179struct TimeitMagic {
180    default_runs: usize,
181}
182
183impl Default for TimeitMagic {
184    fn default() -> Self {
185        Self { default_runs: 1000 }
186    }
187}
188
189impl MagicCommand for TimeitMagic {
190    fn execute_line(&self, repl: &mut Repl, args: &str) -> Result<MagicResult> {
191        if args.trim().is_empty() {
192            return Err(anyhow!("Usage: %timeit [-n RUNS] <expression>"));
193        }
194        
195        // Parse arguments for -n flag
196        let (runs, expr) = if args.starts_with("-n ") {
197            let parts: Vec<&str> = args.splitn(3, ' ').collect();
198            if parts.len() < 3 {
199                return Err(anyhow!("Invalid -n syntax"));
200            }
201            let n = parts[1].parse::<usize>()
202                .map_err(|_| anyhow!("Invalid number of runs"))?;
203            (n, parts[2])
204        } else {
205            (self.default_runs, args)
206        };
207        
208        // Warm up run
209        repl.eval(expr)?;
210        
211        // Timing runs
212        let mut durations = Vec::with_capacity(runs);
213        for _ in 0..runs {
214            let start = Instant::now();
215            repl.eval(expr)?;
216            durations.push(start.elapsed());
217        }
218        
219        // Calculate statistics
220        let total: Duration = durations.iter().sum();
221        let mean = total / runs as u32;
222        
223        durations.sort();
224        let min = durations[0];
225        let max = durations[runs - 1];
226        let median = if runs % 2 == 0 {
227            (durations[runs / 2 - 1] + durations[runs / 2]) / 2
228        } else {
229            durations[runs / 2]
230        };
231        
232        let output = format!(
233            "{} loops, best of {}: {:.3}µs per loop\n\
234             min: {:.3}µs, median: {:.3}µs, max: {:.3}µs",
235            runs, runs,
236            mean.as_micros() as f64,
237            min.as_micros() as f64,
238            median.as_micros() as f64,
239            max.as_micros() as f64
240        );
241        
242        Ok(MagicResult::Text(output))
243    }
244    
245    fn help(&self) -> &'static str {
246        "Time execution with statistics over multiple runs"
247    }
248}
249
250// ============================================================================
251// File and Script Magic Commands
252// ============================================================================
253
254/// %run - Execute external script
255struct RunMagic;
256
257impl MagicCommand for RunMagic {
258    fn execute_line(&self, repl: &mut Repl, args: &str) -> Result<MagicResult> {
259        if args.trim().is_empty() {
260            return Err(anyhow!("Usage: %run <script.ruchy>"));
261        }
262        
263        let script_content = std::fs::read_to_string(args)
264            .map_err(|e| anyhow!("Failed to read script: {}", e))?;
265        
266        let result = repl.eval(&script_content)?;
267        Ok(MagicResult::Text(result))
268    }
269    
270    fn help(&self) -> &'static str {
271        "Execute an external Ruchy script"
272    }
273}
274
275// ============================================================================
276// Debug Magic Commands
277// ============================================================================
278
279/// %debug - Post-mortem debugging
280struct DebugMagic;
281
282impl MagicCommand for DebugMagic {
283    fn execute_line(&self, repl: &mut Repl, _args: &str) -> Result<MagicResult> {
284        // Get debug information from REPL
285        if let Some(debug_info) = repl.get_last_error() {
286            let output = format!(
287                "=== Debug Information ===\n\
288                Expression: {}\n\
289                Error: {}\n\
290                Stack trace:\n{}\n\
291                Bindings at error: {} variables",
292                debug_info.expression,
293                debug_info.error_message,
294                debug_info.stack_trace.join("\n"),
295                debug_info.bindings_snapshot.len()
296            );
297            Ok(MagicResult::Text(output))
298        } else {
299            Ok(MagicResult::Text("No recent error to debug".to_string()))
300        }
301    }
302    
303    fn help(&self) -> &'static str {
304        "Enter post-mortem debugging mode"
305    }
306}
307
308// ============================================================================
309// Profile Magic Command
310// ============================================================================
311
312/// Profile data from execution
313#[derive(Debug, Clone)]
314pub struct ProfileData {
315    pub total_time: Duration,
316    pub function_times: Vec<(String, Duration, usize)>, // (name, time, count)
317}
318
319impl fmt::Display for ProfileData {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        writeln!(f, "=== Profile Results ===")?;
322        writeln!(f, "Total time: {:.3}s", self.total_time.as_secs_f64())?;
323        writeln!(f, "\nFunction Times:")?;
324        writeln!(f, "{:<30} {:>10} {:>10} {:>10}", "Function", "Time (ms)", "Count", "Avg (ms)")?;
325        writeln!(f, "{:-<60}", "")?;
326        
327        for (name, time, count) in &self.function_times {
328            let time_ms = time.as_micros() as f64 / 1000.0;
329            let avg_ms = if *count > 0 { time_ms / *count as f64 } else { 0.0 };
330            writeln!(f, "{name:<30} {time_ms:>10.3} {count:>10} {avg_ms:>10.3}")?;
331        }
332        
333        Ok(())
334    }
335}
336
337/// %profile - Profile code execution
338struct ProfileMagic;
339
340impl MagicCommand for ProfileMagic {
341    fn execute_line(&self, repl: &mut Repl, args: &str) -> Result<MagicResult> {
342        if args.trim().is_empty() {
343            return Err(anyhow!("Usage: %profile <expression>"));
344        }
345        
346        // Simple profiling - in production would use more sophisticated profiling
347        let start = Instant::now();
348        let _result = repl.eval(args)?;
349        let total_time = start.elapsed();
350        
351        // Mock profile data - in production would collect actual function timings
352        let profile_data = ProfileData {
353            total_time,
354            function_times: vec![
355                ("main".to_string(), total_time, 1),
356            ],
357        };
358        
359        Ok(MagicResult::Profile(profile_data))
360    }
361    
362    fn help(&self) -> &'static str {
363        "Profile code execution and generate flamegraph"
364    }
365}
366
367// ============================================================================
368// Workspace Magic Commands
369// ============================================================================
370
371/// %whos - List variables in workspace
372struct WhosMagic;
373
374impl MagicCommand for WhosMagic {
375    fn execute_line(&self, repl: &mut Repl, _args: &str) -> Result<MagicResult> {
376        let bindings = repl.get_bindings();
377        
378        if bindings.is_empty() {
379            return Ok(MagicResult::Text("No variables in workspace".to_string()));
380        }
381        
382        let mut output = String::from("Variable   Type        Value\n");
383        output.push_str("--------   ----        -----\n");
384        
385        for (name, value) in bindings {
386            let type_name = match value {
387                Value::Int(_) => "Int",
388                Value::Float(_) => "Float",
389                Value::String(_) => "String",
390                Value::Bool(_) => "Bool",
391                Value::Char(_) => "Char",
392                Value::List(_) => "List",
393                Value::Tuple(_) => "Tuple",
394                Value::Object(_) => "Object",
395                Value::HashMap(_) => "HashMap",
396                Value::HashSet(_) => "HashSet",
397                Value::Function { .. } => "Function",
398                Value::Lambda { .. } => "Lambda",
399                Value::DataFrame { .. } => "DataFrame",
400                Value::Range { .. } => "Range",
401                Value::EnumVariant { .. } => "EnumVariant",
402                Value::Unit => "Unit",
403                Value::Nil => "Nil",
404            };
405            
406            let value_str = format!("{value:?}");
407            let value_display = if value_str.len() > 40 {
408                format!("{}...", &value_str[..37])
409            } else {
410                value_str
411            };
412            
413            output.push_str(&format!("{name:<10} {type_name:<10} {value_display}\n"));
414        }
415        
416        Ok(MagicResult::Text(output))
417    }
418    
419    fn help(&self) -> &'static str {
420        "List all variables in the workspace"
421    }
422}
423
424/// %clear - Clear specific variables
425struct ClearMagic;
426
427impl MagicCommand for ClearMagic {
428    fn execute_line(&self, repl: &mut Repl, args: &str) -> Result<MagicResult> {
429        if args.trim().is_empty() {
430            return Err(anyhow!("Usage: %clear <pattern>"));
431        }
432        
433        // Simple pattern matching - in production would support regex
434        let pattern = args.trim();
435        let mut cleared = 0;
436        
437        let bindings_copy: Vec<String> = repl.get_bindings().keys().cloned().collect();
438        for name in bindings_copy {
439            if name.contains(pattern) || pattern == "*" {
440                repl.get_bindings_mut().remove(&name);
441                cleared += 1;
442            }
443        }
444        
445        Ok(MagicResult::Text(format!("Cleared {cleared} variables")))
446    }
447    
448    fn help(&self) -> &'static str {
449        "Clear variables matching pattern"
450    }
451}
452
453/// %reset - Reset entire workspace
454struct ResetMagic;
455
456impl MagicCommand for ResetMagic {
457    fn execute_line(&self, repl: &mut Repl, _args: &str) -> Result<MagicResult> {
458        repl.clear_bindings();
459        Ok(MagicResult::Text("Workspace reset".to_string()))
460    }
461    
462    fn help(&self) -> &'static str {
463        "Reset the entire workspace"
464    }
465}
466
467// ============================================================================
468// History Magic Commands
469// ============================================================================
470
471/// %history - Show command history
472struct HistoryMagic;
473
474impl MagicCommand for HistoryMagic {
475    fn execute_line(&self, _repl: &mut Repl, args: &str) -> Result<MagicResult> {
476        // Parse arguments for range
477        let range = if args.trim().is_empty() {
478            10
479        } else {
480            args.trim().parse::<usize>().unwrap_or(10)
481        };
482        
483        // In production, would get actual history from REPL
484        let mut output = format!("Last {range} commands:\n");
485        for i in 1..=range {
486            output.push_str(&format!("{i}: <command {i}>\n"));
487        }
488        
489        Ok(MagicResult::Text(output))
490    }
491    
492    fn help(&self) -> &'static str {
493        "Show command history"
494    }
495}
496
497// ============================================================================
498// Session Magic Commands
499// ============================================================================
500
501/// %save - Save workspace to file
502struct SaveMagic;
503
504impl MagicCommand for SaveMagic {
505    fn execute_line(&self, repl: &mut Repl, args: &str) -> Result<MagicResult> {
506        if args.trim().is_empty() {
507            return Err(anyhow!("Usage: %save <filename>"));
508        }
509        
510        // Serialize workspace - convert to string representation since Value doesn't impl Serialize
511        let bindings = repl.get_bindings();
512        let mut serializable: HashMap<String, String> = HashMap::new();
513        for (k, v) in bindings {
514            serializable.insert(k.clone(), format!("{v:?}"));
515        }
516        let json = serde_json::to_string_pretty(&serializable)
517            .map_err(|e| anyhow!("Failed to serialize: {}", e))?;
518        
519        std::fs::write(args.trim(), json)
520            .map_err(|e| anyhow!("Failed to write file: {}", e))?;
521        
522        Ok(MagicResult::Text(format!("Saved workspace to {}", args.trim())))
523    }
524    
525    fn help(&self) -> &'static str {
526        "Save workspace to file"
527    }
528}
529
530/// %load - Load workspace from file
531struct LoadMagic;
532
533impl MagicCommand for LoadMagic {
534    fn execute_line(&self, _repl: &mut Repl, args: &str) -> Result<MagicResult> {
535        if args.trim().is_empty() {
536            return Err(anyhow!("Usage: %load <filename>"));
537        }
538        
539        let _content = std::fs::read_to_string(args.trim())
540            .map_err(|e| anyhow!("Failed to read file: {}", e))?;
541        
542        // In production, would deserialize and load into workspace
543        
544        Ok(MagicResult::Text(format!("Loaded workspace from {}", args.trim())))
545    }
546    
547    fn help(&self) -> &'static str {
548        "Load workspace from file"
549    }
550}
551
552// ============================================================================
553// Shell Integration Magic Commands
554// ============================================================================
555
556/// %pwd - Print working directory
557struct PwdMagic;
558
559impl MagicCommand for PwdMagic {
560    fn execute_line(&self, _repl: &mut Repl, _args: &str) -> Result<MagicResult> {
561        let pwd = std::env::current_dir()
562            .map_err(|e| anyhow!("Failed to get pwd: {}", e))?;
563        Ok(MagicResult::Text(pwd.display().to_string()))
564    }
565    
566    fn help(&self) -> &'static str {
567        "Print working directory"
568    }
569}
570
571/// %cd - Change directory
572struct CdMagic;
573
574impl MagicCommand for CdMagic {
575    fn execute_line(&self, _repl: &mut Repl, args: &str) -> Result<MagicResult> {
576        let path = if args.trim().is_empty() {
577            std::env::var("HOME").unwrap_or_else(|_| ".".to_string())
578        } else {
579            args.trim().to_string()
580        };
581        
582        std::env::set_current_dir(&path)
583            .map_err(|e| anyhow!("Failed to change directory: {}", e))?;
584        
585        let pwd = std::env::current_dir()
586            .map_err(|e| anyhow!("Failed to get pwd: {}", e))?;
587        
588        Ok(MagicResult::Text(format!("Changed to: {}", pwd.display())))
589    }
590    
591    fn help(&self) -> &'static str {
592        "Change working directory"
593    }
594}
595
596/// %ls - List directory contents
597struct LsMagic;
598
599impl MagicCommand for LsMagic {
600    fn execute_line(&self, _repl: &mut Repl, args: &str) -> Result<MagicResult> {
601        let path = if args.trim().is_empty() {
602            "."
603        } else {
604            args.trim()
605        };
606        
607        let entries = std::fs::read_dir(path)
608            .map_err(|e| anyhow!("Failed to read directory: {}", e))?;
609        
610        let mut output = String::new();
611        for entry in entries {
612            let entry = entry.map_err(|e| anyhow!("Failed to read entry: {}", e))?;
613            let name = entry.file_name();
614            output.push_str(&format!("{}\n", name.to_string_lossy()));
615        }
616        
617        Ok(MagicResult::Text(output))
618    }
619    
620    fn help(&self) -> &'static str {
621        "List directory contents"
622    }
623}
624
625// ============================================================================
626// Unicode Expansion Support
627// ============================================================================
628
629/// Registry for Unicode character expansion (α → \alpha)
630pub struct UnicodeExpander {
631    mappings: HashMap<String, char>,
632}
633
634impl UnicodeExpander {
635    pub fn new() -> Self {
636        let mut mappings = HashMap::new();
637        
638        // Greek letters
639        mappings.insert("alpha".to_string(), 'α');
640        mappings.insert("beta".to_string(), 'β');
641        mappings.insert("gamma".to_string(), 'γ');
642        mappings.insert("delta".to_string(), 'δ');
643        mappings.insert("epsilon".to_string(), 'ε');
644        mappings.insert("zeta".to_string(), 'ζ');
645        mappings.insert("eta".to_string(), 'η');
646        mappings.insert("theta".to_string(), 'θ');
647        mappings.insert("iota".to_string(), 'ι');
648        mappings.insert("kappa".to_string(), 'κ');
649        mappings.insert("lambda".to_string(), 'λ');
650        mappings.insert("mu".to_string(), 'μ');
651        mappings.insert("nu".to_string(), 'ν');
652        mappings.insert("xi".to_string(), 'ξ');
653        mappings.insert("pi".to_string(), 'π');
654        mappings.insert("rho".to_string(), 'ρ');
655        mappings.insert("sigma".to_string(), 'σ');
656        mappings.insert("tau".to_string(), 'τ');
657        mappings.insert("phi".to_string(), 'φ');
658        mappings.insert("chi".to_string(), 'χ');
659        mappings.insert("psi".to_string(), 'ψ');
660        mappings.insert("omega".to_string(), 'ω');
661        
662        // Capital Greek letters
663        mappings.insert("Alpha".to_string(), 'Α');
664        mappings.insert("Beta".to_string(), 'Β');
665        mappings.insert("Gamma".to_string(), 'Γ');
666        mappings.insert("Delta".to_string(), 'Δ');
667        mappings.insert("Theta".to_string(), 'Θ');
668        mappings.insert("Lambda".to_string(), 'Λ');
669        mappings.insert("Pi".to_string(), 'Π');
670        mappings.insert("Sigma".to_string(), 'Σ');
671        mappings.insert("Phi".to_string(), 'Φ');
672        mappings.insert("Psi".to_string(), 'Ψ');
673        mappings.insert("Omega".to_string(), 'Ω');
674        
675        // Mathematical symbols
676        mappings.insert("infty".to_string(), '∞');
677        mappings.insert("sum".to_string(), '∑');
678        mappings.insert("prod".to_string(), '∏');
679        mappings.insert("int".to_string(), '∫');
680        mappings.insert("sqrt".to_string(), '√');
681        mappings.insert("partial".to_string(), '∂');
682        mappings.insert("nabla".to_string(), '∇');
683        mappings.insert("forall".to_string(), '∀');
684        mappings.insert("exists".to_string(), '∃');
685        mappings.insert("in".to_string(), '∈');
686        mappings.insert("notin".to_string(), '∉');
687        mappings.insert("subset".to_string(), '⊂');
688        mappings.insert("supset".to_string(), '⊃');
689        mappings.insert("cup".to_string(), '∪');
690        mappings.insert("cap".to_string(), '∩');
691        mappings.insert("emptyset".to_string(), '∅');
692        mappings.insert("pm".to_string(), '±');
693        mappings.insert("mp".to_string(), '∓');
694        mappings.insert("times".to_string(), '×');
695        mappings.insert("div".to_string(), '÷');
696        mappings.insert("neq".to_string(), '≠');
697        mappings.insert("leq".to_string(), '≤');
698        mappings.insert("geq".to_string(), '≥');
699        mappings.insert("approx".to_string(), '≈');
700        mappings.insert("equiv".to_string(), '≡');
701        
702        Self { mappings }
703    }
704    
705    /// Expand LaTeX-style sequence to Unicode character
706    pub fn expand(&self, sequence: &str) -> Option<char> {
707        // Remove leading backslash if present
708        let key = if sequence.starts_with('\\') {
709            &sequence[1..]
710        } else {
711            sequence
712        };
713        
714        self.mappings.get(key).copied()
715    }
716    
717    /// Get all available expansions
718    pub fn list_expansions(&self) -> Vec<(String, char)> {
719        let mut expansions: Vec<_> = self.mappings
720            .iter()
721            .map(|(k, v)| (format!("\\{k}"), *v))
722            .collect();
723        expansions.sort_by_key(|(k, _)| k.clone());
724        expansions
725    }
726}
727
728impl Default for UnicodeExpander {
729    fn default() -> Self {
730        Self::new()
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    
738    #[test]
739    fn test_magic_registry() {
740        let registry = MagicRegistry::new();
741        assert!(registry.is_magic("%time"));
742        assert!(registry.is_magic("%%time"));
743        assert!(!registry.is_magic("time"));
744        
745        let commands = registry.list_commands();
746        assert!(commands.contains(&"time".to_string()));
747        assert!(commands.contains(&"debug".to_string()));
748    }
749    
750    #[test]
751    fn test_unicode_expander() {
752        let expander = UnicodeExpander::new();
753        
754        assert_eq!(expander.expand("\\alpha"), Some('α'));
755        assert_eq!(expander.expand("alpha"), Some('α'));
756        assert_eq!(expander.expand("\\pi"), Some('π'));
757        assert_eq!(expander.expand("\\infty"), Some('∞'));
758        assert_eq!(expander.expand("\\unknown"), None);
759    }
760    
761    #[test]
762    fn test_magic_result_display() {
763        let result = MagicResult::Text("Hello".to_string());
764        assert_eq!(format!("{result}"), "Hello");
765        
766        let result = MagicResult::Timed {
767            output: "42".to_string(),
768            duration: Duration::from_millis(123),
769        };
770        assert!(format!("{result}").contains("0.123s"));
771    }
772}