Skip to main content

omne_cli/commands/
validate.rs

1//! `omne validate` — check volume integrity against the v2 layout.
2//!
3//! Walks up from cwd to find `.omne/`, runs integrity checks: required
4//! directories, docs baseline, 1-hop boot chain, core manifest, distro
5//! content, pipe schema validation, depth rule scoped to `lib/cfg/`,
6//! and the legacy gate runner.
7
8use std::path::{Component, Path};
9
10use clap::Args as ClapArgs;
11use path_clean::PathClean;
12use walkdir::WalkDir;
13
14use crate::error::CliError;
15use crate::manifest;
16use crate::python;
17use crate::volume;
18
19/// Maximum nesting depth for user-authored content under `lib/cfg/`.
20/// Measured as path components relative to `.omne/` (includes the
21/// `lib/cfg/` prefix). e.g. `lib/cfg/sub1` = depth 3, allowed.
22const MAX_DEPTH: usize = 3;
23
24/// Required top-level directories under `.omne/`.
25const REQUIRED_DIRS: &[&str] = &["core", "dist", "lib", "var"];
26
27/// Required fields in `.omne/omne.md` frontmatter.
28const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
29    "volume",
30    "distro",
31    "distro-version",
32    "created",
33    "kernel-source",
34    "distro-source",
35];
36
37/// Arguments for `omne validate`.
38#[derive(Debug, ClapArgs)]
39pub struct Args {}
40
41pub fn run(_args: &Args) -> Result<(), CliError> {
42    let cwd = std::env::current_dir()
43        .map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
44    validate_at_root(&cwd)
45}
46
47/// Test seam: validate a volume rooted at (or walked up from) `start`.
48pub fn validate_at_root(start: &Path) -> Result<(), CliError> {
49    let root = volume::find_omne_root(start).ok_or(CliError::NotAVolume)?;
50    let omne = root.join(".omne");
51
52    let mut issues = Vec::new();
53    check_required_dirs(&omne, &mut issues);
54    check_core(&omne, &mut issues);
55    check_dist(&omne, &mut issues);
56    check_boot_chain(&root, &mut issues);
57    check_docs_baseline(&omne, &mut issues);
58    check_manifest(&omne, &mut issues);
59    check_depth(&omne, &mut issues);
60    check_gate_runner(&omne, &mut issues);
61    check_pipes(&root, &mut issues);
62
63    for warning in collect_legacy_skill_warnings(&omne) {
64        eprintln!("\x1b[33mwarning:\x1b[0m {warning}");
65    }
66
67    if issues.is_empty() {
68        eprintln!("\x1b[32mVolume is valid.\x1b[0m");
69        Ok(())
70    } else {
71        Err(CliError::ValidationFailed { issues })
72    }
73}
74
75/// Collect warnings for legacy single-file skills that v0.2.1+ no longer
76/// links: any `.md` regular file directly under `.omne/core/skills/` or
77/// `.omne/dist/skills/` (skill entries must now be directories with a
78/// `SKILL.md`, and file-layout commands belong under the sibling
79/// `cmds/` directory).
80fn collect_legacy_skill_warnings(omne: &Path) -> Vec<String> {
81    let mut warnings = Vec::new();
82    for layer in ["core", "dist"] {
83        let skills_dir = omne.join(layer).join("skills");
84        let entries = match std::fs::read_dir(&skills_dir) {
85            Ok(e) => e,
86            Err(_) => continue,
87        };
88        for entry in entries.flatten() {
89            let file_type = match entry.file_type() {
90                Ok(ft) => ft,
91                Err(_) => continue,
92            };
93            if !file_type.is_file() {
94                continue;
95            }
96            let path = entry.path();
97            if path.extension().is_none_or(|ext| ext != "md") {
98                continue;
99            }
100            let name = match path.file_stem().and_then(|s| s.to_str()) {
101                Some(n) => n.to_string(),
102                None => continue,
103            };
104            warnings.push(format!(
105                "single-file skill at {layer}/skills/{name}.md is no longer linked by v0.2.1+; move to {layer}/cmds/{name}.md"
106            ));
107        }
108    }
109    warnings.sort();
110    warnings
111}
112
113/// Walk `.omne/dist/pipes/*.md` and surface every parse / structural
114/// / volume-aware validation issue per pipe. Missing `dist/pipes/`
115/// directory is silently skipped — a fresh volume with no distro
116/// pipes is a valid state.
117fn check_pipes(root: &Path, issues: &mut Vec<String>) {
118    let pipes_dir = volume::dist_dir(root).join("pipes");
119    if !pipes_dir.is_dir() {
120        return;
121    }
122    let entries = match std::fs::read_dir(&pipes_dir) {
123        Ok(e) => e,
124        Err(e) => {
125            issues.push(format!("cannot read {}: {e}", pipes_dir.display()));
126            return;
127        }
128    };
129    for entry in entries.flatten() {
130        let path = entry.path();
131        if path.extension().is_none_or(|ext| ext != "md") {
132            continue;
133        }
134        match crate::pipe::load(&path, root) {
135            Ok(pipe) => {
136                for warning in crate::pipe::collect_warnings(&pipe) {
137                    eprintln!("\x1b[33mwarning:\x1b[0m pipe {}: {warning}", path.display());
138                }
139            }
140            Err(crate::pipe::LoadError::Parse(e)) => {
141                issues.push(format!("pipe {}: {e}", path.display()));
142            }
143            Err(crate::pipe::LoadError::Invalid(errs)) => {
144                for err in errs {
145                    issues.push(format!("pipe {}: {err}", path.display()));
146                }
147            }
148        }
149    }
150}
151
152/// Check that all required top-level directories exist under `.omne/`.
153fn check_required_dirs(omne: &Path, issues: &mut Vec<String>) {
154    for &dir in REQUIRED_DIRS {
155        if !omne.join(dir).is_dir() {
156            issues.push(format!("missing required directory: .omne/{dir}/"));
157        }
158    }
159}
160
161/// Check that `.omne/core/manifest.json` exists.
162fn check_core(omne: &Path, issues: &mut Vec<String>) {
163    let core = omne.join("core");
164    if !core.is_dir() {
165        return;
166    }
167    if !core.join("manifest.json").is_file() {
168        issues.push("missing kernel manifest: core/manifest.json".to_string());
169    }
170}
171
172/// Check that `.omne/dist/AGENTS.md` exists (boot chain target).
173fn check_dist(omne: &Path, issues: &mut Vec<String>) {
174    let dist = omne.join("dist");
175    if !dist.is_dir() {
176        return;
177    }
178    if !dist.join("AGENTS.md").is_file() {
179        issues.push("missing distro entry point: dist/AGENTS.md".to_string());
180    }
181}
182
183/// Check the 1-hop boot chain: volume root `CLAUDE.md` must contain
184/// exactly `@.omne/dist/AGENTS.md`. Detects legacy multi-hop chains
185/// and suggests migration.
186fn check_boot_chain(root: &Path, issues: &mut Vec<String>) {
187    let bootloader = root.join("CLAUDE.md");
188    if !bootloader.is_file() {
189        issues.push("missing CLAUDE.md at volume root".to_string());
190        return;
191    }
192    let content = match std::fs::read_to_string(&bootloader) {
193        Ok(c) => c,
194        Err(e) => {
195            issues.push(format!("cannot read CLAUDE.md: {e}"));
196            return;
197        }
198    };
199
200    let imports: Vec<&str> = content
201        .lines()
202        .map(str::trim)
203        .filter(|l| l.starts_with('@'))
204        .collect();
205
206    if imports.is_empty() {
207        issues.push("CLAUDE.md has no @import — expected @.omne/dist/AGENTS.md".to_string());
208        return;
209    }
210
211    let has_v2_import = imports.contains(&"@.omne/dist/AGENTS.md");
212
213    if !has_v2_import {
214        let is_legacy = imports.iter().any(|&l| {
215            l.contains("MANIFEST.md") || l.contains("SYSTEM.md") || l.contains(".omne/image/")
216        });
217        if is_legacy {
218            issues.push(
219                "legacy boot chain detected — run `omne init` to migrate to 1-hop @.omne/dist/AGENTS.md"
220                    .to_string(),
221            );
222        } else {
223            issues.push(format!(
224                "CLAUDE.md @import does not reference .omne/dist/AGENTS.md — found: {}",
225                imports.join(", ")
226            ));
227        }
228    }
229}
230
231/// Warn (not error) if docs baseline is incomplete.
232fn check_docs_baseline(omne: &Path, issues: &mut Vec<String>) {
233    let docs = omne.join("lib").join("docs");
234    if !docs.is_dir() {
235        return;
236    }
237    if !docs.join("index.md").is_file() {
238        eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/index.md");
239    }
240    for subdir in ["raw", "inter", "wiki"] {
241        if !docs.join(subdir).is_dir() {
242            eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/{subdir}/");
243        }
244    }
245    let _ = issues; // docs baseline is warning-only
246}
247
248/// Check that `.omne/omne.md` exists, has frontmatter, and contains
249/// all required fields.
250fn check_manifest(omne: &Path, issues: &mut Vec<String>) {
251    let readme = omne.join("omne.md");
252    if !readme.is_file() {
253        issues.push("missing .omne/omne.md".to_string());
254        return;
255    }
256
257    let content = match std::fs::read_to_string(&readme) {
258        Ok(c) => c,
259        Err(e) => {
260            issues.push(format!("cannot read .omne/omne.md: {e}"));
261            return;
262        }
263    };
264
265    let yaml_body = match manifest::extract_frontmatter_block(&content) {
266        Ok(body) => body,
267        Err(_) => {
268            issues.push(".omne/omne.md has no YAML frontmatter (---...---)".to_string());
269            return;
270        }
271    };
272
273    for &field in REQUIRED_MANIFEST_FIELDS {
274        let has_field = yaml_body
275            .lines()
276            .any(|line| line.starts_with(field) && line[field.len()..].starts_with(':'));
277        if !has_field {
278            issues.push(format!(".omne/omne.md missing required field: {field}"));
279        }
280    }
281}
282
283/// Check depth of directories under `lib/cfg/` only. MAX_DEPTH = 3.
284/// `core/`, `dist/`, and `lib/docs/` are excluded — release artifacts
285/// and knowledge-base content may have deep nesting.
286fn check_depth(omne: &Path, issues: &mut Vec<String>) {
287    let base = omne.join("lib").join("cfg");
288    if !base.is_dir() {
289        return;
290    }
291    for entry in WalkDir::new(&base).min_depth(1) {
292        let entry = match entry {
293            Ok(e) => e,
294            Err(_) => continue,
295        };
296        if !entry.file_type().is_dir() {
297            continue;
298        }
299        let rel = match entry.path().strip_prefix(omne) {
300            Ok(r) => r,
301            Err(_) => continue,
302        };
303        let depth = rel.components().count();
304        if depth > MAX_DEPTH {
305            issues.push(format!(
306                "depth violation ({depth} > {MAX_DEPTH}): .omne/{}",
307                rel.display()
308            ));
309        }
310    }
311}
312
313/// Read gate_runner from core/manifest.json, validate path safety, and
314/// invoke it with the discovered Python interpreter.
315fn check_gate_runner(omne: &Path, issues: &mut Vec<String>) {
316    let core_manifest = omne.join("core/manifest.json");
317    if !core_manifest.is_file() {
318        return;
319    }
320
321    let content = match std::fs::read_to_string(&core_manifest) {
322        Ok(c) => c,
323        Err(_) => return,
324    };
325
326    let data: serde_json::Value = match serde_json::from_str(&content) {
327        Ok(d) => d,
328        Err(_) => {
329            issues.push("core/manifest.json is invalid JSON".to_string());
330            return;
331        }
332    };
333
334    let gate_runner = match data.get("gate_runner").and_then(|v| v.as_str()) {
335        Some(gr) => gr,
336        None => return,
337    };
338
339    let dist_dir = omne.join("dist");
340    if !is_safe_gate_runner_path(gate_runner, &dist_dir) {
341        issues.push(format!("gate runner path escapes dist/: {gate_runner}"));
342        return;
343    }
344
345    let runner_path = dist_dir.join(gate_runner);
346    if !runner_path.is_file() {
347        eprintln!("\x1b[33mwarning:\x1b[0m gate runner not found: dist/{gate_runner} (skipping)");
348        return;
349    }
350
351    let interpreter = match python::find_interpreter() {
352        Some(interp) => interp,
353        None => {
354            eprintln!("\x1b[33m{}\x1b[0m", python::missing_python_warning());
355            return;
356        }
357    };
358
359    if let Err(e) = python::run_gate_runner(&interpreter, &runner_path, &dist_dir) {
360        match e {
361            python::Error::GateRunnerFailed {
362                exit_code,
363                stdout,
364                stderr,
365            } => {
366                let mut msg = format!("gate runner failed (exit {exit_code}):");
367                for line in stdout.trim().lines() {
368                    msg.push_str(&format!("\n  {line}"));
369                }
370                for line in stderr.trim().lines() {
371                    msg.push_str(&format!("\n  {line}"));
372                }
373                issues.push(msg);
374            }
375            python::Error::GateRunnerTimedOut { elapsed_seconds } => {
376                issues.push(format!(
377                    "gate runner timed out after {elapsed_seconds} seconds"
378                ));
379            }
380            python::Error::InterpreterInvocation(io_err) => {
381                issues.push(format!("failed to invoke gate runner: {io_err}"));
382            }
383        }
384    }
385}
386
387/// Check that a gate runner path is safe (no traversal, no absolute paths).
388fn is_safe_gate_runner_path(gate_runner: &str, base_dir: &Path) -> bool {
389    let path = Path::new(gate_runner);
390
391    if path.is_absolute() {
392        return false;
393    }
394
395    for component in path.components() {
396        match component {
397            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
398                return false;
399            }
400            _ => {}
401        }
402    }
403
404    let resolved = base_dir.join(path).clean();
405    let base_clean = base_dir.clean();
406    resolved.starts_with(&base_clean)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use std::fs;
413    use tempfile::TempDir;
414
415    /// Build a minimal fixture volume rooted at `tmp` with the layout
416    /// needed for `collect_legacy_skill_warnings` — only the
417    /// `core/skills/` and `dist/skills/` directories are created; callers
418    /// populate them per test.
419    fn make_volume(tmp: &Path) {
420        let omne = tmp.join(".omne");
421        fs::create_dir_all(omne.join("core").join("skills")).unwrap();
422        fs::create_dir_all(omne.join("dist").join("skills")).unwrap();
423        fs::create_dir_all(omne.join("core").join("cmds")).unwrap();
424        fs::create_dir_all(omne.join("dist").join("cmds")).unwrap();
425    }
426
427    fn make_dir_skill(tmp: &Path, layer: &str, name: &str) {
428        let dir = tmp.join(".omne").join(layer).join("skills").join(name);
429        fs::create_dir_all(&dir).unwrap();
430        fs::write(
431            dir.join("SKILL.md"),
432            format!("---\nname: {name}\ndescription: test\n---\n"),
433        )
434        .unwrap();
435    }
436
437    fn make_cmd(tmp: &Path, layer: &str, name: &str) {
438        let path = tmp
439            .join(".omne")
440            .join(layer)
441            .join("cmds")
442            .join(format!("{name}.md"));
443        fs::create_dir_all(path.parent().unwrap()).unwrap();
444        fs::write(&path, format!("# {name}\n")).unwrap();
445    }
446
447    fn make_legacy_file_skill(tmp: &Path, layer: &str, name: &str) {
448        let path = tmp
449            .join(".omne")
450            .join(layer)
451            .join("skills")
452            .join(format!("{name}.md"));
453        fs::create_dir_all(path.parent().unwrap()).unwrap();
454        fs::write(&path, format!("# {name}\n")).unwrap();
455    }
456
457    #[test]
458    fn no_warnings_for_valid_cmds_and_dir_skills() {
459        let tmp = TempDir::new().unwrap();
460        make_volume(tmp.path());
461        make_cmd(tmp.path(), "dist", "foo");
462        make_dir_skill(tmp.path(), "dist", "bar");
463
464        let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
465        assert!(
466            warnings.is_empty(),
467            "expected no warnings, got {warnings:?}"
468        );
469    }
470
471    #[test]
472    fn warns_on_legacy_dist_file_skill() {
473        let tmp = TempDir::new().unwrap();
474        make_volume(tmp.path());
475        make_legacy_file_skill(tmp.path(), "dist", "plan");
476
477        let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
478        assert_eq!(warnings.len(), 1, "got {warnings:?}");
479        assert!(
480            warnings[0].contains("dist/skills/plan.md")
481                && warnings[0].contains("dist/cmds/plan.md"),
482            "unexpected warning text: {}",
483            warnings[0]
484        );
485    }
486
487    #[test]
488    fn warns_on_legacy_core_file_skill() {
489        let tmp = TempDir::new().unwrap();
490        make_volume(tmp.path());
491        make_legacy_file_skill(tmp.path(), "core", "plan");
492
493        let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
494        assert_eq!(warnings.len(), 1, "got {warnings:?}");
495        assert!(
496            warnings[0].contains("core/skills/plan.md")
497                && warnings[0].contains("core/cmds/plan.md"),
498            "unexpected warning text: {}",
499            warnings[0]
500        );
501    }
502
503    #[test]
504    fn warns_on_legacy_even_when_cmds_present() {
505        let tmp = TempDir::new().unwrap();
506        make_volume(tmp.path());
507        make_legacy_file_skill(tmp.path(), "dist", "plan");
508        make_cmd(tmp.path(), "dist", "plan");
509
510        let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
511        assert_eq!(warnings.len(), 1, "got {warnings:?}");
512        assert!(
513            warnings[0].contains("dist/skills/plan.md"),
514            "unexpected warning text: {}",
515            warnings[0]
516        );
517    }
518
519    #[test]
520    fn ignores_dir_skills_and_non_md_files() {
521        let tmp = TempDir::new().unwrap();
522        make_volume(tmp.path());
523        make_dir_skill(tmp.path(), "dist", "legit");
524        // Non-.md file directly under skills/ should be ignored.
525        fs::write(
526            tmp.path().join(".omne/dist/skills/README.txt"),
527            "not a skill",
528        )
529        .unwrap();
530
531        let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
532        assert!(
533            warnings.is_empty(),
534            "expected no warnings, got {warnings:?}"
535        );
536    }
537
538    #[test]
539    fn missing_skills_dirs_produce_no_warnings() {
540        let tmp = TempDir::new().unwrap();
541        // Do not call make_volume — no `.omne/` exists.
542        let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
543        assert!(warnings.is_empty());
544    }
545}