kaish-kernel 0.8.0

Core kernel for kaish: lexer, parser, interpreter, and runtime
Documentation
//! read — Read a line from standard input into variables.
//!
//! Implements the shell `read` builtin for reading input.
//!
//! Usage:
//!   read VAR                    # Read line into VAR
//!   read -r VAR                 # Raw mode (no backslash processing)
//!   read -p "prompt: " VAR      # Show prompt before reading
//!   read VAR1 VAR2 VAR3         # Split line into multiple variables

use async_trait::async_trait;
use clap::{CommandFactory, Parser};

use crate::ast::Value;
use crate::interpreter::ExecResult;
use crate::tools::{schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};

/// Read tool: reads a line from stdin into variable(s).
pub struct Read;

/// clap-derived argv layer for read. See docs/clap-migration.md.
#[derive(Parser, Debug)]
#[command(name = "read", about = "Read a line from standard input into variables")]
struct ReadArgs {
    /// Raw mode — do not process backslash escapes.
    #[arg(id = "raw", short = 'r', long = "raw")]
    _raw: bool,

    /// Prompt to display before reading.
    #[arg(id = "prompt", short = 'p', long = "prompt")]
    _prompt: Option<String>,

    #[command(flatten)]
    global: GlobalFlags,

    /// Variable names to receive the words read from stdin.
    names: Vec<String>,
}

#[async_trait]
impl Tool for Read {
    fn name(&self) -> &str {
        "read"
    }

    fn schema(&self) -> ToolSchema {
        schema_from_clap(
            &ReadArgs::command(),
            "read",
            "Read a line from standard input into variables",
            [
                ("Read into variable", "read NAME"),
                ("Read with prompt", "read -p 'Enter value: ' VAR"),
            ],
        )
    }

    async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
        let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
            return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
        };
        let parsed = match ReadArgs::try_parse_from(
            std::iter::once("read".to_string()).chain(args.to_argv()),
        ) {
            Ok(p) => p,
            Err(e) => return ExecResult::failure(2, format!("read: {e}")),
        };
        parsed.global.apply(ctx);

        let raw_mode = args.has_flag("r") || args.has_flag("raw");

        // Get prompt if provided (-p "prompt")
        let prompt = args
            .get_string("prompt", usize::MAX)
            .or_else(|| args.get_string("p", usize::MAX));

        // Get variable names from positional arguments
        let var_names: Vec<String> = args
            .positional
            .iter()
            .filter_map(|v| match v {
                Value::String(s) => Some(s.clone()),
                _ => None,
            })
            .collect();

        if var_names.is_empty() {
            return ExecResult::failure(1, "read: missing variable name");
        }

        // If prompt is provided, output it to stderr
        // (In a real interactive shell this would display before reading)
        let prompt_output = prompt.as_deref().unwrap_or("");

        // Get input from stdin
        let input = match ctx.read_stdin_to_string().await {
            Some(s) => s,
            None => {
                // No stdin provided - return failure (no data to read)
                // Include the prompt in the error so it's visible
                let mut result = ExecResult::failure(1, "read: no input available");
                if !prompt_output.is_empty() {
                    result.err = format!("{}{}", prompt_output, result.err);
                }
                return result;
            }
        };

        // Process the input
        let line = input.lines().next().unwrap_or("");

        // Process backslash escapes unless raw mode
        let processed = if raw_mode {
            line.to_string()
        } else {
            process_escapes(line)
        };

        if var_names.len() == 1 {
            // Single variable: assign entire line
            ctx.scope.set(&var_names[0], Value::String(processed));
        } else {
            // Multiple variables: split on whitespace
            let words: Vec<&str> = processed.split_whitespace().collect();

            for (i, var) in var_names.iter().enumerate() {
                if i < var_names.len() - 1 {
                    // Assign individual words to all but last variable
                    let word = words.get(i).copied().unwrap_or("");
                    ctx.scope.set(var, Value::String(word.to_string()));
                } else {
                    // Last variable gets the rest of the line
                    let rest = if i < words.len() {
                        words[i..].join(" ")
                    } else {
                        String::new()
                    };
                    ctx.scope.set(var, Value::String(rest));
                }
            }
        }

        // Include prompt in stderr output (for visibility to caller)
        let mut result = ExecResult::success("");
        if !prompt_output.is_empty() {
            result.err = prompt_output.to_string();
        }
        result
    }
}

