Skip to main content

slash_lib/
registry.rs

1use std::collections::HashMap;
2
3use slash_lang::parser::ast::Command;
4
5use crate::builtins;
6use crate::command::SlashCommand;
7use crate::env::SlenvLoader;
8use crate::executor::{CommandOutput, CommandRunner, ExecutionError, PipeValue};
9
10/// A table of [`SlashCommand`] implementations dispatched by name.
11///
12/// Implements [`CommandRunner`] so it plugs directly into the existing
13/// [`Executor`](crate::executor::Executor) orchestration engine.
14pub struct CommandRegistry {
15    commands: HashMap<String, Box<dyn SlashCommand>>,
16    env: SlenvLoader,
17}
18
19impl CommandRegistry {
20    /// Create a registry with all builtins and env resolution from `dir/.slenv`.
21    pub fn new(env: SlenvLoader) -> Self {
22        let mut commands = HashMap::new();
23        for cmd in builtins::all() {
24            commands.insert(cmd.name().to_string(), cmd);
25        }
26        Self { commands, env }
27    }
28
29    /// Register a custom command.
30    pub fn register(&mut self, cmd: Box<dyn SlashCommand>) {
31        self.commands.insert(cmd.name().to_string(), cmd);
32    }
33}
34
35impl CommandRunner for CommandRegistry {
36    fn run(
37        &self,
38        cmd: &Command,
39        input: Option<&PipeValue>,
40    ) -> Result<CommandOutput, ExecutionError> {
41        let handler = self
42            .commands
43            .get(&cmd.name)
44            .ok_or_else(|| ExecutionError::Runner(format!("unknown command: /{}", cmd.name)))?;
45
46        // Resolve $KEY in primary arg.
47        let primary = cmd.primary.as_ref().map(|p| self.env.resolve(p));
48
49        // Resolve $KEY in all arg values.
50        let resolved_args: Vec<slash_lang::parser::ast::Arg> = cmd
51            .args
52            .iter()
53            .map(|a| slash_lang::parser::ast::Arg {
54                name: a.name.clone(),
55                value: a.value.as_ref().map(|v| self.env.resolve(v)),
56            })
57            .collect();
58
59        // Validate method names against the command's declared methods.
60        let valid_methods = handler.methods();
61        for arg in &resolved_args {
62            if !valid_methods.iter().any(|m| m.name == arg.name) {
63                let known: Vec<&str> = valid_methods.iter().map(|m| m.name).collect();
64                return Err(ExecutionError::Runner(format!(
65                    "/{}: unknown method '.{}' — valid methods: {}",
66                    cmd.name,
67                    arg.name,
68                    if known.is_empty() {
69                        "(none)".to_string()
70                    } else {
71                        known.join(", ")
72                    },
73                )));
74            }
75        }
76
77        handler.execute(primary.as_deref(), &resolved_args, input)
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::executor::{Execute, Executor};
85    use slash_lang::parser::parse;
86
87    fn registry() -> CommandRegistry {
88        CommandRegistry::new(SlenvLoader::empty())
89    }
90
91    #[test]
92    fn echo_primary_arg() {
93        let reg = registry();
94        let ex = Executor::new(reg);
95        let prog = parse("/echo(hello world)").unwrap();
96        let result = ex.execute(&prog).unwrap();
97        match result {
98            Some(PipeValue::Bytes(b)) => assert_eq!(String::from_utf8(b).unwrap(), "hello world\n"),
99            _ => panic!("expected bytes"),
100        }
101    }
102
103    #[test]
104    fn echo_text_method() {
105        let reg = registry();
106        let ex = Executor::new(reg);
107        let prog = parse("/echo.text(hello)").unwrap();
108        let result = ex.execute(&prog).unwrap();
109        match result {
110            Some(PipeValue::Bytes(b)) => assert_eq!(String::from_utf8(b).unwrap(), "hello\n"),
111            _ => panic!("expected bytes"),
112        }
113    }
114
115    #[test]
116    fn read_file() {
117        // Use file() to get path relative to workspace root.
118        let manifest = env!("CARGO_MANIFEST_DIR");
119        let path = format!("{}/Cargo.toml", manifest);
120        let reg = registry();
121        let ex = Executor::new(reg);
122        let prog = parse(&format!("/read({})", path)).unwrap();
123        let result = ex.execute(&prog).unwrap();
124        match result {
125            Some(PipeValue::Bytes(b)) => {
126                let s = String::from_utf8(b).unwrap();
127                assert!(s.contains("[package]"));
128            }
129            _ => panic!("expected bytes"),
130        }
131    }
132
133    #[test]
134    fn read_missing_file_fails() {
135        let reg = registry();
136        let ex = Executor::new(reg);
137        let prog = parse("/read(nonexistent_file_xyz.txt)").unwrap();
138        assert!(ex.execute(&prog).is_err());
139    }
140
141    #[test]
142    fn pipe_read_to_write_roundtrip() {
143        let tmp = std::env::temp_dir().join("slash_test_write.txt");
144        let tmp_path = tmp.display().to_string();
145        let reg = registry();
146        let ex = Executor::new(reg);
147
148        // Write some content via echo | write
149        let prog = parse(&format!("/echo(test content) | /write({})", tmp_path)).unwrap();
150        ex.execute(&prog).unwrap();
151
152        let content = std::fs::read_to_string(&tmp).unwrap();
153        assert_eq!(content, "test content\n");
154        let _ = std::fs::remove_file(&tmp);
155    }
156
157    #[test]
158    fn exec_runs_command() {
159        let reg = registry();
160        let ex = Executor::new(reg);
161        let prog = parse("/exec(echo hello)").unwrap();
162        let result = ex.execute(&prog).unwrap();
163        match result {
164            Some(PipeValue::Bytes(b)) => assert_eq!(String::from_utf8(b).unwrap().trim(), "hello"),
165            _ => panic!("expected bytes"),
166        }
167    }
168
169    #[test]
170    fn exec_failure_propagates() {
171        let reg = registry();
172        let ex = Executor::new(reg);
173        let prog = parse("/exec(false) && /echo(should not run)").unwrap();
174        let result = ex.execute(&prog).unwrap();
175        // /echo should not have run — result is None or empty
176        assert!(result.is_none());
177    }
178
179    #[test]
180    fn unknown_method_errors() {
181        let reg = registry();
182        let ex = Executor::new(reg);
183        let prog = parse("/echo.nonexistent(val)").unwrap();
184        let err = match ex.execute(&prog) {
185            Err(e) => e,
186            Ok(_) => panic!("expected error for unknown method"),
187        };
188        let msg = format!("{:?}", err);
189        assert!(msg.contains("unknown method '.nonexistent'"), "got: {msg}");
190        assert!(
191            msg.contains("text"),
192            "should list valid methods, got: {msg}"
193        );
194    }
195
196    #[test]
197    fn unknown_command_errors() {
198        let reg = registry();
199        let ex = Executor::new(reg);
200        let prog = parse("/nonexistent").unwrap();
201        assert!(ex.execute(&prog).is_err());
202    }
203
204    #[test]
205    fn env_resolution_in_args() {
206        let mut env = SlenvLoader::empty();
207        env.insert_mut("MSG", "resolved");
208        let reg = CommandRegistry::new(env);
209        let ex = Executor::new(reg);
210        let prog = parse("/echo($MSG)").unwrap();
211        let result = ex.execute(&prog).unwrap();
212        match result {
213            Some(PipeValue::Bytes(b)) => assert_eq!(String::from_utf8(b).unwrap(), "resolved\n"),
214            _ => panic!("expected bytes"),
215        }
216    }
217
218    #[test]
219    fn find_glob() {
220        // Use a relative path from the crate directory.
221        let reg = registry();
222        let ex = Executor::new(reg);
223        let prog = parse("/find(src/*.rs)").unwrap();
224
225        // Run from the crate's own directory.
226        let manifest = env!("CARGO_MANIFEST_DIR");
227        std::env::set_current_dir(manifest).unwrap();
228
229        let result = ex.execute(&prog).unwrap();
230        match result {
231            Some(PipeValue::Bytes(b)) => {
232                let s = String::from_utf8(b).unwrap();
233                assert!(s.contains("lib.rs"));
234            }
235            _ => panic!("expected bytes"),
236        }
237    }
238}