use std::path::{Component, Path};
use clap::Args as ClapArgs;
use path_clean::PathClean;
use walkdir::WalkDir;
use crate::error::CliError;
use crate::python;
use crate::volume;
const MAX_DEPTH: usize = 3;
const REQUIRED_DIRS: &[&str] = &["core", "image", "cfg", "log"];
const REQUIRED_IMAGE_DIRS: &[&str] = &["agents", "skills", "hooks"];
const REQUIRED_IMAGE_FILES: &[&str] = &["context-map.md", "SYSTEM.md"];
const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
"volume",
"distro",
"created",
"kernel-source",
"distro-source",
];
#[derive(Debug, ClapArgs)]
pub struct Args {}
pub fn run(_args: &Args) -> Result<(), CliError> {
let cwd = std::env::current_dir()
.map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
validate_at_root(&cwd)
}
pub fn validate_at_root(start: &Path) -> Result<(), CliError> {
let root = volume::find_omne_root(start).ok_or(CliError::NotAVolume)?;
let omne = root.join(".omne");
let mut issues = Vec::new();
check_required_dirs(&omne, &mut issues);
check_core(&omne, &mut issues);
check_image(&omne, &mut issues);
check_manifest(&omne, &mut issues);
check_depth(&omne, &mut issues);
check_gate_runner(&omne, &mut issues);
if issues.is_empty() {
eprintln!("\x1b[32mVolume is valid.\x1b[0m");
Ok(())
} else {
Err(CliError::ValidationFailed { issues })
}
}
fn check_required_dirs(omne: &Path, issues: &mut Vec<String>) {
for &dir in REQUIRED_DIRS {
if !omne.join(dir).is_dir() {
issues.push(format!("missing required directory: .omne/{dir}/"));
}
}
}
fn check_core(omne: &Path, issues: &mut Vec<String>) {
let core = omne.join("core");
if !core.is_dir() {
return; }
if !core.join("manifest.json").is_file() {
issues.push("missing kernel manifest: core/manifest.json".to_string());
}
}
fn check_image(omne: &Path, issues: &mut Vec<String>) {
let image = omne.join("image");
if !image.is_dir() {
return; }
for &dir in REQUIRED_IMAGE_DIRS {
if !image.join(dir).is_dir() {
issues.push(format!("missing required image directory: image/{dir}/"));
}
}
for &file in REQUIRED_IMAGE_FILES {
if !image.join(file).is_file() {
issues.push(format!("missing required image file: image/{file}"));
}
}
}
fn check_manifest(omne: &Path, issues: &mut Vec<String>) {
let manifest = omne.join("MANIFEST.md");
if !manifest.is_file() {
issues.push("missing MANIFEST.md".to_string());
return;
}
let content = match std::fs::read_to_string(&manifest) {
Ok(c) => c,
Err(e) => {
issues.push(format!("cannot read MANIFEST.md: {e}"));
return;
}
};
let Some(yaml_body) = extract_frontmatter(&content) else {
issues.push("MANIFEST.md has no YAML frontmatter (---...---)".to_string());
return;
};
for &field in REQUIRED_MANIFEST_FIELDS {
let has_field = yaml_body
.lines()
.any(|line| line.starts_with(field) && line[field.len()..].starts_with(':'));
if !has_field {
issues.push(format!("MANIFEST.md missing required field: {field}"));
}
}
}
fn extract_frontmatter(content: &str) -> Option<String> {
let mut lines = content.lines();
if lines.next()? != "---" {
return None;
}
let mut body = String::new();
for line in lines {
if line == "---" {
return Some(body);
}
body.push_str(line);
body.push('\n');
}
None }
fn check_depth(omne: &Path, issues: &mut Vec<String>) {
for &subdir in &["cfg", "log"] {
let base = omne.join(subdir);
if !base.is_dir() {
continue; }
for entry in WalkDir::new(&base).min_depth(1) {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_dir() {
continue;
}
let rel = match entry.path().strip_prefix(omne) {
Ok(r) => r,
Err(_) => continue,
};
let depth = rel.components().count();
if depth > MAX_DEPTH {
issues.push(format!(
"depth violation ({depth} > {MAX_DEPTH}): .omne/{}",
rel.display()
));
}
}
}
}
fn check_gate_runner(omne: &Path, issues: &mut Vec<String>) {
let core_manifest = omne.join("core/manifest.json");
if !core_manifest.is_file() {
return; }
let content = match std::fs::read_to_string(&core_manifest) {
Ok(c) => c,
Err(_) => return,
};
let data: serde_json::Value = match serde_json::from_str(&content) {
Ok(d) => d,
Err(_) => {
issues.push("core/manifest.json is invalid JSON".to_string());
return;
}
};
let gate_runner = match data.get("gate_runner").and_then(|v| v.as_str()) {
Some(gr) => gr,
None => return, };
let image_dir = omne.join("image");
if !is_safe_gate_runner_path(gate_runner, &image_dir) {
issues.push(format!("gate runner path escapes image/: {gate_runner}"));
return;
}
let runner_path = image_dir.join(gate_runner);
if !runner_path.is_file() {
eprintln!("\x1b[33mwarning:\x1b[0m gate runner not found: image/{gate_runner} (skipping)");
return;
}
let interpreter = match python::find_interpreter() {
Some(interp) => interp,
None => {
eprintln!("\x1b[33m{}\x1b[0m", python::missing_python_warning());
return;
}
};
if let Err(e) = python::run_gate_runner(&interpreter, &runner_path, &image_dir) {
match e {
python::Error::GateRunnerFailed {
exit_code,
stdout,
stderr,
} => {
let mut msg = format!("gate runner failed (exit {exit_code}):");
for line in stdout.trim().lines() {
msg.push_str(&format!("\n {line}"));
}
for line in stderr.trim().lines() {
msg.push_str(&format!("\n {line}"));
}
issues.push(msg);
}
python::Error::GateRunnerTimedOut { elapsed_seconds } => {
issues.push(format!(
"gate runner timed out after {elapsed_seconds} seconds"
));
}
python::Error::InterpreterInvocation(io_err) => {
issues.push(format!("failed to invoke gate runner: {io_err}"));
}
}
}
}
fn is_safe_gate_runner_path(gate_runner: &str, image_dir: &Path) -> bool {
let path = Path::new(gate_runner);
if path.is_absolute() {
return false;
}
for component in path.components() {
match component {
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return false;
}
_ => {}
}
}
let resolved = image_dir.join(path).clean();
let image_clean = image_dir.clean();
resolved.starts_with(&image_clean)
}