omne-cli 0.2.0

CLI for managing omne volumes: init, upgrade, and validate kernel and distro releases
Documentation
//! `omne validate` — check volume integrity against the v2 layout.
//!
//! Walks up from cwd to find `.omne/`, runs integrity checks: required
//! directories, docs baseline, 1-hop boot chain, core manifest, distro
//! content, pipe schema validation, depth rule scoped to `lib/cfg/`,
//! and the legacy gate runner.

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;

/// Maximum nesting depth for user-authored content under `lib/cfg/`.
/// Measured as path components relative to `.omne/` (includes the
/// `lib/cfg/` prefix). e.g. `lib/cfg/sub1` = depth 3, allowed.
const MAX_DEPTH: usize = 3;

/// Required top-level directories under `.omne/`.
const REQUIRED_DIRS: &[&str] = &["core", "dist", "lib", "var"];

/// Required fields in `.omne/omne.md` frontmatter.
const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
    "volume",
    "distro",
    "distro-version",
    "created",
    "kernel-source",
    "distro-source",
];

/// Arguments for `omne validate`.
#[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)
}

/// Test seam: validate a volume rooted at (or walked up from) `start`.
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 })
    }
}

/// Walk `.omne/dist/pipes/*.md` and surface every parse / structural
/// / volume-aware validation issue per pipe. Missing `dist/pipes/`
/// directory is silently skipped — a fresh volume with no distro
/// pipes is a valid state.
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()));
                }
            }
        }
    }
}

/// Check that all required top-level directories exist under `.omne/`.
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}/"));
        }
    }
}

/// Check that `.omne/core/manifest.json` exists.
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());
    }
}

/// Check that `.omne/dist/AGENTS.md` exists (boot chain target).
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());
    }
}

/// Check the 1-hop boot chain: volume root `CLAUDE.md` must contain
/// exactly `@.omne/dist/AGENTS.md`. Detects legacy multi-hop chains
/// and suggests migration.
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(", ")
            ));
        }
    }
}

/// Warn (not error) if docs baseline is incomplete.
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; // docs baseline is warning-only
}

/// Check that `.omne/omne.md` exists, has frontmatter, and contains
/// all required fields.
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}"));
        }
    }
}

/// Check depth of directories under `lib/cfg/` only. MAX_DEPTH = 3.
/// `core/`, `dist/`, and `lib/docs/` are excluded — release artifacts
/// and knowledge-base content may have deep nesting.
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()
            ));
        }
    }
}

/// Read gate_runner from core/manifest.json, validate path safety, and
/// invoke it with the discovered Python interpreter.
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}"));
            }
        }
    }
}

/// Check that a gate runner path is safe (no traversal, no absolute paths).
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)
}