use super::helpers::*;
use crate::core::{resolver, state, types};
use crate::tripwire::eventlog;
use std::path::Path;
pub(crate) fn cmd_audit(
state_dir: &Path,
machine_filter: Option<&str>,
limit: usize,
json: bool,
) -> Result<(), String> {
let entries = std::fs::read_dir(state_dir)
.map_err(|e| format!("cannot read state dir {}: {}", state_dir.display(), e))?;
let mut all_events: Vec<(String, types::TimestampedEvent)> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(filter) = machine_filter {
if name != filter {
continue;
}
}
if !entry.path().is_dir() {
continue;
}
let log_path = eventlog::event_log_path(state_dir, &name);
if !log_path.exists() {
continue;
}
let content = std::fs::read_to_string(&log_path)
.map_err(|e| format!("cannot read {}: {}", log_path.display(), e))?;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
if let Ok(event) = serde_json::from_str::<types::TimestampedEvent>(line) {
all_events.push((name.clone(), event));
}
}
}
all_events.sort_by(|a, b| b.1.ts.cmp(&a.1.ts));
all_events.truncate(limit);
if json {
let json_events: Vec<serde_json::Value> = all_events
.iter()
.map(|(machine, ev)| {
serde_json::json!({
"machine": machine,
"timestamp": ev.ts,
"event": format!("{:?}", ev.event),
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json_events).unwrap_or_default()
);
} else {
if all_events.is_empty() {
println!("No audit events found.");
return Ok(());
}
println!("Audit trail (last {limit} events):\n");
for (machine, ev) in &all_events {
println!(" {} [{}] {:?}", ev.ts, machine, ev.event);
}
}
Ok(())
}
fn check_resource_compliance(
id: &str,
res: &types::Resource,
violations: &mut Vec<serde_json::Value>,
) {
if res.resource_type == crate::core::types::ResourceType::File {
if res.mode.is_none() {
violations.push(serde_json::json!({
"resource": id,
"rule": "file-mode-required",
"severity": "warning",
"message": format!("File resource '{}' has no mode set", id),
}));
}
if res.owner.is_none() {
violations.push(serde_json::json!({
"resource": id,
"rule": "file-owner-required",
"severity": "warning",
"message": format!("File resource '{}' has no owner set", id),
}));
}
}
if res.resource_type == crate::core::types::ResourceType::Service && res.enabled.is_none() {
violations.push(serde_json::json!({
"resource": id,
"rule": "service-enabled-required",
"severity": "warning",
"message": format!("Service resource '{}' does not set 'enabled' explicitly", id),
}));
}
if let Some(ref path) = res.path {
if (path.starts_with("/etc/") || path.starts_with("/usr/")) && res.owner.is_none() {
violations.push(serde_json::json!({
"resource": id,
"rule": "system-path-owner-required",
"severity": "error",
"message": format!("Resource '{}' writes to system path '{}' without explicit owner", id, path),
}));
}
}
}
fn output_compliance_results(violations: &[serde_json::Value], json: bool) {
if json {
println!(
"{}",
serde_json::to_string_pretty(violations).unwrap_or_else(|_| "[]".to_string())
);
} else if violations.is_empty() {
println!("{} All compliance checks passed.", green("✓"));
} else {
println!("Compliance violations:\n");
for v in violations {
let icon = if v["severity"] == "error" {
red("✗")
} else {
yellow("!")
};
println!(
" {} [{}] {}",
icon,
v["rule"].as_str().unwrap_or("?"),
v["message"].as_str().unwrap_or("?"),
);
}
println!(
"\n{} {} violation(s) found",
yellow("Total:"),
violations.len()
);
}
}
pub(crate) fn cmd_compliance(file: &Path, json: bool) -> Result<(), String> {
let mut config = parse_and_validate(file)?;
resolver::resolve_data_sources(&mut config)?;
let mut violations = Vec::new();
for (id, res) in &config.resources {
check_resource_compliance(id, res, &mut violations);
}
output_compliance_results(&violations, json);
Ok(())
}
fn collect_export_resources(
state_dir: &Path,
machine_filter: Option<&str>,
) -> Result<Vec<(String, String, types::ResourceLock)>, String> {
let entries =
std::fs::read_dir(state_dir).map_err(|e| format!("cannot read state dir: {e}"))?;
let mut all_resources = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(filter) = machine_filter {
if name != filter {
continue;
}
}
if !entry.path().is_dir() {
continue;
}
if let Some(lock) = state::load_lock(state_dir, &name)? {
for (id, rl) in lock.resources {
all_resources.push((id, lock.machine.clone(), rl));
}
}
}
Ok(all_resources)
}
fn format_ansible(all_resources: &[(String, String, types::ResourceLock)]) -> String {
let mut machines: std::collections::BTreeMap<&str, Vec<&str>> =
std::collections::BTreeMap::new();
for (id, machine, _rl) in all_resources {
machines
.entry(machine.as_str())
.or_default()
.push(id.as_str());
}
let mut lines = vec!["all:".to_string(), " hosts:".to_string()];
for (machine, resources) in &machines {
lines.push(format!(" {machine}:"));
lines.push(" forjar_resources:".to_string());
for res in resources {
lines.push(format!(" - {res}"));
}
}
lines.join("\n")
}
pub(crate) fn cmd_export(
state_dir: &Path,
format: &str,
machine_filter: Option<&str>,
output: Option<&Path>,
) -> Result<(), String> {
let all_resources = collect_export_resources(state_dir, machine_filter)?;
let content = match format {
"csv" => {
let mut lines = vec!["resource,machine,type,status,hash,applied_at".to_string()];
for (id, machine, rl) in &all_resources {
lines.push(format!(
"{},{},{:?},{:?},{},{}",
id,
machine,
rl.resource_type,
rl.status,
rl.hash,
rl.applied_at.as_deref().unwrap_or("-")
));
}
lines.join("\n")
}
"terraform" => {
let mut blocks = Vec::new();
for (id, _machine, rl) in &all_resources {
blocks.push(format!(
"# {}\nimport {{\n to = forjar_resource.{}\n id = \"{}\"\n}}",
id, id, rl.hash
));
}
blocks.join("\n\n")
}
"ansible" => format_ansible(&all_resources),
"json" => {
let entries: Vec<serde_json::Value> = all_resources
.iter()
.map(|(id, machine, rl)| {
serde_json::json!({
"resource": id,
"machine": machine,
"type": format!("{:?}", rl.resource_type),
"status": format!("{:?}", rl.status),
"hash": rl.hash,
"applied_at": rl.applied_at,
})
})
.collect();
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
}
_ => {
return Err(format!(
"Unknown export format '{format}'. Supported: csv, terraform, ansible, json"
))
}
};
if let Some(output_path) = output {
std::fs::write(output_path, &content).map_err(|e| e.to_string())?;
println!(
"Exported {} resources to {}",
all_resources.len(),
output_path.display()
);
} else {
println!("{content}");
}
Ok(())
}
fn check_unused_params(
config: &types::ForjarConfig,
config_str: &str,
suggestions: &mut Vec<serde_json::Value>,
) {
for key in config.params.keys() {
let pattern = format!("{{{{params.{key}}}}}");
if !config_str.contains(&pattern) {
suggestions.push(serde_json::json!({
"type": "unused-param",
"severity": "info",
"message": format!("Parameter '{}' is defined but never referenced", key),
}));
}
}
}
fn check_missing_dependencies(
config: &types::ForjarConfig,
suggestions: &mut Vec<serde_json::Value>,
) {
let mut machine_resources: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (id, res) in &config.resources {
let machine_name = match &res.machine {
types::MachineTarget::Single(s) => s.clone(),
types::MachineTarget::Multiple(ms) => ms.first().cloned().unwrap_or_default(),
};
machine_resources
.entry(machine_name)
.or_default()
.push(id.clone());
}
for resources in machine_resources.values() {
if resources.len() <= 1 {
continue;
}
for id in resources {
let res = &config.resources[id];
if res.depends_on.is_empty() {
let has_dependent = config.resources.values().any(|r| r.depends_on.contains(id));
if !has_dependent && resources.len() > 2 {
suggestions.push(serde_json::json!({
"type": "no-dependencies",
"severity": "info",
"message": format!("Resource '{}' has no depends_on and nothing depends on it — verify ordering", id),
}));
}
}
}
}
}
fn output_suggestions(suggestions: &[serde_json::Value], json: bool) {
if json {
println!(
"{}",
serde_json::to_string_pretty(suggestions).unwrap_or_else(|_| "[]".to_string())
);
} else if suggestions.is_empty() {
println!("{} No suggestions — config looks good.", green("✓"));
} else {
println!("Suggestions:\n");
for s in suggestions {
println!(
" {} [{}] {}",
dim("→"),
s["type"].as_str().unwrap_or("?"),
s["message"].as_str().unwrap_or("?"),
);
}
println!("\n{} {} suggestion(s)", dim("Total:"), suggestions.len());
}
}
pub(crate) fn cmd_suggest(file: &Path, json: bool) -> Result<(), String> {
let config = parse_and_validate(file)?;
let mut suggestions = Vec::new();
let config_str = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
check_unused_params(&config, &config_str, &mut suggestions);
check_missing_dependencies(&config, &mut suggestions);
output_suggestions(&suggestions, json);
Ok(())
}