Skip to main content

alimentar/repl/
commands.rs

1//! REPL Command Parser (ALIM-REPL-003)
2//!
3//! Parses user input into structured commands.
4//! Command grammar mirrors batch CLI for Standard Work (Hyojun) compliance.
5
6use crate::{Error, Result};
7
8/// REPL commands matching batch CLI grammar (Standard Work)
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum ReplCommand {
11    // ─────────────────────────────────────────────────────────────────────────────
12    // Data Loading Commands
13    // ─────────────────────────────────────────────────────────────────────────────
14    /// Load a dataset from file
15    Load {
16        /// Path to the dataset file
17        path: String,
18    },
19
20    /// List loaded datasets
21    Datasets,
22
23    /// Switch active dataset
24    Use {
25        /// Name of the dataset to switch to
26        name: String,
27    },
28
29    // ─────────────────────────────────────────────────────────────────────────────
30    // Data Inspection Commands
31    // ─────────────────────────────────────────────────────────────────────────────
32    /// Display dataset metadata
33    Info,
34
35    /// Show first n rows
36    Head {
37        /// Number of rows to display
38        n: usize,
39    },
40
41    /// Display column schema
42    Schema,
43
44    // ─────────────────────────────────────────────────────────────────────────────
45    // Quality Commands (Andon)
46    // ─────────────────────────────────────────────────────────────────────────────
47    /// Run quality checks
48    QualityCheck,
49
50    /// Compute 100-point quality score
51    QualityScore {
52        /// Show improvement suggestions
53        suggest: bool,
54        /// Output as JSON
55        json: bool,
56        /// Output as badge URL
57        badge: bool,
58    },
59
60    // ─────────────────────────────────────────────────────────────────────────────
61    // Analysis Commands
62    // ─────────────────────────────────────────────────────────────────────────────
63    /// Detect drift against reference
64    DriftDetect {
65        /// Path to reference dataset
66        reference: String,
67    },
68
69    /// Convert/export to format
70    Convert {
71        /// Target format (csv, parquet, json)
72        format: String,
73    },
74
75    // ─────────────────────────────────────────────────────────────────────────────
76    // Pipeline Commands (Batuta Integration - ALIM-REPL-005)
77    // ─────────────────────────────────────────────────────────────────────────────
78    /// Export data for pipeline integration
79    Export {
80        /// What to export (quality)
81        what: String,
82        /// Output as JSON
83        json: bool,
84    },
85
86    /// Validate against schema spec
87    Validate {
88        /// Path to schema specification
89        schema: String,
90    },
91
92    // ─────────────────────────────────────────────────────────────────────────────
93    // Session Commands
94    // ─────────────────────────────────────────────────────────────────────────────
95    /// Show/export command history (ALIM-REPL-006)
96    History {
97        /// Export as reproducible script
98        export: bool,
99    },
100
101    /// Show help
102    Help {
103        /// Help topic (quality, drift, export)
104        topic: Option<String>,
105    },
106
107    /// Exit REPL
108    Quit,
109}
110
111/// Parser for REPL commands (Poka-Yoke input validation)
112pub struct CommandParser;
113
114impl CommandParser {
115    /// Parse a command string into a ReplCommand
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if the command is invalid or unknown.
120    pub fn parse(input: &str) -> Result<ReplCommand> {
121        let input = input.trim();
122
123        if input.is_empty() {
124            return Err(Error::parse("Empty command"));
125        }
126
127        let parts: Vec<&str> = input.split_whitespace().collect();
128        let cmd = parts[0].to_lowercase();
129        let args = &parts[1..];
130
131        match cmd.as_str() {
132            // Data Loading
133            "load" => Self::parse_load(args),
134            "datasets" => Ok(ReplCommand::Datasets),
135            "use" => Self::parse_use(args),
136
137            // Data Inspection
138            "info" => Ok(ReplCommand::Info),
139            "head" => Self::parse_head(args),
140            "schema" => Ok(ReplCommand::Schema),
141
142            // Quality
143            "quality" => Self::parse_quality(args),
144
145            // Analysis
146            "drift" => Self::parse_drift(args),
147            "convert" => Self::parse_convert(args),
148
149            // Pipeline
150            "export" => Self::parse_export(args),
151            "validate" => Self::parse_validate(args),
152
153            // Session
154            "history" => Ok(Self::parse_history(args)),
155            "help" | "?" => Ok(Self::parse_help(args)),
156            "quit" | "exit" | "q" => Ok(ReplCommand::Quit),
157
158            _ => Err(Error::parse(format!("Unknown command: '{}'", cmd))),
159        }
160    }
161
162    fn parse_load(args: &[&str]) -> Result<ReplCommand> {
163        if args.is_empty() {
164            return Err(Error::parse("load requires a file path"));
165        }
166        Ok(ReplCommand::Load {
167            path: args[0].to_string(),
168        })
169    }
170
171    fn parse_use(args: &[&str]) -> Result<ReplCommand> {
172        if args.is_empty() {
173            return Err(Error::parse("use requires a dataset name"));
174        }
175        Ok(ReplCommand::Use {
176            name: args[0].to_string(),
177        })
178    }
179
180    fn parse_head(args: &[&str]) -> Result<ReplCommand> {
181        let n = if args.is_empty() {
182            10 // Default per spec
183        } else {
184            args[0]
185                .parse()
186                .map_err(|_| Error::parse(format!("Invalid number: '{}'", args[0])))?
187        };
188        Ok(ReplCommand::Head { n })
189    }
190
191    fn parse_quality(args: &[&str]) -> Result<ReplCommand> {
192        if args.is_empty() {
193            return Err(Error::parse("quality requires subcommand: check, score"));
194        }
195
196        match args[0].to_lowercase().as_str() {
197            "check" => Ok(ReplCommand::QualityCheck),
198            "score" => {
199                let suggest = args.contains(&"--suggest");
200                let json = args.contains(&"--json");
201                let badge = args.contains(&"--badge");
202                Ok(ReplCommand::QualityScore {
203                    suggest,
204                    json,
205                    badge,
206                })
207            }
208            _ => Err(Error::parse(format!(
209                "Unknown quality subcommand: '{}'. Use: check, score",
210                args[0]
211            ))),
212        }
213    }
214
215    fn parse_drift(args: &[&str]) -> Result<ReplCommand> {
216        if args.is_empty() {
217            return Err(Error::parse("drift requires subcommand: detect"));
218        }
219
220        match args[0].to_lowercase().as_str() {
221            "detect" => {
222                if args.len() < 2 {
223                    return Err(Error::parse("drift detect requires a reference file"));
224                }
225                Ok(ReplCommand::DriftDetect {
226                    reference: args[1].to_string(),
227                })
228            }
229            _ => Err(Error::parse(format!(
230                "Unknown drift subcommand: '{}'. Use: detect",
231                args[0]
232            ))),
233        }
234    }
235
236    fn parse_convert(args: &[&str]) -> Result<ReplCommand> {
237        if args.is_empty() {
238            return Err(Error::parse(
239                "convert requires a format: csv, parquet, json",
240            ));
241        }
242        let format = args[0].to_lowercase();
243        match format.as_str() {
244            "csv" | "parquet" | "json" => Ok(ReplCommand::Convert { format }),
245            _ => Err(Error::parse(format!(
246                "Unknown format: '{}'. Use: csv, parquet, json",
247                args[0]
248            ))),
249        }
250    }
251
252    fn parse_export(args: &[&str]) -> Result<ReplCommand> {
253        if args.is_empty() {
254            return Err(Error::parse("export requires what to export: quality"));
255        }
256
257        let what = args[0].to_lowercase();
258        let json = args.iter().any(|f| *f == "--json" || *f == "-j");
259
260        Ok(ReplCommand::Export { what, json })
261    }
262
263    fn parse_validate(args: &[&str]) -> Result<ReplCommand> {
264        // Look for --schema flag
265        let schema_idx = args.iter().position(|f| *f == "--schema" || *f == "-s");
266
267        let schema = if let Some(idx) = schema_idx {
268            if idx + 1 < args.len() {
269                args[idx + 1].to_string()
270            } else {
271                return Err(Error::parse("--schema requires a file path"));
272            }
273        } else {
274            return Err(Error::parse("validate requires --schema <file>"));
275        };
276
277        Ok(ReplCommand::Validate { schema })
278    }
279
280    fn parse_history(args: &[&str]) -> ReplCommand {
281        let export = args.iter().any(|f| *f == "--export" || *f == "-e");
282        ReplCommand::History { export }
283    }
284
285    fn parse_help(args: &[&str]) -> ReplCommand {
286        let topic = args.first().map(|s| (*s).to_string());
287        ReplCommand::Help { topic }
288    }
289
290    /// Get all valid command names for autocomplete
291    #[must_use]
292    pub fn command_names() -> Vec<&'static str> {
293        vec![
294            "load", "datasets", "use", "info", "head", "schema", "quality", "drift", "convert",
295            "export", "validate", "history", "help", "quit", "exit",
296        ]
297    }
298
299    /// Get subcommands for a given command
300    #[must_use]
301    pub fn subcommands(command: &str) -> Vec<&'static str> {
302        match command {
303            "quality" => vec!["check", "score"],
304            "drift" => vec!["detect"],
305            _ => vec![],
306        }
307    }
308
309    /// Get flags for a given command
310    #[must_use]
311    pub fn flags(command: &str, subcommand: Option<&str>) -> Vec<&'static str> {
312        match (command, subcommand) {
313            ("quality", Some("score")) => vec!["--suggest", "--json", "--badge"],
314            ("export", _) => vec!["--json"],
315            ("validate", _) => vec!["--schema"],
316            ("history", _) => vec!["--export"],
317            _ => vec![],
318        }
319    }
320}