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