Skip to main content

alimentar/repl/
prompt.rs

1//! Andon-style Prompt (Visual Control)
2//!
3//! The prompt displays immediate visual feedback about dataset health,
4//! following the Andon board concept from Toyota Way.
5//!
6//! # Prompt Design (from ALIM-SPEC-006)
7//!
8//! ```text
9//! # Healthy dataset (green)
10//! alimentar [data.parquet: 1512 rows, A] >
11//!
12//! # Issues detected (yellow)
13//! alimentar [data.parquet: 1512 rows, C!] >
14//!
15//! # Critical failures (red)
16//! alimentar [data.parquet: INVALID] >
17//! ```
18
19use std::fmt::Write;
20
21#[cfg(feature = "repl")]
22use nu_ansi_term::{Color, Style};
23#[cfg(feature = "repl")]
24use reedline::Prompt;
25
26use super::session::ReplSession;
27
28/// Health status for Andon display
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum HealthStatus {
31    /// Green - Grade A or B, publishable
32    Healthy,
33    /// Yellow - Grade C or D, needs attention
34    Warning,
35    /// Red - Grade F or invalid data
36    Critical,
37    /// No dataset loaded
38    None,
39}
40
41impl HealthStatus {
42    /// Create health status from letter grade
43    #[must_use]
44    pub fn from_grade(grade: char) -> Self {
45        match grade {
46            'A' | 'B' => Self::Healthy,
47            'C' | 'D' => Self::Warning,
48            'F' => Self::Critical,
49            _ => Self::None,
50        }
51    }
52
53    /// Get ANSI color for this status
54    #[cfg(feature = "repl")]
55    #[must_use]
56    pub fn color(&self) -> Color {
57        match self {
58            Self::Healthy => Color::Green,
59            Self::Warning => Color::Yellow,
60            Self::Critical => Color::Red,
61            Self::None => Color::Default,
62        }
63    }
64
65    /// Get plain text indicator
66    #[must_use]
67    pub fn indicator(&self) -> &'static str {
68        match self {
69            Self::Warning => "!",
70            Self::Critical => "!!",
71            Self::Healthy | Self::None => "",
72        }
73    }
74}
75
76/// Andon-style prompt for REPL
77pub struct AndonPrompt {
78    /// Base prompt text
79    #[allow(dead_code)]
80    base: String,
81}
82
83impl Default for AndonPrompt {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl AndonPrompt {
90    /// Create a new Andon prompt
91    #[must_use]
92    pub fn new() -> Self {
93        Self {
94            base: "alimentar".to_string(),
95        }
96    }
97
98    /// Render prompt string from session state (plain text)
99    #[must_use]
100    pub fn render(session: &ReplSession) -> String {
101        let mut prompt = String::from("alimentar");
102
103        if let Some(name) = session.active_name() {
104            prompt.push_str(" [");
105            prompt.push_str(&name);
106
107            if let Some(rows) = session.active_row_count() {
108                let _ = write!(prompt, ": {} rows", rows);
109            }
110
111            if let Some(grade) = session.active_grade() {
112                let status =
113                    HealthStatus::from_grade(grade.to_string().chars().next().unwrap_or(' '));
114                let _ = write!(prompt, ", {}{}", grade, status.indicator());
115            }
116
117            prompt.push(']');
118        }
119
120        prompt.push_str(" > ");
121        prompt
122    }
123
124    /// Render prompt with colors for terminal
125    #[cfg(feature = "repl")]
126    #[must_use]
127    pub fn render_colored(session: &ReplSession) -> String {
128        let mut prompt = Style::new().bold().paint("alimentar").to_string();
129
130        if let Some(name) = session.active_name() {
131            prompt.push_str(" [");
132            prompt.push_str(&name);
133
134            if let Some(rows) = session.active_row_count() {
135                let _ = write!(prompt, ": {} rows", rows);
136            }
137
138            if let Some(grade) = session.active_grade() {
139                let grade_char = grade.to_string().chars().next().unwrap_or(' ');
140                let status = HealthStatus::from_grade(grade_char);
141                let grade_colored =
142                    status
143                        .color()
144                        .bold()
145                        .paint(format!("{}{}", grade, status.indicator()));
146                let _ = write!(prompt, ", {}", grade_colored);
147            }
148
149            prompt.push(']');
150        }
151
152        prompt.push_str(" > ");
153        prompt
154    }
155}
156
157#[cfg(feature = "repl")]
158impl Prompt for AndonPrompt {
159    fn render_prompt_left(&self) -> std::borrow::Cow<'_, str> {
160        // Basic prompt - session state would need to be passed differently
161        // for dynamic updates. This is the static fallback.
162        std::borrow::Cow::Borrowed("alimentar > ")
163    }
164
165    fn render_prompt_right(&self) -> std::borrow::Cow<'_, str> {
166        std::borrow::Cow::Borrowed("")
167    }
168
169    fn render_prompt_indicator(
170        &self,
171        _prompt_mode: reedline::PromptEditMode,
172    ) -> std::borrow::Cow<'_, str> {
173        std::borrow::Cow::Borrowed("")
174    }
175
176    fn render_prompt_multiline_indicator(&self) -> std::borrow::Cow<'_, str> {
177        std::borrow::Cow::Borrowed("... ")
178    }
179
180    fn render_prompt_history_search_indicator(
181        &self,
182        _history_search: reedline::PromptHistorySearch,
183    ) -> std::borrow::Cow<'_, str> {
184        std::borrow::Cow::Borrowed("(search) ")
185    }
186}
187
188/// Session-aware prompt that updates based on state
189#[cfg(feature = "repl")]
190#[allow(dead_code)]
191pub struct SessionPrompt<'a> {
192    session: &'a ReplSession,
193}
194
195#[cfg(feature = "repl")]
196#[allow(dead_code)]
197impl<'a> SessionPrompt<'a> {
198    /// Create a new session-aware prompt
199    #[must_use]
200    pub fn new(session: &'a ReplSession) -> Self {
201        Self { session }
202    }
203}
204
205#[cfg(feature = "repl")]
206impl Prompt for SessionPrompt<'_> {
207    fn render_prompt_left(&self) -> std::borrow::Cow<'_, str> {
208        std::borrow::Cow::Owned(AndonPrompt::render_colored(self.session))
209    }
210
211    fn render_prompt_right(&self) -> std::borrow::Cow<'_, str> {
212        std::borrow::Cow::Borrowed("")
213    }
214
215    fn render_prompt_indicator(
216        &self,
217        _prompt_mode: reedline::PromptEditMode,
218    ) -> std::borrow::Cow<'_, str> {
219        std::borrow::Cow::Borrowed("")
220    }
221
222    fn render_prompt_multiline_indicator(&self) -> std::borrow::Cow<'_, str> {
223        std::borrow::Cow::Borrowed("... ")
224    }
225
226    fn render_prompt_history_search_indicator(
227        &self,
228        _history_search: reedline::PromptHistorySearch,
229    ) -> std::borrow::Cow<'_, str> {
230        std::borrow::Cow::Borrowed("(search) ")
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use std::sync::Arc;
237
238    use arrow::{
239        array::{Float64Array, Int32Array, StringArray},
240        datatypes::{DataType, Field, Schema as ArrowSchema},
241        record_batch::RecordBatch,
242    };
243
244    use super::*;
245    use crate::ArrowDataset;
246
247    fn create_test_dataset() -> ArrowDataset {
248        let schema = Arc::new(ArrowSchema::new(vec![
249            Field::new("id", DataType::Int32, false),
250            Field::new("name", DataType::Utf8, true),
251            Field::new("value", DataType::Float64, true),
252        ]));
253
254        let batch = RecordBatch::try_new(
255            schema.clone(),
256            vec![
257                Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5])),
258                Arc::new(StringArray::from(vec![
259                    Some("a"),
260                    Some("b"),
261                    None,
262                    Some("d"),
263                    Some("e"),
264                ])),
265                Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0])),
266            ],
267        )
268        .unwrap();
269
270        ArrowDataset::new(vec![batch]).unwrap()
271    }
272
273    // HealthStatus tests
274    #[test]
275    fn test_health_status_from_grade_a() {
276        assert_eq!(HealthStatus::from_grade('A'), HealthStatus::Healthy);
277    }
278
279    #[test]
280    fn test_health_status_from_grade_b() {
281        assert_eq!(HealthStatus::from_grade('B'), HealthStatus::Healthy);
282    }
283
284    #[test]
285    fn test_health_status_from_grade_c() {
286        assert_eq!(HealthStatus::from_grade('C'), HealthStatus::Warning);
287    }
288
289    #[test]
290    fn test_health_status_from_grade_d() {
291        assert_eq!(HealthStatus::from_grade('D'), HealthStatus::Warning);
292    }
293
294    #[test]
295    fn test_health_status_from_grade_f() {
296        assert_eq!(HealthStatus::from_grade('F'), HealthStatus::Critical);
297    }
298
299    #[test]
300    fn test_health_status_from_grade_unknown() {
301        assert_eq!(HealthStatus::from_grade('X'), HealthStatus::None);
302    }
303
304    #[test]
305    fn test_health_status_indicator_healthy() {
306        assert_eq!(HealthStatus::Healthy.indicator(), "");
307    }
308
309    #[test]
310    fn test_health_status_indicator_warning() {
311        assert_eq!(HealthStatus::Warning.indicator(), "!");
312    }
313
314    #[test]
315    fn test_health_status_indicator_critical() {
316        assert_eq!(HealthStatus::Critical.indicator(), "!!");
317    }
318
319    #[test]
320    fn test_health_status_indicator_none() {
321        assert_eq!(HealthStatus::None.indicator(), "");
322    }
323
324    #[cfg(feature = "repl")]
325    #[test]
326    fn test_health_status_color_healthy() {
327        assert_eq!(HealthStatus::Healthy.color(), Color::Green);
328    }
329
330    #[cfg(feature = "repl")]
331    #[test]
332    fn test_health_status_color_warning() {
333        assert_eq!(HealthStatus::Warning.color(), Color::Yellow);
334    }
335
336    #[cfg(feature = "repl")]
337    #[test]
338    fn test_health_status_color_critical() {
339        assert_eq!(HealthStatus::Critical.color(), Color::Red);
340    }
341
342    #[cfg(feature = "repl")]
343    #[test]
344    fn test_health_status_color_none() {
345        assert_eq!(HealthStatus::None.color(), Color::Default);
346    }
347
348    // AndonPrompt tests
349    #[test]
350    fn test_andon_prompt_new() {
351        let prompt = AndonPrompt::new();
352        assert_eq!(prompt.base, "alimentar");
353    }
354
355    #[test]
356    fn test_andon_prompt_default() {
357        let prompt = AndonPrompt::default();
358        assert_eq!(prompt.base, "alimentar");
359    }
360
361    #[test]
362    fn test_andon_prompt_render_no_dataset() {
363        let session = ReplSession::new();
364        let rendered = AndonPrompt::render(&session);
365        assert_eq!(rendered, "alimentar > ");
366    }
367
368    #[test]
369    fn test_andon_prompt_render_with_dataset() {
370        let mut session = ReplSession::new();
371        session.load_dataset("test.parquet", create_test_dataset());
372        let rendered = AndonPrompt::render(&session);
373        assert!(rendered.starts_with("alimentar [test.parquet"));
374        assert!(rendered.contains("5 rows"));
375        assert!(rendered.ends_with("] > "));
376    }
377
378    #[cfg(feature = "repl")]
379    #[test]
380    fn test_andon_prompt_render_colored_no_dataset() {
381        let session = ReplSession::new();
382        let rendered = AndonPrompt::render_colored(&session);
383        assert!(rendered.contains("alimentar"));
384        assert!(rendered.ends_with(" > "));
385    }
386
387    #[cfg(feature = "repl")]
388    #[test]
389    fn test_andon_prompt_render_colored_with_dataset() {
390        let mut session = ReplSession::new();
391        session.load_dataset("data.parquet", create_test_dataset());
392        let rendered = AndonPrompt::render_colored(&session);
393        assert!(rendered.contains("data.parquet"));
394        assert!(rendered.contains("5 rows"));
395    }
396
397    // Prompt trait tests
398    #[cfg(feature = "repl")]
399    #[test]
400    fn test_andon_prompt_render_prompt_left() {
401        use reedline::Prompt;
402        let prompt = AndonPrompt::new();
403        assert_eq!(prompt.render_prompt_left().as_ref(), "alimentar > ");
404    }
405
406    #[cfg(feature = "repl")]
407    #[test]
408    fn test_andon_prompt_render_prompt_right() {
409        use reedline::Prompt;
410        let prompt = AndonPrompt::new();
411        assert_eq!(prompt.render_prompt_right().as_ref(), "");
412    }
413
414    #[cfg(feature = "repl")]
415    #[test]
416    fn test_andon_prompt_render_prompt_indicator() {
417        use reedline::Prompt;
418        let prompt = AndonPrompt::new();
419        assert_eq!(
420            prompt
421                .render_prompt_indicator(reedline::PromptEditMode::Default)
422                .as_ref(),
423            ""
424        );
425    }
426
427    #[cfg(feature = "repl")]
428    #[test]
429    fn test_andon_prompt_render_multiline() {
430        use reedline::Prompt;
431        let prompt = AndonPrompt::new();
432        assert_eq!(prompt.render_prompt_multiline_indicator().as_ref(), "... ");
433    }
434
435    #[cfg(feature = "repl")]
436    #[test]
437    fn test_andon_prompt_render_history_search() {
438        use reedline::Prompt;
439        let prompt = AndonPrompt::new();
440        let search = reedline::PromptHistorySearch::new(
441            reedline::PromptHistorySearchStatus::Passing,
442            "test".to_string(),
443        );
444        assert_eq!(
445            prompt
446                .render_prompt_history_search_indicator(search)
447                .as_ref(),
448            "(search) "
449        );
450    }
451
452    // SessionPrompt tests
453    #[cfg(feature = "repl")]
454    #[test]
455    fn test_session_prompt_new() {
456        let session = ReplSession::new();
457        let _prompt = SessionPrompt::new(&session);
458    }
459
460    #[cfg(feature = "repl")]
461    #[test]
462    fn test_session_prompt_render_prompt_left() {
463        use reedline::Prompt;
464        let session = ReplSession::new();
465        let prompt = SessionPrompt::new(&session);
466        let rendered = prompt.render_prompt_left();
467        assert!(rendered.contains("alimentar"));
468    }
469
470    #[cfg(feature = "repl")]
471    #[test]
472    fn test_session_prompt_render_prompt_right() {
473        use reedline::Prompt;
474        let session = ReplSession::new();
475        let prompt = SessionPrompt::new(&session);
476        assert_eq!(prompt.render_prompt_right().as_ref(), "");
477    }
478
479    #[cfg(feature = "repl")]
480    #[test]
481    fn test_session_prompt_render_prompt_indicator() {
482        use reedline::Prompt;
483        let session = ReplSession::new();
484        let prompt = SessionPrompt::new(&session);
485        assert_eq!(
486            prompt
487                .render_prompt_indicator(reedline::PromptEditMode::Default)
488                .as_ref(),
489            ""
490        );
491    }
492
493    #[cfg(feature = "repl")]
494    #[test]
495    fn test_session_prompt_render_multiline() {
496        use reedline::Prompt;
497        let session = ReplSession::new();
498        let prompt = SessionPrompt::new(&session);
499        assert_eq!(prompt.render_prompt_multiline_indicator().as_ref(), "... ");
500    }
501
502    #[cfg(feature = "repl")]
503    #[test]
504    fn test_session_prompt_render_history_search() {
505        use reedline::Prompt;
506        let session = ReplSession::new();
507        let prompt = SessionPrompt::new(&session);
508        let search = reedline::PromptHistorySearch::new(
509            reedline::PromptHistorySearchStatus::Passing,
510            "test".to_string(),
511        );
512        assert_eq!(
513            prompt
514                .render_prompt_history_search_indicator(search)
515                .as_ref(),
516            "(search) "
517        );
518    }
519
520    #[cfg(feature = "repl")]
521    #[test]
522    fn test_session_prompt_with_dataset() {
523        use reedline::Prompt;
524        let mut session = ReplSession::new();
525        session.load_dataset("mydata.csv", create_test_dataset());
526        let prompt = SessionPrompt::new(&session);
527        let rendered = prompt.render_prompt_left();
528        assert!(rendered.contains("mydata.csv"));
529        assert!(rendered.contains("5 rows"));
530    }
531}