Skip to main content

alimentar/repl/
session.rs

1//! REPL Session State Management (ALIM-REPL-001)
2//!
3//! Stateful session that holds loaded datasets in memory, eliminating
4//! the "Muda of Processing" (re-loading large datasets for every minor query).
5
6use std::{collections::HashMap, fmt::Write, path::Path, sync::Arc};
7
8use super::commands::ReplCommand;
9use crate::{
10    dataset::Dataset,
11    quality::{LetterGrade, QualityChecker, QualityScore},
12    ArrowDataset, Error, Result,
13};
14
15/// Display configuration for REPL output (Mieruka - Visual Control)
16#[derive(Debug, Clone)]
17pub struct DisplayConfig {
18    /// Maximum rows to display in head/tail
19    pub max_rows: usize,
20    /// Maximum column width before truncation
21    pub max_column_width: usize,
22    /// Enable color output (Andon)
23    pub color_output: bool,
24}
25
26impl Default for DisplayConfig {
27    fn default() -> Self {
28        Self {
29            max_rows: 10,
30            max_column_width: 50,
31            color_output: true,
32        }
33    }
34}
35
36impl DisplayConfig {
37    /// Set maximum rows to display
38    #[must_use]
39    pub fn with_max_rows(mut self, rows: usize) -> Self {
40        self.max_rows = rows;
41        self
42    }
43
44    /// Set maximum column width
45    #[must_use]
46    pub fn with_max_column_width(mut self, width: usize) -> Self {
47        self.max_column_width = width;
48        self
49    }
50
51    /// Enable/disable color output
52    #[must_use]
53    pub fn with_color(mut self, enabled: bool) -> Self {
54        self.color_output = enabled;
55        self
56    }
57}
58
59/// Cached quality score for the active dataset
60#[derive(Debug, Clone)]
61pub struct QualityCache {
62    /// The computed quality score
63    pub score: QualityScore,
64    /// When the cache was last updated
65    pub timestamp: std::time::Instant,
66}
67
68/// Stateful REPL session (ALIM-REPL-001)
69///
70/// Prevents reload waste by keeping datasets in memory.
71/// Maintains quality cache for instant Andon display.
72#[derive(Debug)]
73pub struct ReplSession {
74    /// Loaded datasets keyed by name
75    datasets: HashMap<String, Arc<ArrowDataset>>,
76    /// Currently active dataset name
77    active_name: Option<String>,
78    /// Command history for reproducibility (ALIM-REPL-006)
79    history: Vec<String>,
80    /// Display configuration
81    pub config: DisplayConfig,
82    /// Quality score cache for Andon prompt
83    quality_cache: Option<QualityCache>,
84}
85
86impl Default for ReplSession {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl ReplSession {
93    /// Create a new empty session
94    #[must_use]
95    pub fn new() -> Self {
96        Self {
97            datasets: HashMap::new(),
98            active_name: None,
99            history: Vec::new(),
100            config: DisplayConfig::default(),
101            quality_cache: None,
102        }
103    }
104
105    /// Load a dataset into the session
106    ///
107    /// The dataset becomes the active dataset and quality is computed.
108    pub fn load_dataset(&mut self, name: &str, dataset: ArrowDataset) {
109        // Compute quality score for Andon display
110        let score = self.compute_quality(&dataset);
111
112        let arc_dataset = Arc::new(dataset);
113        self.datasets.insert(name.to_string(), arc_dataset);
114        self.active_name = Some(name.to_string());
115
116        if let Some(score) = score {
117            self.quality_cache = Some(QualityCache {
118                score,
119                timestamp: std::time::Instant::now(),
120            });
121        }
122    }
123
124    /// Compute quality score for a dataset
125    fn compute_quality(&self, dataset: &ArrowDataset) -> Option<QualityScore> {
126        let checker = QualityChecker::new();
127        match checker.check(dataset) {
128            Ok(report) => {
129                // Build basic checklist from report
130                let checklist = self.build_basic_checklist(&report);
131                Some(QualityScore::from_checklist(checklist))
132            }
133            Err(_) => None,
134        }
135    }
136
137    /// Build a basic checklist from quality report
138    #[allow(clippy::unused_self)]
139    fn build_basic_checklist(
140        &self,
141        report: &crate::quality::QualityReport,
142    ) -> Vec<crate::quality::ChecklistItem> {
143        use crate::quality::{ChecklistItem, Severity};
144
145        let mut items = Vec::new();
146
147        // Basic schema check (always passes if we got here)
148        items.push(ChecklistItem::new(
149            1,
150            "Schema is readable",
151            Severity::Critical,
152            true,
153        ));
154
155        // Check for null columns
156        let has_excessive_nulls = report.columns.values().any(|c| c.null_ratio > 0.5);
157        items.push(ChecklistItem::new(
158            2,
159            "No excessive null ratios (>50%)",
160            Severity::High,
161            !has_excessive_nulls,
162        ));
163
164        // Check for duplicates
165        let has_high_duplicates = report.columns.values().any(|c| c.duplicate_ratio > 0.3);
166        items.push(ChecklistItem::new(
167            3,
168            "No high duplicate ratios (>30%)",
169            Severity::Medium,
170            !has_high_duplicates,
171        ));
172
173        // Check row count
174        items.push(ChecklistItem::new(
175            4,
176            "Dataset has rows",
177            Severity::Critical,
178            report.row_count > 0,
179        ));
180
181        // Check column count
182        items.push(ChecklistItem::new(
183            5,
184            "Dataset has columns",
185            Severity::Critical,
186            report.column_count > 0,
187        ));
188
189        items
190    }
191
192    /// Switch to a different loaded dataset
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if the dataset name is not found.
197    pub fn use_dataset(&mut self, name: &str) -> Result<()> {
198        if self.datasets.contains_key(name) {
199            self.active_name = Some(name.to_string());
200
201            // Recompute quality for the newly active dataset
202            if let Some(dataset) = self.datasets.get(name) {
203                if let Some(score) = self.compute_quality(dataset) {
204                    self.quality_cache = Some(QualityCache {
205                        score,
206                        timestamp: std::time::Instant::now(),
207                    });
208                }
209            }
210
211            Ok(())
212        } else {
213            Err(Error::NotFound(format!("Dataset '{}' not found", name)))
214        }
215    }
216
217    /// Get the currently active dataset
218    #[must_use]
219    pub fn active_dataset(&self) -> Option<&Arc<ArrowDataset>> {
220        self.active_name.as_ref().and_then(|n| self.datasets.get(n))
221    }
222
223    /// Get the name of the active dataset
224    #[must_use]
225    pub fn active_name(&self) -> Option<String> {
226        self.active_name.clone()
227    }
228
229    /// Get the quality grade for the active dataset
230    #[must_use]
231    pub fn active_grade(&self) -> Option<LetterGrade> {
232        self.quality_cache.as_ref().map(|c| c.score.grade)
233    }
234
235    /// Get the row count of the active dataset
236    #[must_use]
237    pub fn active_row_count(&self) -> Option<usize> {
238        self.active_dataset().map(|d| d.len())
239    }
240
241    /// List all loaded dataset names
242    #[must_use]
243    pub fn datasets(&self) -> Vec<String> {
244        self.datasets.keys().cloned().collect()
245    }
246
247    /// Add a command to history
248    pub fn add_history(&mut self, command: &str) {
249        self.history.push(command.to_string());
250    }
251
252    /// Get command history
253    #[must_use]
254    pub fn history(&self) -> &[String] {
255        &self.history
256    }
257
258    /// Get the quality cache
259    #[must_use]
260    pub fn quality_cache(&self) -> Option<&QualityCache> {
261        self.quality_cache.as_ref()
262    }
263
264    /// Export history as a reproducible shell script (ALIM-REPL-006)
265    #[must_use]
266    pub fn export_history(&self) -> String {
267        let mut script = String::new();
268        script.push_str("#!/usr/bin/env bash\n");
269        script.push_str("# alimentar session export\n");
270        let _ = writeln!(script, "# Generated: {}", chrono_now());
271        script.push_str("# Reproducible session for Batuta pipeline integration\n\n");
272
273        for cmd in &self.history {
274            // Convert REPL commands to batch CLI equivalents
275            let batch_cmd = self.repl_to_batch(cmd);
276            let _ = writeln!(script, "alimentar {}", batch_cmd);
277        }
278
279        script
280    }
281
282    /// Convert REPL command to batch CLI equivalent
283    fn repl_to_batch(&self, cmd: &str) -> String {
284        let parts: Vec<&str> = cmd.split_whitespace().collect();
285        if parts.is_empty() {
286            return String::new();
287        }
288
289        match parts[0] {
290            "load" => {
291                // Keep load command as-is for reproducibility
292                cmd.to_string()
293            }
294            "info" | "schema" | "head" => {
295                // These need the active file path
296                if let Some(name) = &self.active_name {
297                    format!("{} {}", cmd, name)
298                } else {
299                    cmd.to_string()
300                }
301            }
302            "quality" => {
303                if parts.len() > 1 {
304                    if let Some(name) = &self.active_name {
305                        format!("{} {} {}", parts[0], parts[1], name)
306                    } else {
307                        cmd.to_string()
308                    }
309                } else {
310                    cmd.to_string()
311                }
312            }
313            _ => cmd.to_string(),
314        }
315    }
316
317    /// Get schema column names for autocomplete
318    #[must_use]
319    pub fn column_names(&self) -> Vec<String> {
320        self.active_dataset()
321            .map(|d| {
322                d.schema()
323                    .fields()
324                    .iter()
325                    .map(|f| f.name().clone())
326                    .collect()
327            })
328            .unwrap_or_default()
329    }
330
331    /// Execute a REPL command
332    ///
333    /// # Errors
334    ///
335    /// Returns an error if the command execution fails.
336    pub fn execute(&mut self, cmd: ReplCommand) -> Result<()> {
337        match cmd {
338            ReplCommand::Load { path } => self.cmd_load(&path),
339            ReplCommand::Info => self.cmd_info(),
340            ReplCommand::Head { n } => self.cmd_head(n),
341            ReplCommand::Schema => self.cmd_schema(),
342            ReplCommand::QualityCheck => self.cmd_quality_check(),
343            ReplCommand::QualityScore {
344                suggest,
345                json,
346                badge,
347            } => self.cmd_quality_score(suggest, json, badge),
348            ReplCommand::DriftDetect { reference } => self.cmd_drift_detect(&reference),
349            ReplCommand::Convert { format } => self.cmd_convert(&format),
350            ReplCommand::Datasets => self.cmd_datasets(),
351            ReplCommand::Use { name } => self.use_dataset(&name),
352            ReplCommand::History { export } => self.cmd_history(export),
353            ReplCommand::Help { topic } => self.cmd_help(topic.as_deref()),
354            ReplCommand::Export { what, json } => self.cmd_export(&what, json),
355            ReplCommand::Validate { schema } => self.cmd_validate(&schema),
356            ReplCommand::Quit => Ok(()), // Handled in main loop
357        }
358    }
359
360    fn cmd_load(&mut self, path: &str) -> Result<()> {
361        let dataset = load_dataset_from_path(path)?;
362        let name = Path::new(path)
363            .file_name()
364            .and_then(|n| n.to_str())
365            .unwrap_or("data")
366            .to_string();
367
368        self.load_dataset(&name, dataset);
369        println!(
370            "Loaded '{}' ({} rows)",
371            name,
372            self.active_row_count().unwrap_or(0)
373        );
374        Ok(())
375    }
376
377    fn cmd_info(&self) -> Result<()> {
378        let dataset = self.require_active()?;
379        println!(
380            "Dataset: {}",
381            self.active_name.as_deref().unwrap_or("unnamed")
382        );
383        println!("Rows: {}", dataset.len());
384        println!("Columns: {}", dataset.schema().fields().len());
385
386        if let Some(cache) = &self.quality_cache {
387            println!("Quality: {} ({:.1})", cache.score.grade, cache.score.score);
388        }
389
390        Ok(())
391    }
392
393    fn cmd_head(&self, n: usize) -> Result<()> {
394        use crate::transform::Transform;
395
396        let dataset = self.require_active()?;
397        let mut total_rows = 0;
398        let mut output_batches = Vec::new();
399
400        for batch in dataset.iter() {
401            if total_rows >= n {
402                break;
403            }
404            let rows_needed = n - total_rows;
405            let rows_to_take = rows_needed.min(batch.num_rows());
406
407            if rows_to_take > 0 {
408                let take_transform = crate::transform::Take::new(rows_to_take);
409                let limited = take_transform.apply(batch)?;
410                total_rows += limited.num_rows();
411                output_batches.push(limited);
412            }
413        }
414
415        println!(
416            "{}",
417            arrow::util::pretty::pretty_format_batches(&output_batches)?
418        );
419        Ok(())
420    }
421
422    fn cmd_schema(&self) -> Result<()> {
423        let dataset = self.require_active()?;
424        let schema = dataset.schema();
425
426        println!("Schema ({} columns):", schema.fields().len());
427        for field in schema.fields() {
428            let nullable = if field.is_nullable() {
429                "nullable"
430            } else {
431                "not null"
432            };
433            println!("  {}: {} ({})", field.name(), field.data_type(), nullable);
434        }
435
436        Ok(())
437    }
438
439    fn cmd_quality_check(&self) -> Result<()> {
440        let dataset = self.require_active()?;
441        let checker = QualityChecker::new();
442        let report = checker.check(dataset)?;
443
444        println!("Quality Check Results:");
445        println!("  Total rows: {}", report.row_count);
446        println!("  Total columns: {}", report.column_count);
447
448        for (name, col) in &report.columns {
449            println!("\n  Column: {}", name);
450            println!("    Null ratio: {:.2}%", col.null_ratio * 100.0);
451            println!("    Duplicate ratio: {:.2}%", col.duplicate_ratio * 100.0);
452        }
453
454        Ok(())
455    }
456
457    fn cmd_quality_score(&self, suggest: bool, json: bool, badge: bool) -> Result<()> {
458        if let Some(cache) = &self.quality_cache {
459            if json {
460                println!("{}", cache.score.to_json());
461            } else if badge {
462                println!("{}", cache.score.badge_url());
463            } else {
464                println!(
465                    "Quality Score: {} ({:.1}/100)",
466                    cache.score.grade, cache.score.score
467                );
468                println!("Decision: {}", cache.score.grade.publication_decision());
469
470                if suggest {
471                    let failed = cache.score.failed_items();
472                    if !failed.is_empty() {
473                        println!("\nSuggestions:");
474                        for item in failed {
475                            println!("  [{:?}] {}", item.severity, item.description);
476                            if let Some(s) = &item.suggestion {
477                                println!("    → {}", s);
478                            }
479                        }
480                    }
481                }
482            }
483            Ok(())
484        } else {
485            Err(Error::NotFound("No quality data available".to_string()))
486        }
487    }
488
489    fn cmd_drift_detect(&self, reference: &str) -> Result<()> {
490        let dataset = self.require_active()?;
491        let ref_dataset = load_dataset_from_path(reference)?;
492
493        let detector = crate::DriftDetector::new(ref_dataset);
494        let report = detector.detect(dataset)?;
495
496        println!("Drift Detection Report:");
497        println!("  Columns analyzed: {}", report.column_scores.len());
498        println!(
499            "  Drifted columns: {}",
500            report
501                .column_scores
502                .values()
503                .filter(|d| d.drift_detected)
504                .count()
505        );
506
507        for (name, drift) in &report.column_scores {
508            if drift.drift_detected {
509                println!("  {} [{:?}]: {:.2?}", name, drift.severity, drift.p_value);
510            }
511        }
512
513        Ok(())
514    }
515
516    fn cmd_convert(&self, format: &str) -> Result<()> {
517        let dataset = self.require_active()?;
518        let name = self.active_name.as_deref().unwrap_or("data");
519        let output = format!("{}.{}", name, format);
520
521        match format {
522            "csv" => dataset.to_csv(&output)?,
523            "parquet" => dataset.to_parquet(&output)?,
524            "json" => dataset.to_json(&output)?,
525            _ => return Err(Error::InvalidFormat(format!("Unknown format: {}", format))),
526        }
527
528        println!("Converted to {}", output);
529        Ok(())
530    }
531
532    #[allow(clippy::unnecessary_wraps)]
533    fn cmd_datasets(&self) -> Result<()> {
534        if self.datasets.is_empty() {
535            println!("No datasets loaded. Use 'load <file>' to load one.");
536        } else {
537            println!("Loaded datasets:");
538            for name in self.datasets.keys() {
539                let marker = if Some(name) == self.active_name.as_ref() {
540                    "* "
541                } else {
542                    "  "
543                };
544                if let Some(ds) = self.datasets.get(name) {
545                    println!("{}{} ({} rows)", marker, name, ds.len());
546                }
547            }
548        }
549        Ok(())
550    }
551
552    #[allow(clippy::unnecessary_wraps)]
553    fn cmd_history(&self, export: bool) -> Result<()> {
554        if export {
555            print!("{}", self.export_history());
556        } else {
557            for (i, cmd) in self.history.iter().enumerate() {
558                println!("{:4}  {}", i + 1, cmd);
559            }
560        }
561        Ok(())
562    }
563
564    #[allow(clippy::unnecessary_wraps, clippy::unused_self)]
565    fn cmd_help(&self, topic: Option<&str>) -> Result<()> {
566        match topic {
567            None => {
568                println!("alimentar REPL Commands:");
569                println!();
570                println!("Data Loading:");
571                println!("  load <file>          Load a dataset (parquet, csv, json)");
572                println!("  datasets             List loaded datasets");
573                println!("  use <name>           Switch active dataset");
574                println!();
575                println!("Data Inspection:");
576                println!("  info                 Show dataset metadata");
577                println!("  head [n]             Show first n rows (default: 10)");
578                println!("  schema               Display column schema");
579                println!();
580                println!("Quality (Andon):");
581                println!("  quality check        Run quality checks");
582                println!("  quality score        100-point quality score");
583                println!("    --suggest          Show improvement suggestions");
584                println!("    --json             Output as JSON");
585                println!("    --badge            Output shields.io badge URL");
586                println!();
587                println!("Analysis:");
588                println!("  drift detect <ref>   Compare with reference dataset");
589                println!("  convert <format>     Export to format (csv, parquet, json)");
590                println!();
591                println!("Pipeline (Batuta):");
592                println!("  export quality --json  Export quality for PMAT");
593                println!("  validate --schema <f>  Validate against schema spec");
594                println!();
595                println!("Session:");
596                println!("  history              Show command history");
597                println!("  history --export     Export as reproducible script");
598                println!("  help [topic]         Show help (topics: quality, drift, export)");
599                println!("  quit, exit           Exit REPL");
600            }
601            Some("quality") => {
602                println!("Quality Commands (Jidoka - Built-in Quality):");
603                println!();
604                println!("The quality system provides a 100-point scoring based on:");
605                println!("  - Critical (2x weight): Blocks publication");
606                println!("  - High (1.5x weight): Needs immediate attention");
607                println!("  - Medium (1x weight): Should fix before publish");
608                println!("  - Low (0.5x weight): Minor/informational");
609                println!();
610                println!("Letter Grades:");
611                println!("  A (95+): Publish immediately");
612                println!("  B (85-94): Publish with caveats");
613                println!("  C (70-84): Remediation required");
614                println!("  D (50-69): Major rework needed");
615                println!("  F (<50): Do not publish");
616            }
617            Some("drift") => {
618                println!("Drift Detection:");
619                println!();
620                println!("Compare your active dataset against a reference to detect:");
621                println!("  - Statistical distribution changes");
622                println!("  - Schema differences");
623                println!("  - Value range shifts");
624                println!();
625                println!("Usage: drift detect <reference.parquet>");
626            }
627            Some("export") => {
628                println!("Export Commands (Batuta Integration):");
629                println!();
630                println!("  export quality --json   Quality metrics for PMAT");
631                println!("  validate --schema <f>   Pre-transpilation validation");
632                println!("  history --export        Reproducible session script");
633            }
634            Some(t) => {
635                println!("Unknown help topic: '{}'. Try: quality, drift, export", t);
636            }
637        }
638        Ok(())
639    }
640
641    fn cmd_export(&self, what: &str, json: bool) -> Result<()> {
642        match what {
643            "quality" => {
644                if let Some(cache) = &self.quality_cache {
645                    if json {
646                        println!("{}", cache.score.to_json());
647                    } else {
648                        println!("Quality: {} ({:.1})", cache.score.grade, cache.score.score);
649                    }
650                    Ok(())
651                } else {
652                    Err(Error::NotFound("No quality data available".to_string()))
653                }
654            }
655            _ => Err(Error::InvalidFormat(format!(
656                "Unknown export type: {}",
657                what
658            ))),
659        }
660    }
661
662    fn cmd_validate(&self, schema_path: &str) -> Result<()> {
663        let _ = self.require_active()?;
664        // Basic validation - check if schema file exists
665        if std::path::Path::new(schema_path).exists() {
666            println!(
667                "Validation against {} - Feature in development",
668                schema_path
669            );
670            Ok(())
671        } else {
672            Err(Error::NotFound(format!(
673                "Schema file not found: {}",
674                schema_path
675            )))
676        }
677    }
678
679    fn require_active(&self) -> Result<&Arc<ArrowDataset>> {
680        self.active_dataset().ok_or_else(|| {
681            Error::NotFound("No active dataset. Use 'load <file>' first.".to_string())
682        })
683    }
684}
685
686/// Get current time as string (avoids chrono dependency in core)
687fn chrono_now() -> String {
688    use std::time::{SystemTime, UNIX_EPOCH};
689
690    let duration = SystemTime::now()
691        .duration_since(UNIX_EPOCH)
692        .unwrap_or_default();
693
694    format!("{}s since epoch", duration.as_secs())
695}
696
697/// Load dataset from path, auto-detecting format
698fn load_dataset_from_path(path: &str) -> Result<ArrowDataset> {
699    let path = Path::new(path);
700    let extension = path
701        .extension()
702        .and_then(|e| e.to_str())
703        .unwrap_or("")
704        .to_lowercase();
705
706    match extension.as_str() {
707        "parquet" | "pq" => ArrowDataset::from_parquet(path),
708        "csv" => ArrowDataset::from_csv(path),
709        "json" | "jsonl" => ArrowDataset::from_json(path),
710        _ => Err(Error::unsupported_format(format!(
711            "Unknown file extension: .{}. Supported: parquet, csv, json",
712            extension
713        ))),
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use std::sync::Arc;
720
721    use arrow::{
722        array::{Float64Array, Int32Array, StringArray},
723        datatypes::{DataType, Field, Schema as ArrowSchema},
724        record_batch::RecordBatch,
725    };
726
727    use super::*;
728
729    fn create_test_dataset() -> ArrowDataset {
730        let schema = Arc::new(ArrowSchema::new(vec![
731            Field::new("id", DataType::Int32, false),
732            Field::new("name", DataType::Utf8, true),
733            Field::new("value", DataType::Float64, true),
734        ]));
735
736        let batch = RecordBatch::try_new(
737            schema.clone(),
738            vec![
739                Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5])),
740                Arc::new(StringArray::from(vec![
741                    Some("a"),
742                    Some("b"),
743                    None,
744                    Some("d"),
745                    Some("e"),
746                ])),
747                Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0])),
748            ],
749        )
750        .unwrap();
751
752        ArrowDataset::new(vec![batch]).unwrap()
753    }
754
755    // DisplayConfig tests
756    #[test]
757    fn test_display_config_default() {
758        let config = DisplayConfig::default();
759        assert_eq!(config.max_rows, 10);
760        assert_eq!(config.max_column_width, 50);
761        assert!(config.color_output);
762    }
763
764    #[test]
765    fn test_display_config_with_max_rows() {
766        let config = DisplayConfig::default().with_max_rows(20);
767        assert_eq!(config.max_rows, 20);
768    }
769
770    #[test]
771    fn test_display_config_with_max_column_width() {
772        let config = DisplayConfig::default().with_max_column_width(100);
773        assert_eq!(config.max_column_width, 100);
774    }
775
776    #[test]
777    fn test_display_config_with_color() {
778        let config = DisplayConfig::default().with_color(false);
779        assert!(!config.color_output);
780    }
781
782    #[test]
783    fn test_display_config_chained() {
784        let config = DisplayConfig::default()
785            .with_max_rows(25)
786            .with_max_column_width(75)
787            .with_color(false);
788        assert_eq!(config.max_rows, 25);
789        assert_eq!(config.max_column_width, 75);
790        assert!(!config.color_output);
791    }
792
793    // ReplSession tests
794    #[test]
795    fn test_session_new() {
796        let session = ReplSession::new();
797        assert!(session.datasets().is_empty());
798        assert!(session.active_name().is_none());
799        assert!(session.active_dataset().is_none());
800        assert!(session.history().is_empty());
801    }
802
803    #[test]
804    fn test_session_default() {
805        let session = ReplSession::default();
806        assert!(session.datasets().is_empty());
807    }
808
809    #[test]
810    fn test_session_load_dataset() {
811        let mut session = ReplSession::new();
812        let dataset = create_test_dataset();
813
814        session.load_dataset("test", dataset);
815
816        assert_eq!(session.datasets().len(), 1);
817        assert!(session.datasets().contains(&"test".to_string()));
818        assert_eq!(session.active_name(), Some("test".to_string()));
819        assert!(session.active_dataset().is_some());
820    }
821
822    #[test]
823    fn test_session_use_dataset_success() {
824        let mut session = ReplSession::new();
825        session.load_dataset("ds1", create_test_dataset());
826        session.load_dataset("ds2", create_test_dataset());
827
828        assert_eq!(session.active_name(), Some("ds2".to_string()));
829
830        session.use_dataset("ds1").unwrap();
831        assert_eq!(session.active_name(), Some("ds1".to_string()));
832    }
833
834    #[test]
835    fn test_session_use_dataset_not_found() {
836        let mut session = ReplSession::new();
837        let result = session.use_dataset("nonexistent");
838        assert!(result.is_err());
839    }
840
841    #[test]
842    fn test_session_active_row_count() {
843        let mut session = ReplSession::new();
844        assert!(session.active_row_count().is_none());
845
846        session.load_dataset("test", create_test_dataset());
847        assert_eq!(session.active_row_count(), Some(5));
848    }
849
850    #[test]
851    fn test_session_active_grade() {
852        let mut session = ReplSession::new();
853        assert!(session.active_grade().is_none());
854
855        session.load_dataset("test", create_test_dataset());
856        // After loading, quality cache should be populated
857        assert!(session.active_grade().is_some());
858    }
859
860    #[test]
861    fn test_session_column_names() {
862        let mut session = ReplSession::new();
863        assert!(session.column_names().is_empty());
864
865        session.load_dataset("test", create_test_dataset());
866        let cols = session.column_names();
867        assert_eq!(cols.len(), 3);
868        assert!(cols.contains(&"id".to_string()));
869        assert!(cols.contains(&"name".to_string()));
870        assert!(cols.contains(&"value".to_string()));
871    }
872
873    #[test]
874    fn test_session_history() {
875        let mut session = ReplSession::new();
876
877        session.add_history("load test.parquet");
878        session.add_history("head 5");
879        session.add_history("schema");
880
881        assert_eq!(session.history().len(), 3);
882        assert_eq!(session.history()[0], "load test.parquet");
883        assert_eq!(session.history()[1], "head 5");
884        assert_eq!(session.history()[2], "schema");
885    }
886
887    #[test]
888    fn test_session_quality_cache() {
889        let mut session = ReplSession::new();
890        assert!(session.quality_cache().is_none());
891
892        session.load_dataset("test", create_test_dataset());
893        assert!(session.quality_cache().is_some());
894    }
895
896    #[test]
897    fn test_session_export_history() {
898        let mut session = ReplSession::new();
899        session.add_history("load test.parquet");
900        session.add_history("head 5");
901
902        let script = session.export_history();
903        assert!(script.starts_with("#!/usr/bin/env bash"));
904        assert!(script.contains("alimentar load test.parquet"));
905        assert!(script.contains("alimentar head 5"));
906    }
907
908    #[test]
909    fn test_session_export_history_with_active_dataset() {
910        let mut session = ReplSession::new();
911        session.load_dataset("test.parquet", create_test_dataset());
912        session.add_history("info");
913        session.add_history("schema");
914        session.add_history("head");
915
916        let script = session.export_history();
917        assert!(script.contains("alimentar info test.parquet"));
918        assert!(script.contains("alimentar schema test.parquet"));
919        assert!(script.contains("alimentar head test.parquet"));
920    }
921
922    #[test]
923    fn test_session_repl_to_batch_empty() {
924        let session = ReplSession::new();
925        assert_eq!(session.repl_to_batch(""), "");
926    }
927
928    #[test]
929    fn test_session_repl_to_batch_load() {
930        let session = ReplSession::new();
931        assert_eq!(
932            session.repl_to_batch("load test.parquet"),
933            "load test.parquet"
934        );
935    }
936
937    #[test]
938    fn test_session_repl_to_batch_quality_with_args() {
939        let mut session = ReplSession::new();
940        session.load_dataset("data.parquet", create_test_dataset());
941
942        assert_eq!(
943            session.repl_to_batch("quality check"),
944            "quality check data.parquet"
945        );
946    }
947
948    #[test]
949    fn test_session_repl_to_batch_quality_no_active() {
950        let session = ReplSession::new();
951        assert_eq!(session.repl_to_batch("quality check"), "quality check");
952    }
953
954    #[test]
955    fn test_session_repl_to_batch_unknown_command() {
956        let session = ReplSession::new();
957        assert_eq!(session.repl_to_batch("custom cmd"), "custom cmd");
958    }
959
960    // chrono_now test
961    #[test]
962    fn test_chrono_now() {
963        let now = chrono_now();
964        assert!(now.contains("since epoch"));
965        // Should contain a number
966        assert!(now.chars().any(|c| c.is_ascii_digit()));
967    }
968
969    // load_dataset_from_path tests
970    #[test]
971    fn test_load_dataset_unknown_extension() {
972        let result = load_dataset_from_path("test.xyz");
973        assert!(result.is_err());
974        let err = result.unwrap_err().to_string();
975        assert!(err.contains("Unknown file extension"));
976    }
977
978    #[test]
979    fn test_load_dataset_no_extension() {
980        let result = load_dataset_from_path("test");
981        assert!(result.is_err());
982    }
983
984    // Execute command tests with actual dataset
985    #[test]
986    fn test_execute_info() {
987        let mut session = ReplSession::new();
988        session.load_dataset("test", create_test_dataset());
989
990        let result = session.execute(ReplCommand::Info);
991        assert!(result.is_ok());
992    }
993
994    #[test]
995    fn test_execute_info_no_dataset() {
996        let mut session = ReplSession::new();
997        let result = session.execute(ReplCommand::Info);
998        assert!(result.is_err());
999    }
1000
1001    #[test]
1002    fn test_execute_schema() {
1003        let mut session = ReplSession::new();
1004        session.load_dataset("test", create_test_dataset());
1005
1006        let result = session.execute(ReplCommand::Schema);
1007        assert!(result.is_ok());
1008    }
1009
1010    #[test]
1011    fn test_execute_head() {
1012        let mut session = ReplSession::new();
1013        session.load_dataset("test", create_test_dataset());
1014
1015        let result = session.execute(ReplCommand::Head { n: 3 });
1016        assert!(result.is_ok());
1017    }
1018
1019    #[test]
1020    fn test_execute_head_no_dataset() {
1021        let mut session = ReplSession::new();
1022        let result = session.execute(ReplCommand::Head { n: 3 });
1023        assert!(result.is_err());
1024    }
1025
1026    #[test]
1027    fn test_execute_quality_check() {
1028        let mut session = ReplSession::new();
1029        session.load_dataset("test", create_test_dataset());
1030
1031        let result = session.execute(ReplCommand::QualityCheck);
1032        assert!(result.is_ok());
1033    }
1034
1035    #[test]
1036    fn test_execute_quality_score() {
1037        let mut session = ReplSession::new();
1038        session.load_dataset("test", create_test_dataset());
1039
1040        let result = session.execute(ReplCommand::QualityScore {
1041            suggest: false,
1042            json: false,
1043            badge: false,
1044        });
1045        assert!(result.is_ok());
1046    }
1047
1048    #[test]
1049    fn test_execute_quality_score_suggest() {
1050        let mut session = ReplSession::new();
1051        session.load_dataset("test", create_test_dataset());
1052
1053        let result = session.execute(ReplCommand::QualityScore {
1054            suggest: true,
1055            json: false,
1056            badge: false,
1057        });
1058        assert!(result.is_ok());
1059    }
1060
1061    #[test]
1062    fn test_execute_quality_score_json() {
1063        let mut session = ReplSession::new();
1064        session.load_dataset("test", create_test_dataset());
1065
1066        let result = session.execute(ReplCommand::QualityScore {
1067            suggest: false,
1068            json: true,
1069            badge: false,
1070        });
1071        assert!(result.is_ok());
1072    }
1073
1074    #[test]
1075    fn test_execute_quality_score_badge() {
1076        let mut session = ReplSession::new();
1077        session.load_dataset("test", create_test_dataset());
1078
1079        let result = session.execute(ReplCommand::QualityScore {
1080            suggest: false,
1081            json: false,
1082            badge: true,
1083        });
1084        assert!(result.is_ok());
1085    }
1086
1087    #[test]
1088    fn test_execute_quality_score_no_cache() {
1089        let mut session = ReplSession::new();
1090        let result = session.execute(ReplCommand::QualityScore {
1091            suggest: false,
1092            json: false,
1093            badge: false,
1094        });
1095        assert!(result.is_err());
1096    }
1097
1098    #[test]
1099    fn test_execute_datasets_empty() {
1100        let mut session = ReplSession::new();
1101        let result = session.execute(ReplCommand::Datasets);
1102        assert!(result.is_ok());
1103    }
1104
1105    #[test]
1106    fn test_execute_datasets_with_data() {
1107        let mut session = ReplSession::new();
1108        session.load_dataset("ds1", create_test_dataset());
1109        session.load_dataset("ds2", create_test_dataset());
1110
1111        let result = session.execute(ReplCommand::Datasets);
1112        assert!(result.is_ok());
1113    }
1114
1115    #[test]
1116    fn test_execute_history() {
1117        let mut session = ReplSession::new();
1118        session.add_history("test cmd");
1119
1120        let result = session.execute(ReplCommand::History { export: false });
1121        assert!(result.is_ok());
1122    }
1123
1124    #[test]
1125    fn test_execute_history_export() {
1126        let mut session = ReplSession::new();
1127        session.add_history("test cmd");
1128
1129        let result = session.execute(ReplCommand::History { export: true });
1130        assert!(result.is_ok());
1131    }
1132
1133    #[test]
1134    fn test_execute_help_none() {
1135        let mut session = ReplSession::new();
1136        let result = session.execute(ReplCommand::Help { topic: None });
1137        assert!(result.is_ok());
1138    }
1139
1140    #[test]
1141    fn test_execute_help_quality() {
1142        let mut session = ReplSession::new();
1143        let result = session.execute(ReplCommand::Help {
1144            topic: Some("quality".to_string()),
1145        });
1146        assert!(result.is_ok());
1147    }
1148
1149    #[test]
1150    fn test_execute_help_drift() {
1151        let mut session = ReplSession::new();
1152        let result = session.execute(ReplCommand::Help {
1153            topic: Some("drift".to_string()),
1154        });
1155        assert!(result.is_ok());
1156    }
1157
1158    #[test]
1159    fn test_execute_help_export() {
1160        let mut session = ReplSession::new();
1161        let result = session.execute(ReplCommand::Help {
1162            topic: Some("export".to_string()),
1163        });
1164        assert!(result.is_ok());
1165    }
1166
1167    #[test]
1168    fn test_execute_help_unknown() {
1169        let mut session = ReplSession::new();
1170        let result = session.execute(ReplCommand::Help {
1171            topic: Some("unknown".to_string()),
1172        });
1173        assert!(result.is_ok());
1174    }
1175
1176    #[test]
1177    fn test_execute_export_quality() {
1178        let mut session = ReplSession::new();
1179        session.load_dataset("test", create_test_dataset());
1180
1181        let result = session.execute(ReplCommand::Export {
1182            what: "quality".to_string(),
1183            json: false,
1184        });
1185        assert!(result.is_ok());
1186    }
1187
1188    #[test]
1189    fn test_execute_export_quality_json() {
1190        let mut session = ReplSession::new();
1191        session.load_dataset("test", create_test_dataset());
1192
1193        let result = session.execute(ReplCommand::Export {
1194            what: "quality".to_string(),
1195            json: true,
1196        });
1197        assert!(result.is_ok());
1198    }
1199
1200    #[test]
1201    fn test_execute_export_quality_no_cache() {
1202        let mut session = ReplSession::new();
1203        let result = session.execute(ReplCommand::Export {
1204            what: "quality".to_string(),
1205            json: false,
1206        });
1207        assert!(result.is_err());
1208    }
1209
1210    #[test]
1211    fn test_execute_export_unknown() {
1212        let mut session = ReplSession::new();
1213        session.load_dataset("test", create_test_dataset());
1214
1215        let result = session.execute(ReplCommand::Export {
1216            what: "unknown".to_string(),
1217            json: false,
1218        });
1219        assert!(result.is_err());
1220    }
1221
1222    #[test]
1223    fn test_execute_validate_file_not_found() {
1224        let mut session = ReplSession::new();
1225        session.load_dataset("test", create_test_dataset());
1226
1227        let result = session.execute(ReplCommand::Validate {
1228            schema: "nonexistent.json".to_string(),
1229        });
1230        assert!(result.is_err());
1231    }
1232
1233    #[test]
1234    fn test_execute_convert_invalid_format() {
1235        let mut session = ReplSession::new();
1236        session.load_dataset("test", create_test_dataset());
1237
1238        let result = session.execute(ReplCommand::Convert {
1239            format: "invalid".to_string(),
1240        });
1241        assert!(result.is_err());
1242    }
1243
1244    #[test]
1245    fn test_execute_quit() {
1246        let mut session = ReplSession::new();
1247        let result = session.execute(ReplCommand::Quit);
1248        assert!(result.is_ok());
1249    }
1250
1251    #[test]
1252    fn test_execute_use() {
1253        let mut session = ReplSession::new();
1254        session.load_dataset("test", create_test_dataset());
1255
1256        let result = session.execute(ReplCommand::Use {
1257            name: "test".to_string(),
1258        });
1259        assert!(result.is_ok());
1260    }
1261
1262    #[test]
1263    fn test_execute_use_not_found() {
1264        let mut session = ReplSession::new();
1265        let result = session.execute(ReplCommand::Use {
1266            name: "nonexistent".to_string(),
1267        });
1268        assert!(result.is_err());
1269    }
1270
1271    #[test]
1272    fn test_require_active_none() {
1273        let session = ReplSession::new();
1274        let result = session.require_active();
1275        assert!(result.is_err());
1276    }
1277
1278    #[test]
1279    fn test_require_active_some() {
1280        let mut session = ReplSession::new();
1281        session.load_dataset("test", create_test_dataset());
1282
1283        let result = session.require_active();
1284        assert!(result.is_ok());
1285    }
1286
1287    // Multiple datasets test
1288    #[test]
1289    fn test_multiple_datasets() {
1290        let mut session = ReplSession::new();
1291        session.load_dataset("first", create_test_dataset());
1292        session.load_dataset("second", create_test_dataset());
1293        session.load_dataset("third", create_test_dataset());
1294
1295        assert_eq!(session.datasets().len(), 3);
1296        assert_eq!(session.active_name(), Some("third".to_string()));
1297
1298        session.use_dataset("first").unwrap();
1299        assert_eq!(session.active_name(), Some("first".to_string()));
1300    }
1301
1302    // Quality checklist tests
1303    #[test]
1304    fn test_build_basic_checklist() {
1305        let session = ReplSession::new();
1306        let dataset = create_test_dataset();
1307        let checker = QualityChecker::new();
1308        let report = checker.check(&dataset).unwrap();
1309
1310        let checklist = session.build_basic_checklist(&report);
1311        assert_eq!(checklist.len(), 5);
1312    }
1313}