/// Process backslash escape sequences.
fn process_escapes(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.next() {
                Some('n') => result.push('\n'),
                Some('t') => result.push('\t'),
                Some('r') => result.push('\r'),
                Some('\\') => result.push('\\'),
                Some(other) => {
                    // Unknown escape - keep both characters
                    result.push('\\');
                    result.push(other);
                }
                None => result.push('\\'),
            }
        } else {
            result.push(c);
        }
    }

    result
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::vfs::{MemoryFs, VfsRouter};
    use std::sync::Arc;

    fn make_ctx() -> ExecContext {
        let mut vfs = VfsRouter::new();
        vfs.mount("/", MemoryFs::new());
        ExecContext::new(Arc::new(vfs))
    }

    #[tokio::test]
    async fn test_read_single_var() {
        let mut ctx = make_ctx();
        ctx.set_stdin("hello world".to_string());

        let mut args = ToolArgs::new();
        args.positional.push(Value::String("NAME".to_string()));

        let result = Read.execute(args, &mut ctx).await;
        assert!(result.ok());
        assert_eq!(ctx.scope.get("NAME"), Some(&Value::String("hello world".into())));
    }

    #[tokio::test]
    async fn test_read_multiple_vars() {
        let mut ctx = make_ctx();
        ctx.set_stdin("one two three four".to_string());

        let mut args = ToolArgs::new();
        args.positional.push(Value::String("A".to_string()));
        args.positional.push(Value::String("B".to_string()));
        args.positional.push(Value::String("C".to_string()));

        let result = Read.execute(args, &mut ctx).await;
        assert!(result.ok());
        assert_eq!(ctx.scope.get("A"), Some(&Value::String("one".into())));
        assert_eq!(ctx.scope.get("B"), Some(&Value::String("two".into())));
        // C gets "three four" (the rest)
        assert_eq!(ctx.scope.get("C"), Some(&Value::String("three four".into())));
    }

    #[tokio::test]
    async fn test_read_escape_processing() {
        let mut ctx = make_ctx();
        ctx.set_stdin("hello\\nworld".to_string());

        let mut args = ToolArgs::new();
        args.positional.push(Value::String("NAME".to_string()));

        let result = Read.execute(args, &mut ctx).await;
        assert!(result.ok());
        // Without -r, \n becomes newline
        assert_eq!(ctx.scope.get("NAME"), Some(&Value::String("hello\nworld".into())));
    }

    #[tokio::test]
    async fn test_read_raw_mode() {
        let mut ctx = make_ctx();
        ctx.set_stdin("hello\\nworld".to_string());

        let mut args = ToolArgs::new();
        args.positional.push(Value::String("NAME".to_string()));
        args.flags.insert("r".to_string());

        let result = Read.execute(args, &mut ctx).await;
        assert!(result.ok());
        // With -r, \n stays as literal
        assert_eq!(ctx.scope.get("NAME"), Some(&Value::String("hello\\nworld".into())));
    }

    #[tokio::test]
    async fn test_read_no_input() {
        let mut ctx = make_ctx();
        // No stdin set

        let mut args = ToolArgs::new();
        args.positional.push(Value::String("NAME".to_string()));

        let result = Read.execute(args, &mut ctx).await;
        assert!(!result.ok());
        assert!(result.err.contains("no input"));
    }

    #[tokio::test]
    async fn test_read_no_var_name() {
        let mut ctx = make_ctx();
        ctx.set_stdin("hello".to_string());

        let args = ToolArgs::new();

        let result = Read.execute(args, &mut ctx).await;
        assert!(!result.ok());
        assert!(result.err.contains("missing variable name"));
    }

    #[tokio::test]
    async fn test_read_empty_input() {
        let mut ctx = make_ctx();
        ctx.set_stdin("".to_string());

        let mut args = ToolArgs::new();
        args.positional.push(Value::String("NAME".to_string()));

        let result = Read.execute(args, &mut ctx).await;
        assert!(result.ok());
        assert_eq!(ctx.scope.get("NAME"), Some(&Value::String("".into())));
    }

    #[tokio::test]
    async fn test_read_multiline_takes_first() {
        let mut ctx = make_ctx();
        ctx.set_stdin("first line\nsecond line".to_string());

        let mut args = ToolArgs::new();
        args.positional.push(Value::String("NAME".to_string()));

        let result = Read.execute(args, &mut ctx).await;
        assert!(result.ok());
        // Only first line is read
        assert_eq!(ctx.scope.get("NAME"), Some(&Value::String("first line".into())));
    }

    #[test]
    fn test_process_escapes() {
        assert_eq!(process_escapes("hello"), "hello");
        assert_eq!(process_escapes("hello\\nworld"), "hello\nworld");
        assert_eq!(process_escapes("tab\\there"), "tab\there");
        assert_eq!(process_escapes("back\\\\slash"), "back\\slash");
        assert_eq!(process_escapes("end\\"), "end\\");
        assert_eq!(process_escapes("unknown\\x"), "unknown\\x");
    }
}