Skip to main content

alimentar/repl/
mod.rs

1//! Interactive REPL for alimentar (ALIM-SPEC-006)
2//!
3//! Implements the Interactive Andon concept from the CLI & REPL Quality
4//! Specification.
5//!
6//! # Design Principles (Toyota Way)
7//!
8//! - **Genchi Genbutsu** (Go and See): Interactive data inspection without
9//!   compilation
10//! - **Jikotei Kanketsu** (Self-completion): Complete data quality tasks in one
11//!   environment
12//! - **Poka-Yoke** (Mistake Proofing): Schema-aware autocomplete prevents
13//!   invalid input
14//! - **Andon** (Visual Control): Color-coded prompts show dataset health status
15//!
16//! # Requirements Implemented
17//!
18//! - ALIM-REPL-001: Stateful session with dataset caching
19//! - ALIM-REPL-002: <100ms response time for metadata queries
20//! - ALIM-REPL-003: Schema-aware autocomplete with reedline
21//! - ALIM-REPL-004: Contextual help system
22//! - ALIM-REPL-005: Batuta pipeline integration hooks
23//! - ALIM-REPL-006: Reproducible session export
24//! - ALIM-REPL-007: Progressive disclosure commands
25//!
26//! # References
27//! - [5] Nielsen (1993). Usability Engineering - 100ms response threshold
28//! - [16] Perez & Granger (2007). IPython - reproducible sessions
29
30mod commands;
31mod completer;
32mod prompt;
33mod session;
34
35use std::io::IsTerminal;
36
37pub use commands::{CommandParser, ReplCommand};
38pub use completer::SchemaAwareCompleter;
39pub use prompt::{AndonPrompt, HealthStatus};
40#[cfg(feature = "repl")]
41use reedline::{Reedline, Signal};
42pub use session::{DisplayConfig, ReplSession};
43
44use crate::Result;
45
46/// Run the interactive REPL
47///
48/// # Errors
49///
50/// Returns an error if REPL initialization fails
51#[cfg(feature = "repl")]
52pub fn run() -> Result<()> {
53    // Check if stdin is a terminal - use simple mode for piped input (testing)
54    if std::io::stdin().is_terminal() {
55        run_interactive()
56    } else {
57        run_non_interactive()
58    }
59}
60
61/// Run REPL in interactive mode with reedline (full features)
62#[cfg(feature = "repl")]
63fn run_interactive() -> Result<()> {
64    let mut session = ReplSession::new();
65    let mut line_editor = create_editor(&session)?;
66    let prompt = AndonPrompt::new();
67
68    println!(
69        "alimentar {} - Interactive Data Explorer",
70        env!("CARGO_PKG_VERSION")
71    );
72    println!("Type 'help' for commands, 'quit' to exit\n");
73
74    loop {
75        let sig = line_editor.read_line(&prompt);
76        match sig {
77            Ok(Signal::Success(line)) => {
78                if dispatch_line(&line, &mut session, &line_editor) {
79                    break;
80                }
81            }
82            Ok(Signal::CtrlC) => {
83                println!("^C");
84            }
85            Ok(Signal::CtrlD) => {
86                println!("\nGoodbye!");
87                break;
88            }
89            Err(e) => {
90                eprintln!("Input error: {e}");
91                break;
92            }
93        }
94    }
95
96    Ok(())
97}
98
99/// Parse and execute a single REPL line. Returns true if the REPL should exit.
100#[cfg(feature = "repl")]
101fn dispatch_line(line: &str, session: &mut ReplSession, editor: &Reedline) -> bool {
102    let trimmed = line.trim();
103    if trimmed.is_empty() {
104        return false;
105    }
106
107    session.add_history(trimmed);
108
109    match CommandParser::parse(trimmed) {
110        Ok(cmd) => {
111            if matches!(cmd, ReplCommand::Quit) {
112                println!("Goodbye!");
113                return true;
114            }
115            if let Err(e) = session.execute(cmd) {
116                eprintln!("Error: {e}");
117            }
118        }
119        Err(e) => eprintln!("Parse error: {e}"),
120    }
121
122    update_completer(editor, session);
123    false
124}
125
126/// Run REPL in non-interactive mode (for testing and piped input)
127#[cfg(feature = "repl")]
128#[allow(clippy::unnecessary_wraps)] // Consistent API with run_interactive()
129fn run_non_interactive() -> Result<()> {
130    use std::io::BufRead;
131
132    let mut session = ReplSession::new();
133
134    println!(
135        "alimentar {} - Interactive Data Explorer",
136        env!("CARGO_PKG_VERSION")
137    );
138    println!("Type 'help' for commands, 'quit' to exit\n");
139
140    let stdin = std::io::stdin();
141    for line in stdin.lock().lines() {
142        let Ok(line) = line else {
143            println!("Goodbye!");
144            break;
145        };
146
147        let trimmed = line.trim();
148        if trimmed.is_empty() {
149            continue;
150        }
151
152        session.add_history(trimmed);
153
154        match CommandParser::parse(trimmed) {
155            Ok(cmd) => {
156                if matches!(cmd, ReplCommand::Quit) {
157                    println!("Goodbye!");
158                    break;
159                }
160                if let Err(e) = session.execute(cmd) {
161                    eprintln!("Error: {e}");
162                }
163            }
164            Err(e) => eprintln!("Parse error: {e}"),
165        }
166    }
167
168    // Handle EOF gracefully
169    if std::io::stdin().lock().lines().next().is_none() {
170        println!("Goodbye!");
171    }
172
173    Ok(())
174}
175
176#[cfg(feature = "repl")]
177fn create_editor(session: &ReplSession) -> Result<Reedline> {
178    use reedline::{FileBackedHistory, Reedline};
179
180    let history_path = dirs_home().join(".alimentar_history");
181    let history = FileBackedHistory::with_file(1000, history_path)
182        .map_err(|e| crate::Error::io_no_path(std::io::Error::other(e.to_string())))?;
183
184    let completer = Box::new(SchemaAwareCompleter::new(session));
185
186    let editor = Reedline::create()
187        .with_history(Box::new(history))
188        .with_completer(completer);
189
190    Ok(editor)
191}
192
193#[cfg(feature = "repl")]
194fn update_completer(editor: &Reedline, session: &ReplSession) {
195    let completer = Box::new(SchemaAwareCompleter::new(session));
196    // Note: reedline doesn't support runtime completer updates easily
197    // This is a placeholder for future enhancement
198    let _ = (editor, completer);
199}
200
201fn dirs_home() -> std::path::PathBuf {
202    std::env::var("HOME")
203        .map(std::path::PathBuf::from)
204        .unwrap_or_else(|_| std::path::PathBuf::from("."))
205}
206
207// ═══════════════════════════════════════════════════════════════════════════════
208// TESTS - EXTREME TDD: Tests written first per specification
209// ═══════════════════════════════════════════════════════════════════════════════
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::ArrowDataset;
215
216    // ─────────────────────────────────────────────────────────────────────────────
217    // ALIM-REPL-001: Stateful Session Tests
218    // ─────────────────────────────────────────────────────────────────────────────
219
220    #[test]
221    fn test_session_creation() {
222        let session = ReplSession::new();
223        assert!(session.active_dataset().is_none());
224        assert!(session.datasets().is_empty());
225        assert!(session.history().is_empty());
226    }
227
228    #[test]
229    fn test_session_load_dataset() {
230        let mut session = ReplSession::new();
231
232        // Create a test dataset
233        let dataset = create_test_dataset();
234        session.load_dataset("test", dataset);
235
236        assert!(session.datasets().contains(&"test".to_string()));
237        assert!(session.active_dataset().is_some());
238    }
239
240    #[test]
241    fn test_session_switch_dataset() {
242        let mut session = ReplSession::new();
243
244        // Load two datasets
245        session.load_dataset("data1", create_test_dataset());
246        session.load_dataset("data2", create_test_dataset());
247
248        assert_eq!(session.active_name(), Some("data2".to_string()));
249
250        session.use_dataset("data1").unwrap();
251        assert_eq!(session.active_name(), Some("data1".to_string()));
252    }
253
254    #[test]
255    fn test_session_history_tracking() {
256        let mut session = ReplSession::new();
257
258        session.add_history("load data.parquet");
259        session.add_history("info");
260        session.add_history("head 10");
261
262        assert_eq!(session.history().len(), 3);
263        assert_eq!(session.history()[0], "load data.parquet");
264    }
265
266    #[test]
267    fn test_session_history_export() {
268        let mut session = ReplSession::new();
269
270        session.add_history("load data.parquet");
271        session.add_history("quality check");
272        session.add_history("convert csv");
273
274        let script = session.export_history();
275
276        assert!(script.contains("# alimentar session export"));
277        assert!(script.contains("alimentar"));
278        assert!(script.contains("load data.parquet"));
279    }
280
281    #[test]
282    fn test_session_quality_cache() {
283        let mut session = ReplSession::new();
284        session.load_dataset("test", create_test_dataset());
285
286        // Quality cache should be populated on load
287        assert!(session.quality_cache().is_some());
288    }
289
290    // ─────────────────────────────────────────────────────────────────────────────
291    // ALIM-REPL-003: Command Parser Tests
292    // ─────────────────────────────────────────────────────────────────────────────
293
294    #[test]
295    fn test_parse_load_command() {
296        let cmd = CommandParser::parse("load data.parquet").unwrap();
297        assert!(matches!(cmd, ReplCommand::Load { path } if path == "data.parquet"));
298    }
299
300    #[test]
301    fn test_parse_info_command() {
302        let cmd = CommandParser::parse("info").unwrap();
303        assert!(matches!(cmd, ReplCommand::Info));
304    }
305
306    #[test]
307    fn test_parse_head_command_default() {
308        let cmd = CommandParser::parse("head").unwrap();
309        assert!(matches!(cmd, ReplCommand::Head { n: 10 }));
310    }
311
312    #[test]
313    fn test_parse_head_command_with_count() {
314        let cmd = CommandParser::parse("head 25").unwrap();
315        assert!(matches!(cmd, ReplCommand::Head { n: 25 }));
316    }
317
318    #[test]
319    fn test_parse_schema_command() {
320        let cmd = CommandParser::parse("schema").unwrap();
321        assert!(matches!(cmd, ReplCommand::Schema));
322    }
323
324    #[test]
325    fn test_parse_quality_check_command() {
326        let cmd = CommandParser::parse("quality check").unwrap();
327        assert!(matches!(cmd, ReplCommand::QualityCheck));
328    }
329
330    #[test]
331    fn test_parse_quality_score_command() {
332        let cmd = CommandParser::parse("quality score").unwrap();
333        assert!(matches!(
334            cmd,
335            ReplCommand::QualityScore {
336                suggest: false,
337                json: false,
338                badge: false
339            }
340        ));
341    }
342
343    #[test]
344    fn test_parse_quality_score_with_flags() {
345        let cmd = CommandParser::parse("quality score --suggest --json").unwrap();
346        assert!(matches!(
347            cmd,
348            ReplCommand::QualityScore {
349                suggest: true,
350                json: true,
351                badge: false
352            }
353        ));
354    }
355
356    #[test]
357    fn test_parse_drift_detect_command() {
358        let cmd = CommandParser::parse("drift detect baseline.parquet").unwrap();
359        assert!(
360            matches!(cmd, ReplCommand::DriftDetect { reference } if reference == "baseline.parquet")
361        );
362    }
363
364    #[test]
365    fn test_parse_convert_command() {
366        let cmd = CommandParser::parse("convert csv").unwrap();
367        assert!(matches!(cmd, ReplCommand::Convert { format } if format == "csv"));
368    }
369
370    #[test]
371    fn test_parse_datasets_command() {
372        let cmd = CommandParser::parse("datasets").unwrap();
373        assert!(matches!(cmd, ReplCommand::Datasets));
374    }
375
376    #[test]
377    fn test_parse_use_command() {
378        let cmd = CommandParser::parse("use my_data").unwrap();
379        assert!(matches!(cmd, ReplCommand::Use { name } if name == "my_data"));
380    }
381
382    #[test]
383    fn test_parse_history_command() {
384        let cmd = CommandParser::parse("history").unwrap();
385        assert!(matches!(cmd, ReplCommand::History { export: false }));
386    }
387
388    #[test]
389    fn test_parse_history_export_command() {
390        let cmd = CommandParser::parse("history --export").unwrap();
391        assert!(matches!(cmd, ReplCommand::History { export: true }));
392    }
393
394    #[test]
395    fn test_parse_help_command() {
396        let cmd = CommandParser::parse("help").unwrap();
397        assert!(matches!(cmd, ReplCommand::Help { topic: None }));
398    }
399
400    #[test]
401    fn test_parse_help_with_topic() {
402        let cmd = CommandParser::parse("help quality").unwrap();
403        assert!(matches!(cmd, ReplCommand::Help { topic: Some(t) } if t == "quality"));
404    }
405
406    #[test]
407    fn test_parse_question_mark_help() {
408        let cmd = CommandParser::parse("?").unwrap();
409        assert!(matches!(cmd, ReplCommand::Help { topic: None }));
410    }
411
412    #[test]
413    fn test_parse_quit_command() {
414        let cmd = CommandParser::parse("quit").unwrap();
415        assert!(matches!(cmd, ReplCommand::Quit));
416    }
417
418    #[test]
419    fn test_parse_exit_command() {
420        let cmd = CommandParser::parse("exit").unwrap();
421        assert!(matches!(cmd, ReplCommand::Quit));
422    }
423
424    #[test]
425    fn test_parse_invalid_command() {
426        let result = CommandParser::parse("invalid_command_xyz");
427        assert!(result.is_err());
428    }
429
430    #[test]
431    fn test_parse_empty_command() {
432        let result = CommandParser::parse("");
433        assert!(result.is_err());
434    }
435
436    // ─────────────────────────────────────────────────────────────────────────────
437    // ALIM-REPL-004: Schema-Aware Completer Tests
438    // ─────────────────────────────────────────────────────────────────────────────
439
440    #[test]
441    fn test_completer_command_suggestions() {
442        let session = ReplSession::new();
443        let completer = SchemaAwareCompleter::new(&session);
444
445        let suggestions = completer.complete("lo");
446        assert!(suggestions.iter().any(|s| s == "load"));
447    }
448
449    #[test]
450    fn test_completer_subcommand_suggestions() {
451        let session = ReplSession::new();
452        let completer = SchemaAwareCompleter::new(&session);
453
454        let suggestions = completer.complete("quality ");
455        assert!(suggestions.iter().any(|s| s == "check"));
456        assert!(suggestions.iter().any(|s| s == "score"));
457    }
458
459    #[test]
460    fn test_completer_column_suggestions() {
461        let mut session = ReplSession::new();
462        session.load_dataset("test", create_test_dataset());
463
464        let completer = SchemaAwareCompleter::new(&session);
465
466        // Column suggestions for commands that accept columns
467        let suggestions = completer.complete("select ");
468        // Should suggest column names from schema
469        assert!(!suggestions.is_empty());
470    }
471
472    #[test]
473    fn test_completer_dataset_suggestions() {
474        let mut session = ReplSession::new();
475        session.load_dataset("train", create_test_dataset());
476        session.load_dataset("test", create_test_dataset());
477
478        let completer = SchemaAwareCompleter::new(&session);
479
480        let suggestions = completer.complete("use ");
481        assert!(suggestions.iter().any(|s| s == "train"));
482        assert!(suggestions.iter().any(|s| s == "test"));
483    }
484
485    // ─────────────────────────────────────────────────────────────────────────────
486    // ALIM-REPL-005: Andon Prompt Tests (Visual Control)
487    // ─────────────────────────────────────────────────────────────────────────────
488
489    #[test]
490    fn test_prompt_no_dataset() {
491        let session = ReplSession::new();
492        let prompt_str = AndonPrompt::render(&session);
493
494        assert!(prompt_str.contains("alimentar"));
495        assert!(!prompt_str.contains('[')); // No dataset indicator
496    }
497
498    #[test]
499    fn test_prompt_healthy_dataset() {
500        let mut session = ReplSession::new();
501        session.load_dataset("data", create_test_dataset());
502        // Assume quality score A
503
504        let prompt_str = AndonPrompt::render(&session);
505
506        assert!(prompt_str.contains("data"));
507        assert!(prompt_str.contains('A') || prompt_str.contains("rows"));
508    }
509
510    #[test]
511    fn test_health_status_from_grade() {
512        assert_eq!(HealthStatus::from_grade('A'), HealthStatus::Healthy);
513        assert_eq!(HealthStatus::from_grade('B'), HealthStatus::Healthy);
514        assert_eq!(HealthStatus::from_grade('C'), HealthStatus::Warning);
515        assert_eq!(HealthStatus::from_grade('D'), HealthStatus::Warning);
516        assert_eq!(HealthStatus::from_grade('F'), HealthStatus::Critical);
517    }
518
519    // ─────────────────────────────────────────────────────────────────────────────
520    // ALIM-REPL-006: Export Command Tests (Batuta Integration)
521    // ─────────────────────────────────────────────────────────────────────────────
522
523    #[test]
524    fn test_parse_export_quality_json() {
525        let cmd = CommandParser::parse("export quality --json").unwrap();
526        assert!(matches!(cmd, ReplCommand::Export { what, json: true } if what == "quality"));
527    }
528
529    #[test]
530    fn test_parse_validate_schema() {
531        let cmd = CommandParser::parse("validate --schema spec.yaml").unwrap();
532        assert!(matches!(cmd, ReplCommand::Validate { schema } if schema == "spec.yaml"));
533    }
534
535    // ─────────────────────────────────────────────────────────────────────────────
536    // DisplayConfig Tests
537    // ─────────────────────────────────────────────────────────────────────────────
538
539    #[test]
540    fn test_display_config_defaults() {
541        let config = DisplayConfig::default();
542
543        assert_eq!(config.max_rows, 10);
544        assert_eq!(config.max_column_width, 50);
545        assert!(config.color_output);
546    }
547
548    #[test]
549    fn test_display_config_builder() {
550        let config = DisplayConfig::default()
551            .with_max_rows(20)
552            .with_max_column_width(100)
553            .with_color(false);
554
555        assert_eq!(config.max_rows, 20);
556        assert_eq!(config.max_column_width, 100);
557        assert!(!config.color_output);
558    }
559
560    // ─────────────────────────────────────────────────────────────────────────────
561    // Helper Functions for Tests
562    // ─────────────────────────────────────────────────────────────────────────────
563
564    fn create_test_dataset() -> ArrowDataset {
565        use std::sync::Arc;
566
567        use arrow::{
568            array::{Int32Array, StringArray},
569            datatypes::{DataType, Field, Schema},
570            record_batch::RecordBatch,
571        };
572
573        let schema = Schema::new(vec![
574            Field::new("id", DataType::Int32, false),
575            Field::new("name", DataType::Utf8, true),
576            Field::new("score", DataType::Int32, true),
577        ]);
578
579        let id_array = Int32Array::from(vec![1, 2, 3]);
580        let name_array = StringArray::from(vec![Some("Alice"), Some("Bob"), Some("Charlie")]);
581        let score_array = Int32Array::from(vec![Some(85), Some(92), Some(78)]);
582
583        let batch = RecordBatch::try_new(
584            Arc::new(schema),
585            vec![
586                Arc::new(id_array),
587                Arc::new(name_array),
588                Arc::new(score_array),
589            ],
590        )
591        .unwrap();
592
593        ArrowDataset::from_batch(batch).unwrap()
594    }
595
596    // ─────────────────────────────────────────────────────────────────────────────
597    // COVERAGE BOOST: HealthStatus Tests (prompt.rs)
598    // ─────────────────────────────────────────────────────────────────────────────
599
600    #[test]
601    fn test_health_status_indicator_all_variants() {
602        assert_eq!(HealthStatus::Healthy.indicator(), "");
603        assert_eq!(HealthStatus::Warning.indicator(), "!");
604        assert_eq!(HealthStatus::Critical.indicator(), "!!");
605        assert_eq!(HealthStatus::None.indicator(), "");
606    }
607
608    #[test]
609    fn test_health_status_from_grade_unknown() {
610        assert_eq!(HealthStatus::from_grade('X'), HealthStatus::None);
611        assert_eq!(HealthStatus::from_grade('Z'), HealthStatus::None);
612        assert_eq!(HealthStatus::from_grade(' '), HealthStatus::None);
613    }
614
615    #[cfg(feature = "repl")]
616    #[test]
617    fn test_health_status_color_all_variants() {
618        use nu_ansi_term::Color;
619
620        assert_eq!(HealthStatus::Healthy.color(), Color::Green);
621        assert_eq!(HealthStatus::Warning.color(), Color::Yellow);
622        assert_eq!(HealthStatus::Critical.color(), Color::Red);
623        assert_eq!(HealthStatus::None.color(), Color::Default);
624    }
625
626    #[test]
627    fn test_andon_prompt_new_and_default() {
628        let prompt1 = AndonPrompt::new();
629        let prompt2 = AndonPrompt::default();
630        // Both should create valid prompts
631        let session = ReplSession::new();
632        let render1 = AndonPrompt::render(&session);
633        let _ = (prompt1, prompt2); // Use them
634        assert!(render1.contains("alimentar"));
635    }
636
637    #[test]
638    fn test_andon_prompt_render_with_grade_indicator() {
639        let mut session = ReplSession::new();
640        session.load_dataset("test_data", create_test_dataset());
641
642        let prompt_str = AndonPrompt::render(&session);
643        // Should contain dataset name and row info
644        assert!(prompt_str.contains("test_data"));
645        assert!(prompt_str.contains("rows"));
646    }
647
648    #[cfg(feature = "repl")]
649    #[test]
650    fn test_andon_prompt_render_colored() {
651        let mut session = ReplSession::new();
652        session.load_dataset("colored_test", create_test_dataset());
653
654        let prompt_str = AndonPrompt::render_colored(&session);
655        // Should contain ANSI escape codes and data
656        assert!(prompt_str.contains("alimentar"));
657        assert!(prompt_str.contains("colored_test"));
658    }
659
660    #[cfg(feature = "repl")]
661    #[test]
662    fn test_andon_prompt_render_colored_no_dataset() {
663        let session = ReplSession::new();
664        let prompt_str = AndonPrompt::render_colored(&session);
665        assert!(prompt_str.contains("alimentar"));
666        // Prompt may contain ANSI escape codes including '[' for colors
667        // Just verify it renders without error
668    }
669
670    #[cfg(feature = "repl")]
671    #[test]
672    fn test_andon_prompt_trait_methods() {
673        use reedline::Prompt;
674
675        let prompt = AndonPrompt::new();
676
677        assert_eq!(prompt.render_prompt_left().as_ref(), "alimentar > ");
678        assert_eq!(prompt.render_prompt_right().as_ref(), "");
679        assert_eq!(prompt.render_prompt_multiline_indicator().as_ref(), "... ");
680    }
681
682    // ─────────────────────────────────────────────────────────────────────────────
683    // COVERAGE BOOST: Session Tests (session.rs)
684    // ─────────────────────────────────────────────────────────────────────────────
685
686    #[test]
687    fn test_session_use_dataset_not_found() {
688        let mut session = ReplSession::new();
689        session.load_dataset("data1", create_test_dataset());
690
691        let result = session.use_dataset("nonexistent");
692        assert!(result.is_err());
693        assert!(result.unwrap_err().to_string().contains("not found"));
694    }
695
696    #[test]
697    fn test_session_active_row_count() {
698        let mut session = ReplSession::new();
699        assert!(session.active_row_count().is_none());
700
701        session.load_dataset("test", create_test_dataset());
702        assert_eq!(session.active_row_count(), Some(3));
703    }
704
705    #[test]
706    fn test_session_active_grade() {
707        let mut session = ReplSession::new();
708        assert!(session.active_grade().is_none());
709
710        session.load_dataset("test", create_test_dataset());
711        assert!(session.active_grade().is_some());
712    }
713
714    #[test]
715    fn test_session_column_names() {
716        let mut session = ReplSession::new();
717        assert!(session.column_names().is_empty());
718
719        session.load_dataset("test", create_test_dataset());
720        let columns = session.column_names();
721        assert!(columns.contains(&"id".to_string()));
722        assert!(columns.contains(&"name".to_string()));
723        assert!(columns.contains(&"score".to_string()));
724    }
725
726    #[test]
727    fn test_session_export_history_with_active_dataset() {
728        let mut session = ReplSession::new();
729        session.load_dataset("data.parquet", create_test_dataset());
730
731        session.add_history("info");
732        session.add_history("head 5");
733        session.add_history("quality check");
734
735        let script = session.export_history();
736        assert!(script.contains("#!/usr/bin/env bash"));
737        assert!(script.contains("alimentar session export"));
738        // Commands should include file path
739        assert!(script.contains("info"));
740    }
741
742    #[test]
743    fn test_session_config_access() {
744        let session = ReplSession::new();
745        assert_eq!(session.config.max_rows, 10);
746        assert_eq!(session.config.max_column_width, 50);
747        assert!(session.config.color_output);
748    }
749
750    #[test]
751    fn test_session_default_implementation() {
752        let session1 = ReplSession::new();
753        let session2 = ReplSession::default();
754
755        assert_eq!(session1.datasets().len(), session2.datasets().len());
756        assert_eq!(session1.history().len(), session2.history().len());
757    }
758
759    // ─────────────────────────────────────────────────────────────────────────────
760    // COVERAGE BOOST: Completer Tests (completer.rs)
761    // ─────────────────────────────────────────────────────────────────────────────
762
763    #[test]
764    fn test_completer_empty_input() {
765        let session = ReplSession::new();
766        let completer = SchemaAwareCompleter::new(&session);
767
768        let suggestions = completer.complete("");
769        // Should return all commands
770        assert!(suggestions.len() > 5);
771        assert!(suggestions.contains(&"load".to_string()));
772        assert!(suggestions.contains(&"info".to_string()));
773        assert!(suggestions.contains(&"quit".to_string()));
774    }
775
776    #[test]
777    fn test_completer_convert_suggestions() {
778        let session = ReplSession::new();
779        let completer = SchemaAwareCompleter::new(&session);
780
781        let suggestions = completer.complete("convert ");
782        assert!(suggestions.contains(&"csv".to_string()));
783        assert!(suggestions.contains(&"parquet".to_string()));
784        assert!(suggestions.contains(&"json".to_string()));
785    }
786
787    #[test]
788    fn test_completer_convert_partial() {
789        let session = ReplSession::new();
790        let completer = SchemaAwareCompleter::new(&session);
791
792        let suggestions = completer.complete("convert p");
793        assert!(suggestions.contains(&"parquet".to_string()));
794        assert!(!suggestions.contains(&"csv".to_string()));
795    }
796
797    #[test]
798    fn test_completer_help_suggestions() {
799        let session = ReplSession::new();
800        let completer = SchemaAwareCompleter::new(&session);
801
802        let suggestions = completer.complete("help ");
803        assert!(suggestions.contains(&"quality".to_string()));
804        assert!(suggestions.contains(&"drift".to_string()));
805        assert!(suggestions.contains(&"export".to_string()));
806    }
807
808    #[test]
809    fn test_completer_help_partial() {
810        let session = ReplSession::new();
811        let completer = SchemaAwareCompleter::new(&session);
812
813        let suggestions = completer.complete("help q");
814        assert!(suggestions.contains(&"quality".to_string()));
815        assert!(!suggestions.contains(&"drift".to_string()));
816    }
817
818    #[test]
819    fn test_completer_drift_subcommands() {
820        let session = ReplSession::new();
821        let completer = SchemaAwareCompleter::new(&session);
822
823        let suggestions = completer.complete("drift ");
824        assert!(suggestions.contains(&"detect".to_string()));
825    }
826
827    #[test]
828    fn test_completer_load_no_suggestions() {
829        let session = ReplSession::new();
830        let completer = SchemaAwareCompleter::new(&session);
831
832        // File path completion is not implemented (OS level)
833        let suggestions = completer.complete("load ");
834        assert!(suggestions.is_empty());
835    }
836
837    #[test]
838    fn test_completer_select_column_suggestions() {
839        let mut session = ReplSession::new();
840        session.load_dataset("test", create_test_dataset());
841
842        let completer = SchemaAwareCompleter::new(&session);
843
844        let suggestions = completer.complete("select ");
845        assert!(suggestions.contains(&"id".to_string()));
846        assert!(suggestions.contains(&"name".to_string()));
847        assert!(suggestions.contains(&"score".to_string()));
848    }
849
850    #[test]
851    fn test_completer_drop_column_suggestions() {
852        let mut session = ReplSession::new();
853        session.load_dataset("test", create_test_dataset());
854
855        let completer = SchemaAwareCompleter::new(&session);
856
857        let suggestions = completer.complete("drop ");
858        assert!(suggestions.contains(&"id".to_string()));
859    }
860
861    #[test]
862    fn test_completer_update_columns() {
863        let mut session = ReplSession::new();
864        let mut completer = SchemaAwareCompleter::new(&session);
865
866        // Initially no columns
867        let suggestions = completer.complete("select ");
868        assert!(suggestions.is_empty());
869
870        // Load dataset and update
871        session.load_dataset("test", create_test_dataset());
872        completer.update_columns(&session);
873
874        let suggestions = completer.complete("select ");
875        assert!(!suggestions.is_empty());
876    }
877
878    #[test]
879    fn test_completer_update_datasets() {
880        let mut session = ReplSession::new();
881        let mut completer = SchemaAwareCompleter::new(&session);
882
883        // Initially no datasets
884        let suggestions = completer.complete("use ");
885        assert!(suggestions.is_empty());
886
887        // Load dataset and update
888        session.load_dataset("mydata", create_test_dataset());
889        completer.update_datasets(&session);
890
891        let suggestions = completer.complete("use ");
892        assert!(suggestions.contains(&"mydata".to_string()));
893    }
894
895    #[test]
896    fn test_completer_unknown_command() {
897        let session = ReplSession::new();
898        let completer = SchemaAwareCompleter::new(&session);
899
900        // Unknown command should return empty
901        let suggestions = completer.complete("unknowncmd ");
902        assert!(suggestions.is_empty());
903    }
904
905    #[test]
906    fn test_completer_partial_command_filtering() {
907        let session = ReplSession::new();
908        let completer = SchemaAwareCompleter::new(&session);
909
910        let suggestions = completer.complete("he");
911        assert!(suggestions.contains(&"head".to_string()));
912        assert!(suggestions.contains(&"help".to_string()));
913        assert!(!suggestions.contains(&"load".to_string()));
914    }
915
916    #[test]
917    fn test_completer_quality_partial_subcommand() {
918        let session = ReplSession::new();
919        let completer = SchemaAwareCompleter::new(&session);
920
921        let suggestions = completer.complete("quality c");
922        assert!(suggestions.contains(&"check".to_string()));
923        assert!(!suggestions.contains(&"score".to_string()));
924    }
925
926    #[test]
927    fn test_completer_use_with_partial_name() {
928        let mut session = ReplSession::new();
929        session.load_dataset("training_data", create_test_dataset());
930        session.load_dataset("test_data", create_test_dataset());
931
932        let completer = SchemaAwareCompleter::new(&session);
933
934        let suggestions = completer.complete("use tr");
935        assert!(suggestions.contains(&"training_data".to_string()));
936        assert!(!suggestions.contains(&"test_data".to_string()));
937    }
938
939    // ─────────────────────────────────────────────────────────────────────────────
940    // COVERAGE BOOST: Command Parser Tests (commands.rs)
941    // ─────────────────────────────────────────────────────────────────────────────
942
943    #[test]
944    fn test_command_names_returns_all() {
945        let names = CommandParser::command_names();
946        assert!(names.contains(&"load"));
947        assert!(names.contains(&"info"));
948        assert!(names.contains(&"head"));
949        assert!(names.contains(&"schema"));
950        assert!(names.contains(&"quality"));
951        assert!(names.contains(&"drift"));
952        assert!(names.contains(&"convert"));
953        assert!(names.contains(&"datasets"));
954        assert!(names.contains(&"use"));
955        assert!(names.contains(&"history"));
956        assert!(names.contains(&"help"));
957        assert!(names.contains(&"export"));
958        assert!(names.contains(&"validate"));
959        assert!(names.contains(&"quit"));
960        assert!(names.contains(&"exit"));
961    }
962
963    #[test]
964    fn test_subcommands_quality() {
965        let subs = CommandParser::subcommands("quality");
966        assert!(subs.contains(&"check"));
967        assert!(subs.contains(&"score"));
968    }
969
970    #[test]
971    fn test_subcommands_drift() {
972        let subs = CommandParser::subcommands("drift");
973        assert!(subs.contains(&"detect"));
974    }
975
976    #[test]
977    fn test_subcommands_unknown() {
978        let subs = CommandParser::subcommands("unknown");
979        assert!(subs.is_empty());
980    }
981
982    #[test]
983    fn test_flags_quality_score() {
984        let flags = CommandParser::flags("quality", Some("score"));
985        assert!(flags.contains(&"--suggest"));
986        assert!(flags.contains(&"--json"));
987        assert!(flags.contains(&"--badge"));
988    }
989
990    #[test]
991    fn test_flags_export() {
992        let flags = CommandParser::flags("export", None);
993        assert!(flags.contains(&"--json"));
994    }
995
996    #[test]
997    fn test_flags_validate() {
998        let flags = CommandParser::flags("validate", None);
999        assert!(flags.contains(&"--schema"));
1000    }
1001
1002    #[test]
1003    fn test_flags_history() {
1004        let flags = CommandParser::flags("history", None);
1005        assert!(flags.contains(&"--export"));
1006    }
1007
1008    #[test]
1009    fn test_flags_unknown() {
1010        let flags = CommandParser::flags("unknown", None);
1011        assert!(flags.is_empty());
1012    }
1013
1014    #[test]
1015    fn test_parse_quality_score_badge() {
1016        let cmd = CommandParser::parse("quality score --badge").unwrap();
1017        assert!(matches!(cmd, ReplCommand::QualityScore { badge: true, .. }));
1018    }
1019
1020    #[test]
1021    fn test_parse_history_shorthand() {
1022        let cmd = CommandParser::parse("history -e").unwrap();
1023        assert!(matches!(cmd, ReplCommand::History { export: true }));
1024    }
1025
1026    #[test]
1027    fn test_parse_validate_shorthand() {
1028        let cmd = CommandParser::parse("validate -s schema.yaml").unwrap();
1029        assert!(matches!(cmd, ReplCommand::Validate { schema } if schema == "schema.yaml"));
1030    }
1031
1032    #[test]
1033    fn test_parse_case_insensitive() {
1034        let cmd1 = CommandParser::parse("LOAD file.csv").unwrap();
1035        let cmd2 = CommandParser::parse("Load FILE.CSV").unwrap();
1036        assert!(matches!(cmd1, ReplCommand::Load { .. }));
1037        assert!(matches!(cmd2, ReplCommand::Load { .. }));
1038    }
1039
1040    #[test]
1041    fn test_parse_head_invalid_number() {
1042        // Parser returns error for invalid numbers
1043        let result = CommandParser::parse("head abc");
1044        assert!(result.is_err());
1045        assert!(result.unwrap_err().to_string().contains("Invalid number"));
1046    }
1047
1048    #[test]
1049    fn test_parse_load_missing_path() {
1050        let result = CommandParser::parse("load");
1051        assert!(result.is_err());
1052    }
1053
1054    #[test]
1055    fn test_parse_use_missing_name() {
1056        let result = CommandParser::parse("use");
1057        assert!(result.is_err());
1058    }
1059
1060    #[test]
1061    fn test_parse_convert_missing_format() {
1062        let result = CommandParser::parse("convert");
1063        assert!(result.is_err());
1064    }
1065
1066    #[test]
1067    fn test_parse_drift_missing_reference() {
1068        let result = CommandParser::parse("drift detect");
1069        assert!(result.is_err());
1070    }
1071
1072    #[test]
1073    fn test_parse_quality_invalid_subcommand() {
1074        let result = CommandParser::parse("quality invalid");
1075        assert!(result.is_err());
1076    }
1077
1078    #[test]
1079    fn test_parse_quality_missing_subcommand() {
1080        let result = CommandParser::parse("quality");
1081        assert!(result.is_err());
1082    }
1083
1084    #[test]
1085    fn test_parse_drift_invalid_subcommand() {
1086        let result = CommandParser::parse("drift invalid");
1087        assert!(result.is_err());
1088    }
1089
1090    #[test]
1091    fn test_parse_validate_missing_schema() {
1092        let result = CommandParser::parse("validate --schema");
1093        assert!(result.is_err());
1094    }
1095
1096    #[test]
1097    fn test_parse_export_missing_what() {
1098        let result = CommandParser::parse("export");
1099        assert!(result.is_err());
1100    }
1101}