use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
pub type AnsiblePlaybook = Vec<AnsiblePlay>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnsiblePlay {
pub name: Option<String>,
pub hosts: String,
#[serde(default, rename = "become")]
pub become_enabled: Option<bool>,
#[serde(default, rename = "become_user")]
pub become_user: Option<String>,
#[serde(default, rename = "become_method")]
pub become_method: Option<String>,
#[serde(default)]
pub gather_facts: Option<bool>,
#[serde(default)]
pub vars: Option<HashMap<String, serde_json::Value>>,
#[serde(default, rename = "vars_files")]
pub vars_files: Option<Vec<String>>,
#[serde(default)]
pub pre_tasks: Option<Vec<AnsibleTask>>,
#[serde(default)]
pub tasks: Vec<AnsibleTask>,
#[serde(default)]
pub post_tasks: Option<Vec<AnsibleTask>>,
#[serde(default)]
pub handlers: Option<Vec<AnsibleTask>>,
#[serde(default)]
pub roles: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default, rename = "serial")]
pub serial_batch: Option<serde_json::Value>,
#[serde(default)]
pub max_fail_percentage: Option<u32>,
#[serde(default)]
pub environment: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnsibleTask {
pub name: Option<String>,
#[serde(default)]
pub shell: Option<serde_json::Value>,
#[serde(default)]
pub command: Option<serde_json::Value>,
#[serde(default)]
pub script: Option<String>,
#[serde(default)]
pub copy: Option<serde_json::Value>,
#[serde(default)]
pub template: Option<serde_json::Value>,
#[serde(default)]
pub file: Option<serde_json::Value>,
#[serde(default)]
pub apt: Option<serde_json::Value>,
#[serde(default)]
pub yum: Option<serde_json::Value>,
#[serde(default)]
pub dnf: Option<serde_json::Value>,
#[serde(default)]
pub pip: Option<serde_json::Value>,
#[serde(default)]
pub systemd: Option<serde_json::Value>,
#[serde(default)]
pub service: Option<serde_json::Value>,
#[serde(default)]
pub docker_container: Option<serde_json::Value>,
#[serde(default)]
pub kubernetes: Option<serde_json::Value>,
#[serde(default)]
pub get_url: Option<serde_json::Value>,
#[serde(default)]
pub unarchive: Option<serde_json::Value>,
#[serde(default)]
pub git: Option<serde_json::Value>,
#[serde(default)]
pub cron: Option<serde_json::Value>,
#[serde(default)]
pub user: Option<serde_json::Value>,
#[serde(default)]
pub group: Option<serde_json::Value>,
#[serde(default)]
pub lineinfile: Option<serde_json::Value>,
#[serde(default)]
pub replace: Option<serde_json::Value>,
#[serde(default)]
pub stat: Option<serde_json::Value>,
#[serde(default)]
pub debug: Option<serde_json::Value>,
#[serde(default)]
pub assert: Option<serde_json::Value>,
#[serde(default)]
pub wait_for: Option<serde_json::Value>,
#[serde(default)]
pub pause: Option<serde_json::Value>,
#[serde(default)]
pub set_fact: Option<serde_json::Value>,
#[serde(default)]
pub register: Option<String>,
#[serde(default)]
pub when: Option<serde_json::Value>,
#[serde(default, rename = "loop")]
pub loop_items: Option<serde_json::Value>,
#[serde(default, rename = "with_items")]
pub with_items: Option<serde_json::Value>,
#[serde(default, rename = "with_dict")]
pub with_dict: Option<serde_json::Value>,
#[serde(default, rename = "with_fileglob")]
pub with_fileglob: Option<String>,
#[serde(default)]
pub until: Option<serde_json::Value>,
#[serde(default)]
pub retries: Option<u32>,
#[serde(default)]
pub delay: Option<u32>,
#[serde(default)]
pub changed_when: Option<serde_json::Value>,
#[serde(default)]
pub failed_when: Option<serde_json::Value>,
#[serde(default)]
pub ignore_errors: Option<bool>,
#[serde(default)]
pub notify: Option<serde_json::Value>,
#[serde(default)]
pub tags: Option<serde_json::Value>,
#[serde(default, rename = "become")]
pub task_become: Option<bool>,
#[serde(default, rename = "become_user")]
pub task_become_user: Option<String>,
#[serde(default, rename = "delegate_to")]
pub delegate_to: Option<String>,
#[serde(default, rename = "local_action")]
pub local_action: Option<serde_json::Value>,
#[serde(flatten)]
pub other: HashMap<String, serde_json::Value>,
}
impl AnsiblePlay {
pub fn from_value(data: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(data.clone())
.map_err(|e| format!("Failed to parse Ansible play: {e}"))
}
pub fn looks_like_playbook(data: &serde_json::Value) -> bool {
let arr = match data.as_array() {
Some(a) => a,
None => return false,
};
arr.iter().any(|item| item.get("hosts").is_some())
}
}
impl ConfigValidator for AnsiblePlaybook {
fn yaml_type(&self) -> YamlType {
YamlType::Ansible
}
fn validate_structure(&self) -> Vec<Diagnostic> {
let mut diags = Vec::new();
if self.is_empty() {
diags.push(Diagnostic {
severity: Severity::Error,
message: "Playbook is empty — no plays defined".into(),
path: None,
});
}
for (i, play) in self.iter().enumerate() {
if play.hosts.is_empty() {
diags.push(Diagnostic {
severity: Severity::Error,
message: format!("Play[{}]: 'hosts' is required but empty", i),
path: Some(format!("[{}] > hosts", i)),
});
}
}
diags
}
fn validate_semantics(&self) -> Vec<Diagnostic> {
let mut diags = Vec::new();
for (play_idx, play) in self.iter().enumerate() {
let play_prefix = format!("[{}]", play_idx);
if play.become_enabled == Some(true) && play.become_user.is_none() {
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("Play[{}]: become=true without become_user — defaults to root", play_idx),
path: Some(format!("{} > become_user", play_prefix)),
});
}
if play.tasks.is_empty() && play.roles.is_none() {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Play[{}]: no tasks or roles defined", play_idx),
path: Some(format!("{} > tasks", play_prefix)),
});
}
for (task_idx, task) in play.tasks.iter().enumerate() {
let task_path = format!("{} > tasks > {}", play_prefix, task_idx);
validate_task(task, &task_path, &mut diags);
}
if let Some(pre_tasks) = &play.pre_tasks {
for (task_idx, task) in pre_tasks.iter().enumerate() {
let task_path = format!("{} > pre_tasks > {}", play_prefix, task_idx);
validate_task(task, &task_path, &mut diags);
}
}
if let Some(post_tasks) = &play.post_tasks {
for (task_idx, task) in post_tasks.iter().enumerate() {
let task_path = format!("{} > post_tasks > {}", play_prefix, task_idx);
validate_task(task, &task_path, &mut diags);
}
}
if let Some(handlers) = &play.handlers {
for (task_idx, task) in handlers.iter().enumerate() {
let task_path = format!("{} > handlers > {}", play_prefix, task_idx);
validate_task(task, &task_path, &mut diags);
}
}
if play.name.is_none() {
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("Play[{}]: no 'name' — add for better logging", play_idx),
path: Some(play_prefix),
});
}
}
diags
}
}
fn validate_task(task: &AnsibleTask, path: &str, diags: &mut Vec<Diagnostic>) {
if task.name.is_none() {
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("Task at '{}' has no 'name' — add for clarity", path),
path: Some(path.into()),
});
}
if task.shell.is_some() {
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("Task at '{}' uses 'shell' module — prefer 'command' when shell features aren't needed", path),
path: Some(format!("{} > shell", path)),
});
}
if let Some(shell_val) = &task.shell {
let shell_str = match shell_val {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(obj) => {
obj.get("cmd").and_then(|v| v.as_str()).unwrap_or("").to_string()
}
_ => String::new(),
};
let dangerous = ["rm -rf", "dd if=", "> /dev/sd", "chmod 777", "curl | bash", "wget | bash"];
for pattern in dangerous {
if shell_str.contains(pattern) {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Task at '{}' contains potentially dangerous pattern: '{}'", path, pattern),
path: Some(format!("{} > shell", path)),
});
}
}
}
if task.apt.is_some()
&& let Some(obj) = task.apt.as_ref().and_then(|v| v.as_object())
&& !obj.contains_key("update_cache") && obj.contains_key("name")
{
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("Task at '{}' uses 'apt' without update_cache — may install outdated packages", path),
path: Some(format!("{} > apt", path)),
});
}
if task.ignore_errors == Some(true) {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Task at '{}' has ignore_errors=true — failures will be silently ignored", path),
path: Some(format!("{} > ignore_errors", path)),
});
}
let has_module = task.shell.is_some()
|| task.command.is_some()
|| task.script.is_some()
|| task.copy.is_some()
|| task.template.is_some()
|| task.file.is_some()
|| task.apt.is_some()
|| task.yum.is_some()
|| task.dnf.is_some()
|| task.pip.is_some()
|| task.systemd.is_some()
|| task.service.is_some()
|| task.docker_container.is_some()
|| task.kubernetes.is_some()
|| task.get_url.is_some()
|| task.unarchive.is_some()
|| task.git.is_some()
|| task.cron.is_some()
|| task.user.is_some()
|| task.group.is_some()
|| task.lineinfile.is_some()
|| task.replace.is_some()
|| task.stat.is_some()
|| task.debug.is_some()
|| task.assert.is_some()
|| task.wait_for.is_some()
|| task.pause.is_some()
|| task.set_fact.is_some()
|| !task.other.is_empty();
if !has_module {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Task at '{}' has no recognized module", path),
path: Some(path.into()),
});
}
}