Skip to main content

dkp_core/procedures/
mod.rs

1pub mod schema;
2
3#[cfg(feature = "procedures")]
4pub mod executor;
5
6pub mod scaffold;
7
8pub use schema::{EntryPoint, ProcedureDef, ProcedureSchema};
9
10use std::path::Path;
11
12use crate::{error::DkpError, DkpResult, Pack};
13
14/// Enumerate all procedures defined in machine/procedures/.
15///
16/// A procedure is any file with a `.schema.json` suffix. The `.wasm` and `.md`
17/// companions are optional (absence is reported by validate_all, not here).
18pub fn list(pack: &Pack) -> DkpResult<Vec<ProcedureDef>> {
19    let dir = pack.procedures_dir();
20    if !dir.exists() {
21        return Ok(vec![]);
22    }
23
24    let mut defs = Vec::new();
25
26    for entry in std::fs::read_dir(&dir)? {
27        let entry = entry?;
28        let path = entry.path();
29
30        // Only look at .schema.json files
31        let name = path.file_name().unwrap_or_default().to_string_lossy();
32        let Some(id) = name.strip_suffix(".schema.json") else {
33            continue;
34        };
35        let id = id.to_string();
36
37        let schema_path = path.clone();
38        let bytes = std::fs::read(&schema_path)?;
39        let schema: ProcedureSchema =
40            serde_json::from_slice(&bytes).map_err(|e| DkpError::AssetParse {
41                asset: format!("procedures/{name}"),
42                source: e,
43            })?;
44
45        let wasm_path = dir.join(format!("{id}.wasm"));
46        let doc_path = dir.join(format!("{id}.md"));
47
48        let entry_point = schema.entry_point.clone();
49
50        defs.push(ProcedureDef {
51            id,
52            wasm_path: if wasm_path.exists() {
53                Some(wasm_path)
54            } else {
55                None
56            },
57            entry_point,
58            doc_path,
59            schema_path,
60            schema,
61        });
62    }
63
64    defs.sort_by(|a, b| a.id.cmp(&b.id));
65    Ok(defs)
66}
67
68/// Validate all procedures in machine/procedures/ for Gate 4.
69///
70/// Returns a list of human-readable error strings (empty = all pass).
71pub fn validate_all(pack: &Pack) -> DkpResult<Vec<String>> {
72    let dir = pack.procedures_dir();
73    if !dir.exists() {
74        return Ok(vec![]);
75    }
76
77    let mut errors = Vec::new();
78
79    // procedure_capabilities must be declared when procedures/ is non-empty
80    if pack.manifest.procedure_capabilities.is_none() {
81        errors.push(
82            "manifest.json missing 'procedure_capabilities' (required when machine/procedures/ is non-empty)".to_string(),
83        );
84    }
85
86    let defs = list(pack)?;
87
88    if defs.is_empty() {
89        // Directory exists but has no .schema.json files
90        return Ok(errors);
91    }
92
93    for def in &defs {
94        let id = &def.id;
95
96        // .schema.json must be valid JSON Schema (check for "type" or "$schema" key)
97        if let Err(e) = validate_schema_file(&def.schema_path) {
98            errors.push(format!("procedures/{id}.schema.json: {e}"));
99        }
100
101        // .md must exist and be non-empty
102        if !def.doc_path.exists() {
103            errors.push(format!(
104                "procedures/{id}.md: missing (required alongside .schema.json)"
105            ));
106        } else if std::fs::metadata(&def.doc_path)
107            .map(|m| m.len() == 0)
108            .unwrap_or(false)
109        {
110            errors.push(format!("procedures/{id}.md: empty"));
111        }
112
113        // Only WASM-backed procedures are permitted
114        if def.wasm_path.is_none() {
115            if def.entry_point.is_none() {
116                errors.push(format!(
117                    "procedures/{id}: no .wasm binary and no entry_point in .schema.json; \
118                     procedure cannot be executed (see spec ยง9.12)"
119                ));
120            } else {
121                errors.push(format!(
122                    "procedures/{id}: non-WASM procedures are not permitted; \
123                     provide a {id}.wasm binary (entry_point is not accepted)"
124                ));
125            }
126        }
127
128        // If entry_point declared, the referenced filename must exist
129        if let Some(ref ep) = def.entry_point {
130            let alt_path = dir.join(&ep.filename);
131            if !alt_path.exists() {
132                errors.push(format!(
133                    "procedures/{id}.schema.json entry_point.filename '{}' not found in machine/procedures/",
134                    ep.filename
135                ));
136            }
137        }
138    }
139
140    Ok(errors)
141}
142
143fn validate_schema_file(path: &Path) -> Result<(), String> {
144    let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
145    let value: serde_json::Value =
146        serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON: {e}"))?;
147
148    // Must be an object with at minimum 'input' and 'output' keys
149    let obj = value.as_object().ok_or("schema must be a JSON object")?;
150    if !obj.contains_key("input") {
151        return Err("missing required 'input' key".to_string());
152    }
153    if !obj.contains_key("output") {
154        return Err("missing required 'output' key".to_string());
155    }
156
157    // input and output must each be JSON Schema objects
158    for key in ["input", "output"] {
159        if obj[key].as_object().is_none() {
160            return Err(format!("'{key}' must be a JSON object (JSON Schema)"));
161        }
162    }
163
164    Ok(())
165}
166
167/// Return the number of procedures that have a runnable WASM binary.
168pub fn count_with_wasm(pack: &Pack) -> usize {
169    list(pack)
170        .unwrap_or_default()
171        .iter()
172        .filter(|d| d.wasm_path.is_some())
173        .count()
174}
175
176/// Return the total number of procedures (schema files) in the pack.
177pub fn count_total(pack: &Pack) -> usize {
178    list(pack).unwrap_or_default().len()
179}