omne_cli/commands/
validate.rs1use 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
19const MAX_DEPTH: usize = 3;
23
24const REQUIRED_DIRS: &[&str] = &["core", "dist", "lib", "var"];
26
27const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
29 "volume",
30 "distro",
31 "distro-version",
32 "created",
33 "kernel-source",
34 "distro-source",
35];
36
37#[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
47pub 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 if issues.is_empty() {
64 eprintln!("\x1b[32mVolume is valid.\x1b[0m");
65 Ok(())
66 } else {
67 Err(CliError::ValidationFailed { issues })
68 }
69}
70
71fn check_pipes(root: &Path, issues: &mut Vec<String>) {
76 let pipes_dir = volume::dist_dir(root).join("pipes");
77 if !pipes_dir.is_dir() {
78 return;
79 }
80 let entries = match std::fs::read_dir(&pipes_dir) {
81 Ok(e) => e,
82 Err(e) => {
83 issues.push(format!("cannot read {}: {e}", pipes_dir.display()));
84 return;
85 }
86 };
87 for entry in entries.flatten() {
88 let path = entry.path();
89 if path.extension().is_none_or(|ext| ext != "md") {
90 continue;
91 }
92 match crate::pipe::load(&path, root) {
93 Ok(pipe) => {
94 for warning in crate::pipe::collect_warnings(&pipe) {
95 eprintln!("\x1b[33mwarning:\x1b[0m pipe {}: {warning}", path.display());
96 }
97 }
98 Err(crate::pipe::LoadError::Parse(e)) => {
99 issues.push(format!("pipe {}: {e}", path.display()));
100 }
101 Err(crate::pipe::LoadError::Invalid(errs)) => {
102 for err in errs {
103 issues.push(format!("pipe {}: {err}", path.display()));
104 }
105 }
106 }
107 }
108}
109
110fn check_required_dirs(omne: &Path, issues: &mut Vec<String>) {
112 for &dir in REQUIRED_DIRS {
113 if !omne.join(dir).is_dir() {
114 issues.push(format!("missing required directory: .omne/{dir}/"));
115 }
116 }
117}
118
119fn check_core(omne: &Path, issues: &mut Vec<String>) {
121 let core = omne.join("core");
122 if !core.is_dir() {
123 return;
124 }
125 if !core.join("manifest.json").is_file() {
126 issues.push("missing kernel manifest: core/manifest.json".to_string());
127 }
128}
129
130fn check_dist(omne: &Path, issues: &mut Vec<String>) {
132 let dist = omne.join("dist");
133 if !dist.is_dir() {
134 return;
135 }
136 if !dist.join("AGENTS.md").is_file() {
137 issues.push("missing distro entry point: dist/AGENTS.md".to_string());
138 }
139}
140
141fn check_boot_chain(root: &Path, issues: &mut Vec<String>) {
145 let bootloader = root.join("CLAUDE.md");
146 if !bootloader.is_file() {
147 issues.push("missing CLAUDE.md at volume root".to_string());
148 return;
149 }
150 let content = match std::fs::read_to_string(&bootloader) {
151 Ok(c) => c,
152 Err(e) => {
153 issues.push(format!("cannot read CLAUDE.md: {e}"));
154 return;
155 }
156 };
157
158 let imports: Vec<&str> = content
159 .lines()
160 .map(str::trim)
161 .filter(|l| l.starts_with('@'))
162 .collect();
163
164 if imports.is_empty() {
165 issues.push("CLAUDE.md has no @import — expected @.omne/dist/AGENTS.md".to_string());
166 return;
167 }
168
169 let has_v2_import = imports.contains(&"@.omne/dist/AGENTS.md");
170
171 if !has_v2_import {
172 let is_legacy = imports.iter().any(|&l| {
173 l.contains("MANIFEST.md") || l.contains("SYSTEM.md") || l.contains(".omne/image/")
174 });
175 if is_legacy {
176 issues.push(
177 "legacy boot chain detected — run `omne init` to migrate to 1-hop @.omne/dist/AGENTS.md"
178 .to_string(),
179 );
180 } else {
181 issues.push(format!(
182 "CLAUDE.md @import does not reference .omne/dist/AGENTS.md — found: {}",
183 imports.join(", ")
184 ));
185 }
186 }
187}
188
189fn check_docs_baseline(omne: &Path, issues: &mut Vec<String>) {
191 let docs = omne.join("lib").join("docs");
192 if !docs.is_dir() {
193 return;
194 }
195 if !docs.join("index.md").is_file() {
196 eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/index.md");
197 }
198 for subdir in ["raw", "inter", "wiki"] {
199 if !docs.join(subdir).is_dir() {
200 eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/{subdir}/");
201 }
202 }
203 let _ = issues; }
205
206fn check_manifest(omne: &Path, issues: &mut Vec<String>) {
209 let readme = omne.join("omne.md");
210 if !readme.is_file() {
211 issues.push("missing .omne/omne.md".to_string());
212 return;
213 }
214
215 let content = match std::fs::read_to_string(&readme) {
216 Ok(c) => c,
217 Err(e) => {
218 issues.push(format!("cannot read .omne/omne.md: {e}"));
219 return;
220 }
221 };
222
223 let yaml_body = match manifest::extract_frontmatter_block(&content) {
224 Ok(body) => body,
225 Err(_) => {
226 issues.push(".omne/omne.md has no YAML frontmatter (---...---)".to_string());
227 return;
228 }
229 };
230
231 for &field in REQUIRED_MANIFEST_FIELDS {
232 let has_field = yaml_body
233 .lines()
234 .any(|line| line.starts_with(field) && line[field.len()..].starts_with(':'));
235 if !has_field {
236 issues.push(format!(".omne/omne.md missing required field: {field}"));
237 }
238 }
239}
240
241fn check_depth(omne: &Path, issues: &mut Vec<String>) {
245 let base = omne.join("lib").join("cfg");
246 if !base.is_dir() {
247 return;
248 }
249 for entry in WalkDir::new(&base).min_depth(1) {
250 let entry = match entry {
251 Ok(e) => e,
252 Err(_) => continue,
253 };
254 if !entry.file_type().is_dir() {
255 continue;
256 }
257 let rel = match entry.path().strip_prefix(omne) {
258 Ok(r) => r,
259 Err(_) => continue,
260 };
261 let depth = rel.components().count();
262 if depth > MAX_DEPTH {
263 issues.push(format!(
264 "depth violation ({depth} > {MAX_DEPTH}): .omne/{}",
265 rel.display()
266 ));
267 }
268 }
269}
270
271fn check_gate_runner(omne: &Path, issues: &mut Vec<String>) {
274 let core_manifest = omne.join("core/manifest.json");
275 if !core_manifest.is_file() {
276 return;
277 }
278
279 let content = match std::fs::read_to_string(&core_manifest) {
280 Ok(c) => c,
281 Err(_) => return,
282 };
283
284 let data: serde_json::Value = match serde_json::from_str(&content) {
285 Ok(d) => d,
286 Err(_) => {
287 issues.push("core/manifest.json is invalid JSON".to_string());
288 return;
289 }
290 };
291
292 let gate_runner = match data.get("gate_runner").and_then(|v| v.as_str()) {
293 Some(gr) => gr,
294 None => return,
295 };
296
297 let dist_dir = omne.join("dist");
298 if !is_safe_gate_runner_path(gate_runner, &dist_dir) {
299 issues.push(format!("gate runner path escapes dist/: {gate_runner}"));
300 return;
301 }
302
303 let runner_path = dist_dir.join(gate_runner);
304 if !runner_path.is_file() {
305 eprintln!("\x1b[33mwarning:\x1b[0m gate runner not found: dist/{gate_runner} (skipping)");
306 return;
307 }
308
309 let interpreter = match python::find_interpreter() {
310 Some(interp) => interp,
311 None => {
312 eprintln!("\x1b[33m{}\x1b[0m", python::missing_python_warning());
313 return;
314 }
315 };
316
317 if let Err(e) = python::run_gate_runner(&interpreter, &runner_path, &dist_dir) {
318 match e {
319 python::Error::GateRunnerFailed {
320 exit_code,
321 stdout,
322 stderr,
323 } => {
324 let mut msg = format!("gate runner failed (exit {exit_code}):");
325 for line in stdout.trim().lines() {
326 msg.push_str(&format!("\n {line}"));
327 }
328 for line in stderr.trim().lines() {
329 msg.push_str(&format!("\n {line}"));
330 }
331 issues.push(msg);
332 }
333 python::Error::GateRunnerTimedOut { elapsed_seconds } => {
334 issues.push(format!(
335 "gate runner timed out after {elapsed_seconds} seconds"
336 ));
337 }
338 python::Error::InterpreterInvocation(io_err) => {
339 issues.push(format!("failed to invoke gate runner: {io_err}"));
340 }
341 }
342 }
343}
344
345fn is_safe_gate_runner_path(gate_runner: &str, base_dir: &Path) -> bool {
347 let path = Path::new(gate_runner);
348
349 if path.is_absolute() {
350 return false;
351 }
352
353 for component in path.components() {
354 match component {
355 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
356 return false;
357 }
358 _ => {}
359 }
360 }
361
362 let resolved = base_dir.join(path).clean();
363 let base_clean = base_dir.clean();
364 resolved.starts_with(&base_clean)
365}