use crate::error::{AppError, Result}; use serde_json::Value; #[cfg(unix)] use libc; use std::process::Stdio; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::Command, sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, }; #[derive(Debug)] pub enum StructuredCommand { Input { prompt: String, default: Option, secret: bool, }, Menu { prompt: String, options: Vec, }, Confirm { prompt: String, }, Message { level: MessageLevel, text: String, }, Progress { /// Some(n) = deterministic bar (0-100). /// None = indeterminate spinner (percent omitted from JSON). percent: Option, message: Option, }, /// A list of checkboxes and radio buttons. /// Response: space-separated IDs of all checked/selected fields. Form { prompt: String, fields: Vec, }, /// Run an interactive program that needs the real terminal. Exec { shell: String, }, } #[derive(Debug, Clone, PartialEq)] pub enum FormFieldType { Checkbox, Radio, } #[derive(Debug, Clone)] pub struct FormField { pub id: String, pub label: String, pub field_type: FormFieldType, pub default: bool, /// Radio buttons with the same group are mutually exclusive. pub group: Option, } #[derive(Debug)] pub struct MenuOption { pub id: String, pub label: String, } #[derive(Debug)] pub enum MessageLevel { Info, Warn, Error, } /// Запускает bash-скрипт в структурированном режиме. /// Возвращает: /// - `output_rx` – канал для строк вывода (обычный текст и stderr) /// - `command_rx` – канал для команд от скрипта /// - `finished_rx` – одноразовый канал с кодом завершения /// - `reply_tx` – канал для отправки ответов обратно в stdin скрипта pub fn spawn( script: &str, ) -> Result<( UnboundedReceiver, UnboundedReceiver, tokio::sync::oneshot::Receiver, UnboundedSender, tokio::sync::oneshot::Sender<()>, // kill signal )> { let mut child = Command::new("bash") .arg("-c") .arg(script) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; // Capture the PID before moving child into tasks (needed for kill). let pid = child.id(); let stdin = child.stdin.take().unwrap(); let stdout = child.stdout.take().unwrap(); let stderr = child.stderr.take().unwrap(); let (output_tx, output_rx) = mpsc::unbounded_channel(); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::(); let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); // Чтение stdout let output_tx_clone = output_tx.clone(); let cmd_tx_stdout = cmd_tx.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { if line.starts_with("CMD:") { if parse_command(&line[4..], cmd_tx_stdout.clone()).is_err() { let _ = output_tx_clone.send(format!("[PROTOCOL ERROR] {}", line)); } } else { let _ = output_tx_clone.send(line); } } }); // Чтение stderr let output_tx_stderr = output_tx.clone(); let cmd_tx_stderr = cmd_tx.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stderr).lines(); while let Ok(Some(line)) = reader.next_line().await { if line.starts_with("CMD:") { if parse_command(&line[4..], cmd_tx_stderr.clone()).is_err() { let _ = output_tx_stderr.send(format!("[PROTOCOL ERROR] {}", line)); } } else { let _ = output_tx_stderr.send(format!("stderr: {}", line)); } } }); // Запись в stdin tokio::spawn(async move { let mut stdin = stdin; while let Some(response) = reply_rx.recv().await { if stdin.write_all(response.as_bytes()).await.is_err() { break; } if stdin.write_all(b"\n").await.is_err() { break; } } }); // Wait for child exit normally. tokio::spawn(async move { let status = child.wait().await; let code = status.map(|s| s.code().unwrap_or(1)).unwrap_or(1); let _ = finished_tx.send(code); }); // Kill-watcher: when kill_tx fires, send SIGTERM to the entire process // group so bash AND all its children (mysqldump, php, etc.) are killed. tokio::spawn(async move { if kill_rx.await.is_ok() { if let Some(pid) = pid { #[cfg(unix)] unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGTERM); } } } }); Ok((output_rx, cmd_rx, finished_rx, reply_tx, kill_tx)) } fn parse_command(json_str: &str, cmd_tx: UnboundedSender) -> Result<()> { let v: Value = serde_json::from_str(json_str)?; let typ = v["type"].as_str().ok_or_else(|| AppError::Protocol("Missing type".into()))?; let cmd = match typ { "input" => { let prompt = v["prompt"].as_str().unwrap_or("").to_string(); let default = v["default"].as_str().map(String::from); let secret = v["secret"].as_bool().unwrap_or(false); StructuredCommand::Input { prompt, default, secret } } "menu" => { let prompt = v["prompt"].as_str().unwrap_or("").to_string(); let options = v["options"] .as_array() .map(|arr| { arr.iter() .filter_map(|opt| { Some(MenuOption { id: opt["id"].as_str()?.to_string(), label: opt["label"].as_str()?.to_string(), }) }) .collect() }) .unwrap_or_default(); StructuredCommand::Menu { prompt, options } } "confirm" => { let prompt = v["prompt"].as_str().unwrap_or("").to_string(); StructuredCommand::Confirm { prompt } } "message" => { let level = match v["level"].as_str() { Some("warn") => MessageLevel::Warn, Some("error") => MessageLevel::Error, _ => MessageLevel::Info, }; let text = v["text"].as_str().unwrap_or("").to_string(); StructuredCommand::Message { level, text } } "progress" => { // percent absent or null → None (indeterminate spinner) let percent = v["percent"].as_u64().map(|n| n.min(100) as u8); let message = v["message"].as_str().map(String::from); StructuredCommand::Progress { percent, message } } "form" => { let prompt = v["prompt"].as_str().unwrap_or("").to_string(); let fields = v["fields"].as_array() .map(|arr| { arr.iter().filter_map(|f| { let id = f["id"].as_str()?.to_string(); let label = f["label"].as_str()?.to_string(); let field_type = match f["field_type"].as_str().unwrap_or("checkbox") { "radio" => FormFieldType::Radio, _ => FormFieldType::Checkbox, }; let default = f["default"].as_bool().unwrap_or(false); let group = f["group"].as_str().map(String::from); Some(FormField { id, label, field_type, default, group }) }).collect() }) .unwrap_or_default(); StructuredCommand::Form { prompt, fields } } "exec" => { let shell = v["shell"].as_str().unwrap_or("").to_string(); StructuredCommand::Exec { shell } } _ => return Err(AppError::Protocol(format!("Unknown command type: {}", typ))), }; cmd_tx.send(cmd).map_err(|_| AppError::Protocol("Command channel closed".into()))?; Ok(()) }