use std::path::{Component, Path};
use clap::Args as ClapArgs;
use path_clean::PathClean;
use walkdir::WalkDir;
use crate::error::CliError;
use crate::manifest;
use crate::python;
use crate::volume;
const MAX_DEPTH: usize = 3;
const REQUIRED_DIRS: &[&str] = &["core", "dist", "lib", "var"];
const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
"volume",
"distro",
"distro-version",
"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_dist(&omne, &mut issues);
check_boot_chain(&root, &mut issues);
check_docs_baseline(&omne, &mut issues);
check_manifest(&omne, &mut issues);
check_depth(&omne, &mut issues);
check_gate_runner(&omne, &mut issues);
check_pipes(&root, &mut issues);
if issues.is_empty() {
eprintln!("\x1b[32mVolume is valid.\x1b[0m");
Ok(())
} else {
Err(CliError::ValidationFailed { issues })
}
}
fn check_pipes(root: &Path, issues: &mut Vec<String>) {
let pipes_dir = volume::dist_dir(root).join("pipes");
if !pipes_dir.is_dir() {
return;
}
let entries = match std::fs::read_dir(&pipes_dir) {
Ok(e) => e,
Err(e) => {
issues.push(format!("cannot read {}: {e}", pipes_dir.display()));
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "md") {
continue;
}
match crate::pipe::load(&path, root) {
Ok(pipe) => {
for warning in crate::pipe::collect_warnings(&pipe) {
eprintln!("\x1b[33mwarning:\x1b[0m pipe {}: {warning}", path.display());
}
}
Err(crate::pipe::LoadError::Parse(e)) => {
issues.push(format!("pipe {}: {e}", path.display()));
}
Err(crate::pipe::LoadError::Invalid(errs)) => {
for err in errs {
issues.push(format!("pipe {}: {err}", path.display()));
}
}
}
}
}
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_dist(omne: &Path, issues: &mut Vec<String>) {
let dist = omne.join("dist");
if !dist.is_dir() {
return;
}
if !dist.join("AGENTS.md").is_file() {
issues.push("missing distro entry point: dist/AGENTS.md".to_string());
}
}
fn check_boot_chain(root: &Path, issues: &mut Vec<String>) {
let bootloader = root.join("CLAUDE.md");
if !bootloader.is_file() {
issues.push("missing CLAUDE.md at volume root".to_string());
return;
}
let content = match std::fs::read_to_string(&bootloader) {
Ok(c) => c,
Err(e) => {
issues.push(format!("cannot read CLAUDE.md: {e}"));
return;
}
};
let imports: Vec<&str> = content
.lines()
.map(str::trim)
.filter(|l| l.starts_with('@'))
.collect();
if imports.is_empty() {
issues.push("CLAUDE.md has no @import — expected @.omne/dist/AGENTS.md".to_string());
return;
}
let has_v2_import = imports.contains(&"@.omne/dist/AGENTS.md");
if !has_v2_import {
let is_legacy = imports.iter().any(|&l| {
l.contains("MANIFEST.md") || l.contains("SYSTEM.md") || l.contains(".omne/image/")
});
if is_legacy {
issues.push(
"legacy boot chain detected — run `omne init` to migrate to 1-hop @.omne/dist/AGENTS.md"
.to_string(),
);
} else {
issues.push(format!(
"CLAUDE.md @import does not reference .omne/dist/AGENTS.md — found: {}",
imports.join(", ")
));
}
}
}
fn check_docs_baseline(omne: &Path, issues: &mut Vec<String>) {
let docs = omne.join("lib").join("docs");
if !docs.is_dir() {
return;
}
if !docs.join("index.md").is_file() {
eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/index.md");
}
for subdir in ["raw", "inter", "wiki"] {
if !docs.join(subdir).is_dir() {
eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/{subdir}/");
}
}
let _ = issues; }
fn check_manifest(omne: &Path, issues: &mut Vec<String>) {
let readme = omne.join("omne.md");
if !readme.is_file() {
issues.push("missing .omne/omne.md".to_string());
return;
}
let content = match std::fs::read_to_string(&readme) {
Ok(c) => c,
Err(e) => {
issues.push(format!("cannot read .omne/omne.md: {e}"));
return;
}
};
let yaml_body = match manifest::extract_frontmatter_block(&content) {
Ok(body) => body,
Err(_) => {
issues.push(".omne/omne.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!(".omne/omne.md missing required field: {field}"));
}
}
}
fn check_depth(omne: &Path, issues: &mut Vec<String>) {
let base = omne.join("lib").join("cfg");
if !base.is_dir() {
return;
}
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 dist_dir = omne.join("dist");
if !is_safe_gate_runner_path(gate_runner, &dist_dir) {
issues.push(format!("gate runner path escapes dist/: {gate_runner}"));
return;
}
let runner_path = dist_dir.join(gate_runner);
if !runner_path.is_file() {
eprintln!("\x1b[33mwarning:\x1b[0m gate runner not found: dist/{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, &dist_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, base_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 = base_dir.join(path).clean();
let base_clean = base_dir.clean();
resolved.starts_with(&base_clean)
}