slash-lib 0.1.0

Executor types and high-level API for the slash-command language
Documentation
use std::collections::HashMap;

use slash_lang::parser::ast::Command;

use crate::builtins;
use crate::command::SlashCommand;
use crate::env::SlenvLoader;
use crate::executor::{CommandOutput, CommandRunner, ExecutionError, PipeValue};

/// A table of [`SlashCommand`] implementations dispatched by name.
///
/// Implements [`CommandRunner`] so it plugs directly into the existing
/// [`Executor`](crate::executor::Executor) orchestration engine.
pub struct CommandRegistry {
    commands: HashMap<String, Box<dyn SlashCommand>>,
    env: SlenvLoader,
}

impl CommandRegistry {
    /// Create a registry with all builtins and env resolution from `dir/.slenv`.
    pub fn new(env: SlenvLoader) -> Self {
        let mut commands = HashMap::new();
        for cmd in builtins::all() {
            commands.insert(cmd.name().to_string(), cmd);
        }
        Self { commands, env }
    }

    /// Register a custom command.
    pub fn register(&mut self, cmd: Box<dyn SlashCommand>) {
        self.commands.insert(cmd.name().to_string(), cmd);
    }
}

impl CommandRunner for CommandRegistry {
    fn run(
        &self,
        cmd: &Command,
        input: Option<&PipeValue>,
    ) -> Result<CommandOutput, ExecutionError> {
        let handler = self
            .commands
            .get(&cmd.name)
            .ok_or_else(|| ExecutionError::Runner(format!("unknown command: /{}", cmd.name)))?;

        // Resolve $KEY in primary arg.
        let primary = cmd.primary.as_ref().map(|p| self.env.resolve(p));

        // Resolve $KEY in all arg values.
        let resolved_args: Vec<slash_lang::parser::ast::Arg> = cmd
            .args
            .iter()
            .map(|a| slash_lang::parser::ast::Arg {
                name: a.name.clone(),
                value: a.value.as_ref().map(|v| self.env.resolve(v)),
            })
            .collect();

        // Validate method names against the command's declared methods.
        let valid_methods = handler.methods();
        for arg in &resolved_args {
            if !valid_methods.iter().any(|m| m.name == arg.name) {
                let known: Vec<&str> = valid_methods.iter().map(|m| m.name).collect();
                return Err(ExecutionError::Runner(format!(
                    "/{}: unknown method '.{}' — valid methods: {}",
                    cmd.name,
                    arg.name,
                    if known.is_empty() {
                        "(none)".to_string()
                    } else {
                        known.join(", ")
                    },
                )));
            }
        }

        handler.execute(primary.as_deref(), &resolved_args, input)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::executor::{Execute, Executor};
    use slash_lang::parser::parse;

    fn registry() -> CommandRegistry {
        CommandRegistry::new(SlenvLoader::empty())
    }

    #[test]
    fn echo_primary_arg() {
        let reg = registry();
        let ex = Executor::new(reg);
        let prog = parse("/echo(hello world)").unwrap();
        let result = ex.execute(&prog).unwrap();
        match result {
            Some(PipeValue::Bytes(b)) => assert_eq!(String::from_utf8(b).unwrap(), "hello world\n"),
            _ => panic!("expected bytes"),
        }
    }

    #[test]
    fn echo_text_method() {
        let reg = registry();
        let ex = Executor::new(reg);
        let prog = parse("/echo.text(hello)").unwrap();
        let result = ex.execute(&prog).unwrap();
        match result {
            Some(PipeValue::Bytes(b)) => assert_eq!(String::from_utf8(b).unwrap(), "hello\n"),
            _ => panic!("expected bytes"),
        }
    }

    #[test]
    fn read_file() {
        // Use file() to get path relative to workspace root.
        let manifest = env!("CARGO_MANIFEST_DIR");
        let path = format!("{}/Cargo.toml", manifest);
        let reg = registry();
        let ex = Executor::new(reg);
        let prog = parse(&format!("/read({})", path)).unwrap();
        let result = ex.execute(&prog).unwrap();
        match result {
            Some(PipeValue::Bytes(b)) => {
                let s = String::from_utf8(b).unwrap();
                assert!(s.contains("[package]"));
            }
            _ => panic!("expected bytes"),
        }
    }

    #[test]
    fn read_missing_file_fails() {
        let reg = registry();
        let ex = Executor::new(reg);
        let prog = parse("/read(nonexistent_file_xyz.txt)").unwrap();
        assert!(ex.execute(&prog).is_err());
    }

    #[test]
    fn pipe_read_to_write_roundtrip() {
        let tmp = std::env::temp_dir().join("slash_test_write.txt");
        let tmp_path = tmp.display().to_string();
        let reg = registry();
        let ex = Executor::new(reg);

        // Write some content via echo | write
        let prog = parse(&format!("/echo(test content) | /write({})", tmp_path)).unwrap();
        ex.execute(&prog).unwrap();

        let content = std::fs::read_to_string(&tmp).unwrap();
        assert_eq!(content, "test content\n");
        let _ = std::fs::remove_file(&tmp);
    }

    #[test]
    fn exec_runs_command() {
        let reg = registry();
        let ex = Executor::new(reg);
        let prog = parse("/exec(echo hello)").unwrap();
        let result = ex.execute(&prog).unwrap();
        match result {
            Some(PipeValue::Bytes(b)) => assert_eq!(String::from_utf8(b).unwrap().trim(), "hello"),
            _ => panic!("expected bytes"),
        }
    }

    #[test]
    fn exec_failure_propagates() {
        let reg = registry();
        let ex = Executor::new(reg);
        let prog = parse("/exec(false) && /echo(should not run)").unwrap();
        let result = ex.execute(&prog).unwrap();
        // /echo should not have run — result is None or empty
        assert!(result.is_none());
    }

    #[test]
    fn unknown_method_errors() {
        let reg = registry();
        let ex = Executor::new(reg);
        let prog = parse("/echo.nonexistent(val)").unwrap();
        let err = match ex.execute(&prog) {
            Err(e) => e,
            Ok(_) => panic!("expected error for unknown method"),
        };
        let msg = format!("{:?}", err);
        assert!(msg.contains("unknown method '.nonexistent'"), "got: {msg}");
        assert!(
            msg.contains("text"),
            "should list valid methods, got: {msg}"
        );
    }

    #[test]
    fn unknown_command_errors() {
        let reg = registry();
        let ex = Executor::new(reg);
        let prog = parse("/nonexistent").unwrap();
        assert!(ex.execute(&prog).is_err());
    }

    #[test]
    fn env_resolution_in_args() {
        let mut env = SlenvLoader::empty();
        env.insert_mut("MSG", "resolved");
        let reg = CommandRegistry::new(env);
        let ex = Executor::new(reg);
        let prog = parse("/echo($MSG)").unwrap();
        let result = ex.execute(&prog).unwrap();
        match result {
            Some(PipeValue::Bytes(b)) => assert_eq!(String::from_utf8(b).unwrap(), "resolved\n"),
            _ => panic!("expected bytes"),
        }
    }

    #[test]
    fn find_glob() {
        // Use a relative path from the crate directory.
        let reg = registry();
        let ex = Executor::new(reg);
        let prog = parse("/find(src/*.rs)").unwrap();

        // Run from the crate's own directory.
        let manifest = env!("CARGO_MANIFEST_DIR");
        std::env::set_current_dir(manifest).unwrap();

        let result = ex.execute(&prog).unwrap();
        match result {
            Some(PipeValue::Bytes(b)) => {
                let s = String::from_utf8(b).unwrap();
                assert!(s.contains("lib.rs"));
            }
            _ => panic!("expected bytes"),
        }
    }
}