use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use secunit_core::schemas::Schema;
use serde_json::Value as JsonValue;
use serde_yaml::{Mapping, Value as YamlValue};
use super::Ctx;
#[derive(Debug, Default)]
struct ImportSummary {
added_controls: Vec<String>,
kept_controls: Vec<String>,
added_files: Vec<String>,
kept_files: Vec<String>,
inventory_created: bool,
inventory_added: Vec<(String, String)>,
inventory_added_kinds: Vec<String>,
invalid: Vec<(PathBuf, Vec<String>)>,
}
impl ImportSummary {
fn print_human(&self) {
if self.added_controls.is_empty()
&& self.kept_controls.is_empty()
&& self.added_files.is_empty()
&& self.kept_files.is_empty()
&& !self.inventory_created
&& self.inventory_added.is_empty()
&& self.inventory_added_kinds.is_empty()
&& self.invalid.is_empty()
{
println!("No drafts found to import.");
return;
}
if !self.added_controls.is_empty() {
println!("Added controls ({}):", self.added_controls.len());
for c in &self.added_controls {
println!(" + {c}");
}
}
if !self.kept_controls.is_empty() {
println!("Kept controls ({}):", self.kept_controls.len());
for c in &self.kept_controls {
println!(" = {c}");
}
}
if self.inventory_created {
println!("Created inventory.yaml from draft.");
}
if !self.inventory_added_kinds.is_empty() {
println!(
"Added inventory kinds ({}): {}",
self.inventory_added_kinds.len(),
self.inventory_added_kinds.join(", ")
);
}
if !self.inventory_added.is_empty() {
println!("Added inventory entries ({}):", self.inventory_added.len());
for (k, n) in &self.inventory_added {
println!(" + {k}/{n}");
}
}
for f in &self.added_files {
println!("Added {f} from draft.");
}
for f in &self.kept_files {
println!("Kept existing {f} (draft ignored).");
}
if !self.invalid.is_empty() {
println!("\nDrafts rejected ({}):", self.invalid.len());
for (path, errs) in &self.invalid {
println!(" ✗ {}", path.display());
for e in errs {
println!(" {e}");
}
}
}
}
fn to_json(&self) -> JsonValue {
serde_json::json!({
"added_controls": self.added_controls,
"kept_controls": self.kept_controls,
"added_files": self.added_files,
"kept_files": self.kept_files,
"inventory_created": self.inventory_created,
"inventory_added": self.inventory_added.iter()
.map(|(k, n)| serde_json::json!({"kind": k, "name": n}))
.collect::<Vec<_>>(),
"inventory_added_kinds": self.inventory_added_kinds,
"invalid": self.invalid.iter().map(|(p, e)| serde_json::json!({
"path": p,
"errors": e,
})).collect::<Vec<_>>(),
})
}
}
fn draft_root(src: &Path) -> PathBuf {
let raw = src.join("raw");
if raw.is_dir() {
raw
} else {
src.to_path_buf()
}
}
pub fn import(ctx: &Ctx, src_dir: &Path) -> Result<ExitCode> {
let src = src_dir
.canonicalize()
.with_context(|| format!("resolve source dir {}", src_dir.display()))?;
let root = ctx
.root
.canonicalize()
.with_context(|| format!("resolve --root {}", ctx.root.display()))?;
let drafts = draft_root(&src);
let mut summary = ImportSummary::default();
import_controls(&drafts, &root, &mut summary)?;
import_inventory(&drafts, &root, &mut summary)?;
for fname in ["schedule.yaml", "_config.yaml"] {
let schema = if fname == "schedule.yaml" {
Schema::Schedule
} else {
Schema::Config
};
import_top_level(&drafts, &root, fname, schema, &mut summary)?;
}
if ctx.json {
println!("{}", serde_json::to_string_pretty(&summary.to_json())?);
} else {
summary.print_human();
}
Ok(if summary.invalid.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
})
}
fn import_controls(drafts: &Path, root: &Path, summary: &mut ImportSummary) -> Result<()> {
let dir = drafts.join("controls");
if !dir.is_dir() {
return Ok(());
}
let dest_dir = root.join("controls");
fs::create_dir_all(&dest_dir)?;
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
continue;
}
let text =
fs::read_to_string(&path).with_context(|| format!("read draft {}", path.display()))?;
let value: JsonValue = match yaml_to_json(&text) {
Ok(v) => v,
Err(e) => {
summary
.invalid
.push((path.clone(), vec![format!("yaml: {e}")]));
continue;
}
};
let errs = Schema::Control.validate(&value);
if !errs.is_empty() {
summary.invalid.push((path.clone(), errs));
continue;
}
let fname = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("draft has no filename: {}", path.display()))?;
let dest = dest_dir.join(fname);
let label = fname.to_string_lossy().into_owned();
if dest.exists() {
summary.kept_controls.push(label);
continue;
}
fs::write(&dest, &text).with_context(|| format!("write {}", dest.display()))?;
summary.added_controls.push(label);
}
summary.added_controls.sort();
summary.kept_controls.sort();
Ok(())
}
fn import_inventory(drafts: &Path, root: &Path, summary: &mut ImportSummary) -> Result<()> {
let src = drafts.join("inventory.yaml");
if !src.is_file() {
return Ok(());
}
let dest = root.join("inventory.yaml");
let draft_text = fs::read_to_string(&src)?;
let draft_yaml: YamlValue = match serde_yaml::from_str(&draft_text) {
Ok(v) => v,
Err(e) => {
summary
.invalid
.push((src.clone(), vec![format!("yaml: {e}")]));
return Ok(());
}
};
let draft_json = yaml_to_json_value(&draft_yaml)?;
let errs = Schema::Inventory.validate(&draft_json);
if !errs.is_empty() {
summary.invalid.push((src.clone(), errs));
return Ok(());
}
if !dest.exists() {
fs::write(&dest, &draft_text)?;
summary.inventory_created = true;
return Ok(());
}
let live_text = fs::read_to_string(&dest)?;
let live_yaml: YamlValue =
serde_yaml::from_str(&live_text).with_context(|| format!("parse {}", dest.display()))?;
let merged = merge_inventory(&live_yaml, &draft_yaml, summary);
let merged_json = yaml_to_json_value(&merged)?;
let errs = Schema::Inventory.validate(&merged_json);
if !errs.is_empty() {
summary.invalid.push((dest.clone(), errs));
return Ok(());
}
let out = serde_yaml::to_string(&merged)?;
fs::write(&dest, out).with_context(|| format!("write {}", dest.display()))?;
Ok(())
}
fn import_top_level(
drafts: &Path,
root: &Path,
fname: &str,
schema: Schema,
summary: &mut ImportSummary,
) -> Result<()> {
let src = drafts.join(fname);
if !src.is_file() {
return Ok(());
}
let dest = root.join(fname);
if dest.exists() {
summary.kept_files.push(fname.to_string());
return Ok(());
}
let text = fs::read_to_string(&src)?;
let value: JsonValue = match yaml_to_json(&text) {
Ok(v) => v,
Err(e) => {
summary.invalid.push((src, vec![format!("yaml: {e}")]));
return Ok(());
}
};
let errs = schema.validate(&value);
if !errs.is_empty() {
summary.invalid.push((src, errs));
return Ok(());
}
fs::write(&dest, &text).with_context(|| format!("write {}", dest.display()))?;
summary.added_files.push(fname.to_string());
Ok(())
}
fn merge_inventory(live: &YamlValue, draft: &YamlValue, summary: &mut ImportSummary) -> YamlValue {
let live_map = match live.as_mapping() {
Some(m) => m.clone(),
None => Mapping::new(),
};
let draft_map = match draft.as_mapping() {
Some(m) => m,
None => return YamlValue::Mapping(live_map),
};
let mut out = live_map.clone();
for (kind_key, draft_entries) in draft_map {
let kind_name = kind_key.as_str().unwrap_or_default().to_string();
let draft_seq = match draft_entries.as_sequence() {
Some(s) => s,
None => continue,
};
match out.get(kind_key).cloned() {
Some(YamlValue::Sequence(mut live_seq)) => {
let live_names: std::collections::HashSet<String> = live_seq
.iter()
.filter_map(|e| e.as_mapping())
.filter_map(|m| m.get(YamlValue::String("name".into())))
.filter_map(|n| n.as_str())
.map(str::to_string)
.collect();
for d in draft_seq {
let dname = d
.as_mapping()
.and_then(|m| m.get(YamlValue::String("name".into())))
.and_then(|v| v.as_str())
.unwrap_or("");
if dname.is_empty() || live_names.contains(dname) {
continue;
}
live_seq.push(d.clone());
summary
.inventory_added
.push((kind_name.clone(), dname.to_string()));
}
out.insert(kind_key.clone(), YamlValue::Sequence(live_seq));
}
_ => {
out.insert(kind_key.clone(), draft_entries.clone());
summary.inventory_added_kinds.push(kind_name.clone());
for d in draft_seq {
if let Some(name) = d
.as_mapping()
.and_then(|m| m.get(YamlValue::String("name".into())))
.and_then(|v| v.as_str())
{
summary
.inventory_added
.push((kind_name.clone(), name.to_string()));
}
}
}
}
}
YamlValue::Mapping(out)
}
fn yaml_to_json(text: &str) -> Result<JsonValue> {
let yaml: YamlValue = serde_yaml::from_str(text)?;
yaml_to_json_value(&yaml)
}
fn yaml_to_json_value(yaml: &YamlValue) -> Result<JsonValue> {
let s = serde_json::to_string(yaml)?;
Ok(serde_json::from_str(&s)?)
}