use crate::scripting::NinjaEngine;
use crate::{common::types::FieldValue, manager::ShurikenManager};
use anyhow::{Error, Result, bail};
use either::Either;
use log::debug;
use shlex::split;
use std::env;
use std::sync::Arc;
use std::{io, path::PathBuf, process::Stdio};
use tokio::process::Command as SubprocessCommand;
use tokio::sync::RwLock;
#[derive(Debug, Clone)]
pub enum Command {
HttpStart(u16),
Start,
Stop,
Select(String),
Get(String),
Exit,
Configure,
ConfigureBlock(Vec<(String, FieldValue)>),
Set { key: String, value: FieldValue },
List,
ListState,
Install(PathBuf),
Toggle(String),
Execute(PathBuf),
Help,
None,
}
fn strip_comments(line: &str) -> &str {
if let Some(i) = line.find("//") {
&line[..i]
} else if let Some(i) = line.find('#') {
&line[..i]
} else {
line
}
}
fn parse_value(raw: &str) -> FieldValue {
let v = raw.trim();
if (v.starts_with('"') && v.ends_with('"')) || (v.starts_with('\'') && v.ends_with('\'')) {
let inner = &v[1..v.len() - 1];
return FieldValue::String(inner.to_string());
}
match v.to_ascii_lowercase().as_str() {
"true" => return FieldValue::Bool(true),
"false" => return FieldValue::Bool(false),
_ => {}
}
if let Ok(i) = v.parse::<i64>() {
return FieldValue::Number(i);
}
FieldValue::String(v.to_string())
}
fn parse_kv(text: &str) -> Result<Option<(String, FieldValue)>> {
let t = text.trim();
if t.is_empty() {
return Ok(None);
}
if let Some((left, right)) = t.split_once('=') {
let key = left.trim();
let val = right.trim();
if key.is_empty() {
bail!("Invalid assignment (empty key): `{}`", text);
}
let val = val.trim_end_matches(';').trim();
Ok(Some((key.to_string(), parse_value(val))))
} else {
Ok(None)
}
}
fn collect_block<'a, I>(first_after_brace: &'a str, lines: &mut I) -> Result<String>
where
I: Iterator<Item = &'a str>,
{
let trimmed_after = first_after_brace.trim();
if let Some(inner) = trimmed_after.strip_suffix("}") {
return Ok(inner.trim().to_string());
}
let mut collected = String::new();
if !trimmed_after.is_empty() {
collected.push_str(trimmed_after);
collected.push('\n');
}
for next in lines {
let stripped = strip_comments(next).trim();
if let Some(inner) = stripped.strip_suffix('}') {
if !inner.trim().is_empty() {
collected.push_str(inner.trim());
}
return Ok(collected.trim().to_string());
} else if !stripped.is_empty() {
collected.push_str(stripped);
collected.push('\n');
}
}
bail!("Missing closing '}}' for block");
}
fn command_parser(script: &str) -> Result<Vec<Command>> {
let mut commands = Vec::new();
let mut lines = script.lines();
while let Some(raw_line) = lines.next() {
let no_comment = strip_comments(raw_line);
let trimmed = no_comment.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("configure") {
if let Some((_, after_brace)) = trimmed.split_once('{') {
let block_content = collect_block(after_brace, &mut lines)?;
let mut kvs: Vec<(String, FieldValue)> = Vec::new();
for chunk in block_content.split([';', '\n']) {
if let Some((k, v)) = parse_kv(chunk)? {
kvs.push((k, v));
}
}
commands.push(Command::ConfigureBlock(kvs));
continue;
} else {
commands.push(Command::Configure);
continue;
}
}
if let Some(tokens) = split(trimmed) {
let tokens: Vec<String> = tokens
.into_iter()
.filter(|token| {
let token = token.trim();
!token.is_empty() && token != "="
})
.collect();
if tokens.is_empty() {
continue;
}
let cmd = match tokens[0].as_str() {
"http" => Command::HttpStart(tokens[1].parse().unwrap_or(80)),
"start" => Command::Start,
"stop" => Command::Stop,
"select" => {
if tokens.len() > 1 {
Command::Select(tokens[1].clone())
} else {
Command::None
}
}
"help" => Command::Help,
"get" => {
if tokens.len() > 1 {
Command::Get(tokens[1].clone())
} else {
Command::None
}
}
"set" => {
if tokens.len() > 2 {
Command::Set {
key: tokens[1].clone(),
value: parse_value(tokens[2].as_str()),
}
} else {
Command::None
}
}
"list" => {
if tokens.len() > 1 && tokens[1].eq_ignore_ascii_case("state") {
Command::ListState
} else {
Command::List
}
}
"install" => {
if tokens.len() > 1 {
Command::Install(PathBuf::from(tokens[1].clone()))
} else {
Command::None
}
}
"toggle" => {
if tokens.len() > 1 {
Command::Toggle(tokens[1].clone())
} else {
Command::None
}
}
"execute" => {
if tokens.len() > 1 {
Command::Execute(PathBuf::from(tokens[1].clone()))
} else {
Command::None
}
}
"exit" => Command::Exit,
"configure" => Command::Configure, _ => Command::None,
};
commands.push(cmd);
}
}
Ok(commands)
}
fn locate_ninja_cli() -> Result<PathBuf> {
let exe_path = env::current_exe()?;
if let Some(root) = exe_path.parent() {
let cli_path = if cfg!(windows) {
root.join("shurikenctl.exe")
} else {
root.join("shurikenctl")
};
if !cli_path.exists() {
return Err(Error::msg("No ninja CLI found"));
}
Ok(cli_path)
} else {
Err(Error::msg(
"No parent directory? where and how did you run this? please email me i'm genuinely curious. -- Hannan \"tunafysh\" Smani",
))
}
}
pub struct DslContext {
pub manager: ShurikenManager,
pub selected: Arc<RwLock<Option<String>>>,
}
impl DslContext {
pub fn new(manager: ShurikenManager) -> Self {
Self {
manager,
selected: Arc::new(RwLock::new(None)),
}
}
}
pub async fn execute_commands(ctx: &DslContext, script: String) -> Result<Vec<String>> {
let parsed_commands = command_parser(script.as_str())?;
let mut output: Vec<String> = Vec::new();
for command in parsed_commands {
match command {
Command::HttpStart(port) => {
let path = locate_ninja_cli()?;
SubprocessCommand::new(path)
.arg("api")
.arg(port.to_string())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::inherit())
.status()
.await?;
output.push(format!("HTTP server started on port {}", port));
}
Command::Select(name) => {
debug!("Shurikens: {:#?}", ctx.manager.shurikens.read().await);
if ctx.manager.shurikens.read().await.contains_key(&name) {
*ctx.selected.write().await = Some(name.clone());
output.push(format!("Selected shuriken '{}'", name));
} else {
output.push(format!("No such shuriken: {}", name));
}
}
Command::Configure => {
if let Some(name) = &*ctx.selected.read().await {
let mut shurikens = ctx.manager.shurikens.write().await;
if let Some(shuriken) = shurikens.get_mut(name) {
let path = &ctx.manager.root_path;
shuriken
.configure(
path,
&*ctx.manager.engine.lock().await,
Some(ctx.manager.clone()),
)
.await
.map_err(Error::msg)?;
output.push(format!(
"Generated configuration for shuriken {} successfully.",
&name
));
}
}
}
Command::ConfigureBlock(kvs) => {
if let Some(shuriken_name) = &*ctx.selected.read().await {
let mut shurikens = ctx.manager.shurikens.write().await;
if let Some(shuriken) = shurikens.get_mut(shuriken_name)
&& let Some(cfg) = &mut shuriken.config
{
let partial_options = cfg.options.get_or_insert_with(Default::default);
for (k, v) in kvs {
partial_options.insert(k.clone(), v.clone());
output.push(format!(
"Set {} = {} for {}",
k,
v.render(),
shuriken_name
));
}
} else {
output.push("No selected shuriken or missing config while applying configure block.".to_string());
}
} else {
output.push("No shuriken selected — configure block ignored.".into());
}
}
Command::Help => {
output.push(
"Available commands:
http start <port> - Start the HTTP server
select <name> - Select a shuriken
configure - Generate configuration for the selected shuriken
configure { k = v } - Apply config assignments to the selected shuriken
set <key> <value> - Set a config key for the selected shuriken
get <key> - Get a config key's value
toggle <key> - Toggle a boolean config key
start - Start the selected shuriken
stop - Stop the selected shuriken
install <path> - Install a new shuriken from a file
list - List all shurikens
list state - List shurikens with their states
execute <script> - Run a Ninja script file
exit - Deselect current shuriken
help - Show this message"
.to_string(),
);
}
Command::Set { key, value } => {
if let Some(shuriken_name) = &*ctx.selected.read().await {
let mut shurikens = ctx.manager.shurikens.write().await;
if let Some(shuriken) = shurikens.get_mut(shuriken_name)
&& let Some(cfg) = &mut shuriken.config
{
let cloned_value: FieldValue = value.clone();
if let Some(partial_options) = &mut cfg.options {
partial_options.insert(key.clone(), FieldValue::from(value.render()));
}
output.push(format!(
"Set {} = {} for {}",
key,
cloned_value.render(),
shuriken_name
));
}
}
}
Command::Get(key) => {
if let Some(shuriken_name) = &*ctx.selected.read().await {
let shurikens = ctx.manager.shurikens.read().await;
if let Some(shuriken) = shurikens.get(shuriken_name)
&& let Some(cfg) = &shuriken.config
&& let Some(options) = &cfg.options
{
output.push(format!("{:?} = {:?}", key, options.get(&key)));
}
}
}
Command::Toggle(key) => {
if let Some(shuriken_name) = &*ctx.selected.read().await {
let mut shurikens = ctx.manager.shurikens.write().await;
if let Some(shuriken) = shurikens.get_mut(shuriken_name)
&& let Some(cfg) = &mut shuriken.config
&& let Some(options) = &mut cfg.options
&& let Some(FieldValue::Bool(value)) = options.get_mut(&key)
{
*value = !*value;
output.push(format!("Toggled {} to {}", key, value));
}
}
}
Command::List => {
if let Either::Right(names) = ctx.manager.list(false).await? {
output.push(format!("Shurikens: {:?}", names))
}
}
Command::ListState => {
if let Either::Left(states) = ctx.manager.list(true).await? {
for (name, state) in states {
output.push(format!("{} -> {:?}", name, state));
}
}
}
Command::Start => {
if let Some(name) = &*ctx.selected.read().await {
match ctx.manager.start(name).await {
Ok(_) => output.push(format!("Started {}", name)),
Err(e) => output.push(format!("Error: {}", e)),
}
}
}
Command::Stop => {
if let Some(name) = &*ctx.selected.read().await {
match ctx.manager.stop(name).await {
Ok(_) => output.push(format!("Stopped {}", name)),
Err(e) => output.push(format!("Error: {}", e)),
}
}
}
Command::Execute(script_path) => {
let engine = NinjaEngine::new()
.await
.map_err(|e| io::Error::other(e.to_string()))?;
engine
.execute_file(&script_path, None, Some(ctx.manager.clone()))
.await
.map_err(|e| io::Error::other(e.to_string()))?;
}
Command::Install(file_path) => match ctx.manager.install(&file_path).await {
Ok(_) => output.push("Installed successfully".into()),
Err(e) => output.push(format!("Install failed: {}", e)),
},
Command::Exit => {
if ctx.selected.write().await.is_some() {
*ctx.selected.write().await = None;
output.push("Discarded current shuriken".into());
} else {
output.push("Cannot exit when there's no shuriken to discard.".into());
}
}
Command::None => {
output.push("Invalid or unsupported command.".to_string());
}
}
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_comments() {
assert_eq!(strip_comments("code // comment"), "code ");
assert_eq!(strip_comments("code # comment"), "code ");
assert_eq!(strip_comments("code"), "code");
assert_eq!(strip_comments(""), "");
assert_eq!(strip_comments("// comment"), "");
assert_eq!(strip_comments("# comment"), "");
}
#[test]
fn test_parse_value() {
let val = parse_value("\"hello\"");
match val {
FieldValue::String(s) => assert_eq!(s, "hello"),
_ => panic!("Expected String"),
}
let val = parse_value("'world'");
match val {
FieldValue::String(s) => assert_eq!(s, "world"),
_ => panic!("Expected String"),
}
let val = parse_value("true");
match val {
FieldValue::Bool(b) => assert!(b),
_ => panic!("Expected Bool"),
}
let val = parse_value("false");
match val {
FieldValue::Bool(b) => assert!(!b),
_ => panic!("Expected Bool"),
}
let val = parse_value("42");
match val {
FieldValue::Number(n) => assert_eq!(n, 42),
_ => panic!("Expected Number"),
}
let val = parse_value("unquoted");
match val {
FieldValue::String(s) => assert_eq!(s, "unquoted"),
_ => panic!("Expected String"),
}
}
#[test]
fn test_parse_kv() {
let result = parse_kv("key = value").unwrap();
assert!(result.is_some());
let (k, v) = result.unwrap();
assert_eq!(k, "key");
match v {
FieldValue::String(s) => assert_eq!(s, "value"),
_ => panic!("Expected String"),
}
let result = parse_kv("key = value;").unwrap();
assert!(result.is_some());
let (k, _) = result.unwrap();
assert_eq!(k, "key");
let result = parse_kv("").unwrap();
assert!(result.is_none());
let result = parse_kv("= value");
assert!(result.is_err());
let result = parse_kv("standalone").unwrap();
assert!(result.is_none());
}
#[test]
fn test_collect_block_inline() {
let lines_vec: Vec<&str> = vec![];
let mut lines = lines_vec.iter().copied();
let result = collect_block("key = value }", &mut lines);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "key = value");
}
#[test]
fn test_collect_block_multiline() {
let lines_vec = vec!["line2", "line3 }"];
let mut lines = lines_vec.iter().copied();
let result = collect_block("line1", &mut lines);
assert!(result.is_ok());
let content = result.unwrap();
assert!(content.contains("line1"));
assert!(content.contains("line2"));
assert!(content.contains("line3"));
}
#[test]
fn test_collect_block_missing_closing() {
let lines_vec = vec!["line2", "line3"];
let mut lines = lines_vec.iter().copied();
let result = collect_block("line1", &mut lines);
assert!(result.is_err());
}
#[test]
fn test_command_parser_simple() {
let script = "start\nstop\nlist";
let result = command_parser(script).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_command_parser_with_comments() {
let script = "start // start the service\nstop # stop it";
let result = command_parser(script).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_command_parser_configure_block() {
let script = "configure { key1 = value1; key2 = 42 }";
let result = command_parser(script).unwrap();
assert_eq!(result.len(), 1);
match &result[0] {
Command::ConfigureBlock(kvs) => {
assert_eq!(kvs.len(), 2);
assert_eq!(kvs[0].0, "key1");
}
_ => panic!("Expected ConfigureBlock"),
}
}
#[test]
fn test_command_parser_empty_lines() {
let script = "\n\nstart\n\n\nstop\n\n";
let result = command_parser(script).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_command_parser_select() {
let script = "select myservice";
let result = command_parser(script).unwrap();
assert_eq!(result.len(), 1);
match &result[0] {
Command::Select(name) => assert_eq!(name, "myservice"),
_ => panic!("Expected Select"),
}
}
#[test]
fn test_command_parser_set() {
let script = "set key value";
let result = command_parser(script).unwrap();
assert_eq!(result.len(), 1);
match &result[0] {
Command::Set { key, .. } => assert_eq!(key, "key"),
_ => panic!("Expected Set"),
}
}
}