Skip to main content

ninja/scripting/
dsl.rs

1use crate::scripting::NinjaEngine;
2use crate::{common::types::FieldValue, manager::ShurikenManager};
3use anyhow::{Error, Result, bail};
4use either::Either;
5use log::debug;
6use shlex::split;
7use std::env;
8use std::sync::Arc;
9use std::{io, path::PathBuf, process::Stdio};
10use tokio::process::Command as SubprocessCommand;
11use tokio::sync::RwLock;
12
13/// Commands that can be executed within the Ninja DSL.
14///
15/// These commands are parsed from DSL scripts and executed to manage Shurikens.
16#[derive(Debug, Clone)]
17pub enum Command {
18    /// Start an HTTP API server on the specified port
19    HttpStart(u16),
20    /// Start the currently selected Shuriken
21    Start,
22    /// Stop the currently selected Shuriken
23    Stop,
24    /// Select a Shuriken by name for subsequent operations
25    Select(String),
26    /// Get a configuration value by key
27    Get(String),
28    /// Deselect the current Shuriken
29    Exit,
30    /// Configure the currently selected Shuriken
31    Configure,
32    /// Configure the currently selected Shuriken with specific key-value pairs
33    ConfigureBlock(Vec<(String, FieldValue)>),
34    /// Set a configuration key to a value
35    Set { key: String, value: FieldValue },
36    /// List all available Shurikens
37    List,
38    /// List all Shurikens with their current states
39    ListState,
40    /// Install a new Shuriken from a URL, registry entry, or file path
41    Install(String),
42    /// Toggle a boolean configuration value
43    Toggle(String),
44    /// Execute a Ninja script file
45    Execute(PathBuf),
46    /// Display help information
47    Help,
48    /// No-op command
49    None,
50}
51
52// -----------------
53// Parsing helpers
54// -----------------
55
56/// Removes single-line comments from a line of code.
57///
58/// Supports both `//` and `#` comment delimiters.
59/// Returns the text up to the first comment marker.
60fn strip_comments(line: &str) -> &str {
61    if let Some(i) = line.find("//") {
62        &line[..i]
63    } else if let Some(i) = line.find('#') {
64        &line[..i]
65    } else {
66        line
67    }
68}
69
70/// Parses a raw string into a typed `FieldValue`.
71///
72/// Supports quoted strings, booleans (`true`/`false`), and integers.
73/// Falls back to a string if no specific type matches.
74fn parse_value(raw: &str) -> FieldValue {
75    let v = raw.trim();
76
77    // quoted string support
78    if (v.starts_with('"') && v.ends_with('"')) || (v.starts_with('\'') && v.ends_with('\'')) {
79        let inner = &v[1..v.len() - 1];
80        return FieldValue::String(inner.to_string());
81    }
82
83    // boolean
84    match v.to_ascii_lowercase().as_str() {
85        "true" => return FieldValue::Bool(true),
86        "false" => return FieldValue::Bool(false),
87        _ => {}
88    }
89
90    // integer (i64)
91    if let Ok(i) = v.parse::<i64>() {
92        return FieldValue::Number(i);
93    }
94
95    // fallback: raw string
96    FieldValue::String(v.to_string())
97}
98
99/// Parses a single key-value assignment (e.g., `key = value`).
100///
101/// # Returns
102/// - `Ok(Some((key, value)))` if a valid assignment is found
103/// - `Ok(None)` if the line is empty or not an assignment
104/// - `Err` if the assignment is malformed (e.g., empty key)
105fn parse_kv(text: &str) -> Result<Option<(String, FieldValue)>> {
106    let t = text.trim();
107    if t.is_empty() {
108        return Ok(None);
109    }
110
111    if let Some((left, right)) = t.split_once('=') {
112        let key = left.trim();
113        let val = right.trim();
114
115        if key.is_empty() {
116            bail!("Invalid assignment (empty key): `{}`", text);
117        }
118
119        // support trailing semicolon being present on the right side
120        let val = val.trim_end_matches(';').trim();
121        Ok(Some((key.to_string(), parse_value(val))))
122    } else {
123        // not an assignment (maybe a standalone token) — ignore gracefully
124        Ok(None)
125    }
126}
127
128/// Collects the content of a block delimited by `{` and `}`.
129///
130/// Supports both inline blocks (`{ content }`) and multiline blocks.
131/// Skips comments while collecting lines.
132///
133/// # Arguments
134/// - `first_after_brace`: Content on the same line after the opening `{`
135/// - `lines`: Iterator over remaining lines
136///
137/// # Returns
138/// - `Ok(content)` if the block was successfully collected
139/// - `Err` if no closing `}` is found
140fn collect_block<'a, I>(first_after_brace: &'a str, lines: &mut I) -> Result<String>
141where
142    I: Iterator<Item = &'a str>,
143{
144    // If the `first_after_brace` already contains the closing brace on same line
145    let trimmed_after = first_after_brace.trim();
146
147    if let Some(inner) = trimmed_after.strip_suffix("}") {
148        // slice out trailing `}` and return
149        return Ok(inner.trim().to_string());
150    }
151
152    // Start with what's after the brace on the first line
153    let mut collected = String::new();
154    if !trimmed_after.is_empty() {
155        collected.push_str(trimmed_after);
156        collected.push('\n');
157    }
158
159    // gather until we find a line containing a `}` (allow trailing whitespace)
160    for next in lines {
161        let stripped = strip_comments(next).trim();
162        if let Some(inner) = stripped.strip_suffix('}') {
163            if !inner.trim().is_empty() {
164                collected.push_str(inner.trim());
165            }
166            return Ok(collected.trim().to_string());
167        } else if !stripped.is_empty() {
168            collected.push_str(stripped);
169            collected.push('\n');
170        }
171    }
172
173    // If we get here: no closing brace found
174    bail!("Missing closing '}}' for block");
175}
176
177/// Parses a DSL script into a sequence of `Command` objects.
178///
179/// Handles comments, block syntax for configure, and token-based command parsing.
180/// Supports both legacy and rich configure block syntax.
181///
182/// # Returns
183/// - `Ok(commands)` with the parsed command sequence
184/// - `Err` if parsing fails
185fn command_parser(script: &str) -> Result<Vec<Command>> {
186    let mut commands = Vec::new();
187
188    // iterate over raw lines but keep ownership as &str slices from script.lines()
189    let mut lines = script.lines();
190
191    // We need an iterator that allows peeking; we'll manually consume lines as needed
192    while let Some(raw_line) = lines.next() {
193        // remove comments first
194        let no_comment = strip_comments(raw_line);
195        let trimmed = no_comment.trim();
196        if trimmed.is_empty() {
197            continue;
198        }
199
200        // ---------- CONFIGURE BLOCK detection ----------
201        // handle: configure { ... }  (inline or multiline)
202        if trimmed.starts_with("configure") {
203            // after the word `configure`, find '{' if present
204            if let Some((_, after_brace)) = trimmed.split_once('{') {
205                // collect inner content (inline or multiline)
206                let block_content = collect_block(after_brace, &mut lines)?;
207                // split by semicolons or newlines and parse assignments
208                let mut kvs: Vec<(String, FieldValue)> = Vec::new();
209
210                for chunk in block_content.split([';', '\n']) {
211                    if let Some((k, v)) = parse_kv(chunk)? {
212                        kvs.push((k, v));
213                    }
214                }
215
216                commands.push(Command::ConfigureBlock(kvs));
217                continue;
218            } else {
219                // no brace on this line — treat as `configure` (legacy)
220                commands.push(Command::Configure);
221                continue;
222            }
223        }
224
225        // ---------- FALLBACK: use shlex tokenization like before ----------
226        // allow tokens with quotes and splitting similar to previous implementation
227        if let Some(tokens) = split(trimmed) {
228            let tokens: Vec<String> = tokens
229                .into_iter()
230                .filter(|token| {
231                    let token = token.trim();
232                    !token.is_empty() && token != "="
233                })
234                .collect();
235
236            if tokens.is_empty() {
237                continue;
238            }
239
240            let cmd = match tokens[0].as_str() {
241                "http" => Command::HttpStart(tokens[1].parse().unwrap_or(80)),
242                "start" => Command::Start,
243                "stop" => Command::Stop,
244                "select" => {
245                    if tokens.len() > 1 {
246                        Command::Select(tokens[1].clone())
247                    } else {
248                        Command::None
249                    }
250                }
251                "help" => Command::Help,
252                "get" => {
253                    if tokens.len() > 1 {
254                        Command::Get(tokens[1].clone())
255                    } else {
256                        Command::None
257                    }
258                }
259                "set" => {
260                    if tokens.len() > 2 {
261                        Command::Set {
262                            key: tokens[1].clone(),
263                            value: parse_value(tokens[2].as_str()),
264                        }
265                    } else {
266                        Command::None
267                    }
268                }
269                "list" => {
270                    if tokens.len() > 1 && tokens[1].eq_ignore_ascii_case("state") {
271                        Command::ListState
272                    } else {
273                        Command::List
274                    }
275                }
276                "install" => {
277                    if tokens.len() > 1 {
278                        Command::Install(tokens[1].clone())
279                    } else {
280                        Command::None
281                    }
282                }
283                "toggle" => {
284                    if tokens.len() > 1 {
285                        Command::Toggle(tokens[1].clone())
286                    } else {
287                        Command::None
288                    }
289                }
290                "execute" => {
291                    if tokens.len() > 1 {
292                        Command::Execute(PathBuf::from(tokens[1].clone()))
293                    } else {
294                        Command::None
295                    }
296                }
297                "exit" => Command::Exit,
298                "configure" => Command::Configure, // fallback if no {}
299                _ => Command::None,
300            };
301
302            commands.push(cmd);
303        }
304    }
305
306    Ok(commands)
307}
308
309// ==================
310// Helper Function
311// ==================
312
313/// Locates the ninja CLI executable (`shurikenctl` or `shurikenctl.exe`).
314///
315/// Searches in the same directory as the current executable.
316///
317/// # Returns
318/// - `Ok(path)` if the CLI executable is found
319/// - `Err` if the executable cannot be found or the current executable location is unknown
320fn locate_ninja_cli() -> Result<PathBuf> {
321    let exe_path = env::current_exe()?;
322    if let Some(root) = exe_path.parent() {
323        let cli_path = if cfg!(windows) {
324            root.join("shurikenctl.exe")
325        } else {
326            root.join("shurikenctl")
327        };
328
329        if !cli_path.exists() {
330            return Err(Error::msg("No ninja CLI found"));
331        }
332
333        Ok(cli_path)
334    } else {
335        Err(Error::msg(
336            "No parent directory? where and how did you run this? please email me i'm genuinely curious. -- Hannan \"tunafysh\" Smani",
337        ))
338    }
339}
340
341/// Context for executing Ninja DSL commands.
342///
343/// Maintains the manager reference and currently selected Shuriken.
344pub struct DslContext {
345    /// Reference to the Shuriken manager
346    pub manager: ShurikenManager,
347    /// Currently selected Shuriken name (if any)
348    pub selected: Arc<RwLock<Option<String>>>,
349}
350
351impl DslContext {
352    /// Creates a new DSL context with the given manager.
353    ///
354    /// # Arguments
355    /// - `manager`: The Shuriken manager
356    ///
357    /// # Returns
358    /// A new `DslContext` with no Shuriken selected
359    pub fn new(manager: ShurikenManager) -> Self {
360        Self {
361            manager,
362            selected: Arc::new(RwLock::new(None)),
363        }
364    }
365}
366
367/// Parses and executes a series of DSL commands.
368///
369/// Processes the DSL script, executing each command in sequence
370/// and collecting output messages.
371///
372/// # Arguments
373/// - `ctx`: DSL execution context with manager and selected Shuriken
374/// - `script`: The DSL script string to execute
375///
376/// # Returns
377/// - `Ok(output)` with a vector of output messages from command execution
378/// - `Err` if parsing or execution fails
379pub async fn execute_commands(ctx: &DslContext, script: String) -> Result<Vec<String>> {
380    let parsed_commands = command_parser(script.as_str())?;
381
382    let mut output: Vec<String> = Vec::new();
383
384    for command in parsed_commands {
385        match command {
386            // HTTP server
387            Command::HttpStart(port) => {
388                let path = locate_ninja_cli()?;
389                SubprocessCommand::new(path)
390                    .arg("api")
391                    .arg(port.to_string())
392                    .stdout(Stdio::inherit())
393                    .stderr(Stdio::inherit())
394                    .stdin(Stdio::inherit())
395                    .status()
396                    .await?;
397                output.push(format!("HTTP server started on port {}", port));
398            }
399
400            // Select shuriken
401            Command::Select(name) => {
402                debug!("Shurikens: {:#?}", ctx.manager.shurikens.read().await);
403                if ctx.manager.shurikens.read().await.contains_key(&name) {
404                    *ctx.selected.write().await = Some(name.clone());
405                    output.push(format!("Selected shuriken '{}'", name));
406                } else {
407                    output.push(format!("No such shuriken: {}", name));
408                }
409            }
410
411            // simple legacy configure
412            Command::Configure => {
413                if let Some(name) = &*ctx.selected.read().await {
414                    let mut shurikens = ctx.manager.shurikens.write().await;
415                    if let Some(shuriken) = shurikens.get_mut(name) {
416                        let path = &ctx.manager.root_path;
417                        shuriken
418                            .configure(
419                                path,
420                                &*ctx.manager.engine.lock().await,
421                                Some(ctx.manager.clone()),
422                            )
423                            .await
424                            .map_err(Error::msg)?;
425
426                        output.push(format!(
427                            "Generated configuration for shuriken {} successfully.",
428                            &name
429                        ));
430                    }
431                }
432            }
433
434            // New: configure block
435            Command::ConfigureBlock(kvs) => {
436                if let Some(shuriken_name) = &*ctx.selected.read().await {
437                    let mut shurikens = ctx.manager.shurikens.write().await;
438                    if let Some(shuriken) = shurikens.get_mut(shuriken_name)
439                        && let Some(cfg) = &mut shuriken.config
440                    {
441                        let partial_options = cfg.options.get_or_insert_with(Default::default);
442                        for (k, v) in kvs {
443                            partial_options.insert(k.clone(), v.clone());
444                            output.push(format!(
445                                "Set {} = {} for {}",
446                                k,
447                                v.render(),
448                                shuriken_name
449                            ));
450                        }
451                    } else {
452                        output.push("No selected shuriken or missing config while applying configure block.".to_string());
453                    }
454                } else {
455                    output.push("No shuriken selected — configure block ignored.".into());
456                }
457            }
458
459            Command::Help => {
460                output.push(
461                    "Available commands:
462                  http start <port>        - Start the HTTP server
463                  select <name>            - Select a shuriken
464                  configure                - Generate configuration for the selected shuriken
465                  configure { k = v }      - Apply config assignments to the selected shuriken
466                  set <key> <value>        - Set a config key for the selected shuriken
467                  get <key>                - Get a config key's value
468                  toggle <key>             - Toggle a boolean config key
469                  start                    - Start the selected shuriken
470                  stop                     - Stop the selected shuriken
471                  install <url | registry_entry | path>           - Install a new shuriken from a file, url or a registry entry
472                  list                     - List all shurikens
473                  list state               - List shurikens with their states
474                  execute <script>         - Run a Ninja script file
475                  exit                     - Deselect current shuriken
476                  help                     - Show this message"
477                        .to_string(),
478                );
479            }
480
481            // Config commands
482            Command::Set { key, value } => {
483                if let Some(shuriken_name) = &*ctx.selected.read().await {
484                    let mut shurikens = ctx.manager.shurikens.write().await;
485                    if let Some(shuriken) = shurikens.get_mut(shuriken_name)
486                        && let Some(cfg) = &mut shuriken.config
487                    {
488                        let cloned_value: FieldValue = value.clone();
489                        if let Some(partial_options) = &mut cfg.options {
490                            partial_options.insert(key.clone(), FieldValue::from(value.render()));
491                        }
492
493                        output.push(format!(
494                            "Set {} = {} for {}",
495                            key,
496                            cloned_value.render(),
497                            shuriken_name
498                        ));
499                    }
500                }
501            }
502
503            Command::Get(key) => {
504                if let Some(shuriken_name) = &*ctx.selected.read().await {
505                    let shurikens = ctx.manager.shurikens.read().await;
506                    if let Some(shuriken) = shurikens.get(shuriken_name)
507                        && let Some(cfg) = &shuriken.config
508                        && let Some(options) = &cfg.options
509                    {
510                        output.push(format!("{:?} = {:?}", key, options.get(&key)));
511                    }
512                }
513            }
514
515            Command::Toggle(key) => {
516                if let Some(shuriken_name) = &*ctx.selected.read().await {
517                    let mut shurikens = ctx.manager.shurikens.write().await;
518                    if let Some(shuriken) = shurikens.get_mut(shuriken_name)
519                        && let Some(cfg) = &mut shuriken.config
520                        && let Some(options) = &mut cfg.options
521                        && let Some(FieldValue::Bool(value)) = options.get_mut(&key)
522                    {
523                        *value = !*value;
524                        output.push(format!("Toggled {} to {}", key, value));
525                    }
526                }
527            }
528
529            // Shuriken management
530            Command::List => {
531                if let Either::Right(names) = ctx.manager.list(false).await? {
532                    output.push(format!("Shurikens: {:?}", names))
533                }
534            }
535            Command::ListState => {
536                if let Either::Left(states) = ctx.manager.list(true).await? {
537                    for (name, state) in states {
538                        output.push(format!("{} -> {:?}", name, state));
539                    }
540                }
541            }
542
543            Command::Start => {
544                if let Some(name) = &*ctx.selected.read().await {
545                    match ctx.manager.start(name).await {
546                        Ok(_) => output.push(format!("Started {}", name)),
547                        Err(e) => output.push(format!("Error: {}", e)),
548                    }
549                }
550            }
551            Command::Stop => {
552                if let Some(name) = &*ctx.selected.read().await {
553                    match ctx.manager.stop(name).await {
554                        Ok(_) => output.push(format!("Stopped {}", name)),
555                        Err(e) => output.push(format!("Error: {}", e)),
556                    }
557                }
558            }
559
560            Command::Execute(script_path) => {
561                let engine = NinjaEngine::new()
562                    .await
563                    .map_err(|e| io::Error::other(e.to_string()))?;
564                engine
565                    .execute_file(&script_path, None, Some(ctx.manager.clone()))
566                    .await
567                    .map_err(|e| io::Error::other(e.to_string()))?;
568            }
569            Command::Install(file_path) => match ctx.manager.install(&file_path).await {
570                Ok(_) => output.push("Installed successfully".into()),
571                Err(e) => output.push(format!("Install failed: {}", e)),
572            },
573
574            // Exit the shuriken
575            Command::Exit => {
576                if ctx.selected.write().await.is_some() {
577                    *ctx.selected.write().await = None;
578                    output.push("Discarded current shuriken".into());
579                } else {
580                    output.push("Cannot exit when there's no shuriken to discard.".into());
581                }
582            }
583
584            // Unsupported
585            Command::None => {
586                output.push("Invalid or unsupported command.".to_string());
587            }
588        }
589    }
590
591    Ok(output)
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn test_strip_comments() {
600        // Test with // comment
601        assert_eq!(strip_comments("code // comment"), "code ");
602
603        // Test with # comment
604        assert_eq!(strip_comments("code # comment"), "code ");
605
606        // Test with no comment
607        assert_eq!(strip_comments("code"), "code");
608
609        // Test with empty line
610        assert_eq!(strip_comments(""), "");
611
612        // Test with only comment
613        assert_eq!(strip_comments("// comment"), "");
614        assert_eq!(strip_comments("# comment"), "");
615    }
616
617    #[test]
618    fn test_parse_value() {
619        // Test string with double quotes
620        let val = parse_value("\"hello\"");
621        match val {
622            FieldValue::String(s) => assert_eq!(s, "hello"),
623            _ => panic!("Expected String"),
624        }
625
626        // Test string with single quotes
627        let val = parse_value("'world'");
628        match val {
629            FieldValue::String(s) => assert_eq!(s, "world"),
630            _ => panic!("Expected String"),
631        }
632
633        // Test boolean true
634        let val = parse_value("true");
635        match val {
636            FieldValue::Bool(b) => assert!(b),
637            _ => panic!("Expected Bool"),
638        }
639
640        // Test boolean false
641        let val = parse_value("false");
642        match val {
643            FieldValue::Bool(b) => assert!(!b),
644            _ => panic!("Expected Bool"),
645        }
646
647        // Test integer
648        let val = parse_value("42");
649        match val {
650            FieldValue::Number(n) => assert_eq!(n, 42),
651            _ => panic!("Expected Number"),
652        }
653
654        // Test fallback to string
655        let val = parse_value("unquoted");
656        match val {
657            FieldValue::String(s) => assert_eq!(s, "unquoted"),
658            _ => panic!("Expected String"),
659        }
660    }
661
662    #[test]
663    fn test_parse_kv() {
664        // Test valid key-value pair
665        let result = parse_kv("key = value").unwrap();
666        assert!(result.is_some());
667        let (k, v) = result.unwrap();
668        assert_eq!(k, "key");
669        match v {
670            FieldValue::String(s) => assert_eq!(s, "value"),
671            _ => panic!("Expected String"),
672        }
673
674        // Test with trailing semicolon
675        let result = parse_kv("key = value;").unwrap();
676        assert!(result.is_some());
677        let (k, _) = result.unwrap();
678        assert_eq!(k, "key");
679
680        // Test empty line
681        let result = parse_kv("").unwrap();
682        assert!(result.is_none());
683
684        // Test empty key (should fail)
685        let result = parse_kv("= value");
686        assert!(result.is_err());
687
688        // Test no equals sign (should return None)
689        let result = parse_kv("standalone").unwrap();
690        assert!(result.is_none());
691    }
692
693    #[test]
694    fn test_collect_block_inline() {
695        let lines_vec: Vec<&str> = vec![];
696        let mut lines = lines_vec.iter().copied();
697
698        // Test inline block with closing brace on same line
699        let result = collect_block("key = value }", &mut lines);
700        assert!(result.is_ok());
701        assert_eq!(result.unwrap(), "key = value");
702    }
703
704    #[test]
705    fn test_collect_block_multiline() {
706        let lines_vec = vec!["line2", "line3 }"];
707        let mut lines = lines_vec.iter().copied();
708
709        // Test multiline block
710        let result = collect_block("line1", &mut lines);
711        assert!(result.is_ok());
712        let content = result.unwrap();
713        assert!(content.contains("line1"));
714        assert!(content.contains("line2"));
715        assert!(content.contains("line3"));
716    }
717
718    #[test]
719    fn test_collect_block_missing_closing() {
720        let lines_vec = vec!["line2", "line3"];
721        let mut lines = lines_vec.iter().copied();
722
723        // Test missing closing brace
724        let result = collect_block("line1", &mut lines);
725        assert!(result.is_err());
726    }
727
728    #[test]
729    fn test_command_parser_simple() {
730        // Test simple commands
731        let script = "start\nstop\nlist";
732        let result = command_parser(script).unwrap();
733        assert_eq!(result.len(), 3);
734    }
735
736    #[test]
737    fn test_command_parser_with_comments() {
738        // Test commands with comments
739        let script = "start // start the service\nstop # stop it";
740        let result = command_parser(script).unwrap();
741        assert_eq!(result.len(), 2);
742    }
743
744    #[test]
745    fn test_command_parser_configure_block() {
746        // Test configure block parsing
747        let script = "configure { key1 = value1; key2 = 42 }";
748        let result = command_parser(script).unwrap();
749        assert_eq!(result.len(), 1);
750        match &result[0] {
751            Command::ConfigureBlock(kvs) => {
752                assert_eq!(kvs.len(), 2);
753                assert_eq!(kvs[0].0, "key1");
754            }
755            _ => panic!("Expected ConfigureBlock"),
756        }
757    }
758
759    #[test]
760    fn test_command_parser_empty_lines() {
761        // Test handling of empty lines
762        let script = "\n\nstart\n\n\nstop\n\n";
763        let result = command_parser(script).unwrap();
764        assert_eq!(result.len(), 2);
765    }
766
767    #[test]
768    fn test_command_parser_select() {
769        // Test select command
770        let script = "select myservice";
771        let result = command_parser(script).unwrap();
772        assert_eq!(result.len(), 1);
773        match &result[0] {
774            Command::Select(name) => assert_eq!(name, "myservice"),
775            _ => panic!("Expected Select"),
776        }
777    }
778
779    #[test]
780    fn test_command_parser_set() {
781        // Test set command
782        let script = "set key value";
783        let result = command_parser(script).unwrap();
784        assert_eq!(result.len(), 1);
785        match &result[0] {
786            Command::Set { key, .. } => assert_eq!(key, "key"),
787            _ => panic!("Expected Set"),
788        }
789    }
790}