1use logicpearl_core::{resolve_manifest_path, LogicPearlError, Result};
26use logicpearl_ir::{LogicPearlActionIr, LogicPearlGateIr};
27use logicpearl_pipeline::{PipelineDefinition, PipelineExecution, PreparedPipeline};
28use logicpearl_plugin::PluginExecutionPolicy;
29use logicpearl_runtime::{
30 evaluate_action_policy, evaluate_gate_with_explanation, parse_input_payload,
31 ActionEvaluationResult, GateEvaluationResult,
32};
33use serde::{Deserialize, Serialize};
34use serde_json::Value;
35use std::fs;
36use std::path::{Path, PathBuf};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum EngineKind {
41 Artifact,
42 ActionArtifact,
43 Pipeline,
44}
45
46pub type ArtifactEvaluation = GateEvaluationResult;
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct ArtifactExecution {
50 pub gate_id: String,
51 pub evaluation: ArtifactEvaluation,
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct ArtifactBatchExecution {
56 pub gate_id: String,
57 pub evaluations: Vec<ArtifactEvaluation>,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ActionArtifactExecution {
62 pub action_policy_id: String,
63 pub evaluation: ActionEvaluationResult,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct ActionArtifactBatchExecution {
68 pub action_policy_id: String,
69 pub evaluations: Vec<ActionEvaluationResult>,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(tag = "kind", rename_all = "snake_case")]
74pub enum EngineSingleExecution {
75 Artifact(ArtifactExecution),
76 ActionArtifact(ActionArtifactExecution),
77 Pipeline(PipelineExecution),
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "snake_case")]
82pub enum EngineBatchExecution {
83 Artifact(ArtifactBatchExecution),
84 ActionArtifact(ActionArtifactBatchExecution),
85 Pipeline(Vec<PipelineExecution>),
86}
87
88#[derive(Debug, Clone)]
89pub struct LogicPearlEngine {
90 kind: EngineKind,
91 source_path: PathBuf,
92 prepared: PreparedExecution,
93}
94
95#[derive(Debug, Clone)]
96enum PreparedExecution {
97 Artifact(PreparedArtifact),
98 ActionArtifact(PreparedActionArtifact),
99 Pipeline(PreparedPipeline),
100}
101
102#[derive(Debug, Clone)]
103struct PreparedArtifact {
104 gate: LogicPearlGateIr,
105}
106
107#[derive(Debug, Clone)]
108struct PreparedActionArtifact {
109 policy: LogicPearlActionIr,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113struct NamedArtifactManifest {
114 files: NamedArtifactFiles,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118struct NamedArtifactFiles {
119 #[serde(alias = "ir")]
120 pearl_ir: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124struct ActionArtifactManifest {
125 artifact_kind: String,
126 files: ActionArtifactFiles,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130struct ActionArtifactFiles {
131 #[serde(alias = "ir")]
132 pearl_ir: String,
133}
134
135impl LogicPearlEngine {
136 pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
137 let path = path.as_ref();
138 if looks_like_pipeline_path(path) {
139 return Self::from_pipeline_path(path);
140 }
141 if let Some(pipeline_path) = resolve_pipeline_manifest_input(path)? {
142 return Self::from_pipeline_path(pipeline_path);
143 }
144 if looks_like_artifact_path(path) {
145 return Self::from_artifact_path(path);
146 }
147
148 if path.is_file() {
149 let content = fs::read_to_string(path)?;
150 let value: Value = serde_json::from_str(&content)?;
151 if value.get("pipeline_version").is_some() {
152 return Self::from_pipeline_path(path);
153 }
154 if value.get("ir_version").is_some() || value.get("files").is_some() {
155 return Self::from_artifact_path(path);
156 }
157 }
158
159 if path.is_dir() {
160 if path.join("pipeline.json").exists() {
161 return Self::from_pipeline_path(path.join("pipeline.json"));
162 }
163 if path.join("artifact.json").exists() || path.join("pearl.ir.json").exists() {
164 return Self::from_artifact_path(path);
165 }
166 }
167
168 Err(LogicPearlError::message(format!(
169 "could not determine whether {} is a LogicPearl artifact or pipeline",
170 path.display()
171 )))
172 }
173
174 pub fn from_artifact_path(path: impl AsRef<Path>) -> Result<Self> {
175 let path = path.as_ref();
176 if let Some(action_policy_ir) = resolve_action_artifact_input(path)? {
177 let policy = LogicPearlActionIr::from_path(&action_policy_ir)?;
178 return Ok(Self {
179 kind: EngineKind::ActionArtifact,
180 source_path: path.to_path_buf(),
181 prepared: PreparedExecution::ActionArtifact(PreparedActionArtifact { policy }),
182 });
183 }
184 let resolved = resolve_artifact_input(path)?;
185 let gate = LogicPearlGateIr::from_path(&resolved.pearl_ir)?;
186 Ok(Self {
187 kind: EngineKind::Artifact,
188 source_path: path.to_path_buf(),
189 prepared: PreparedExecution::Artifact(PreparedArtifact { gate }),
190 })
191 }
192
193 pub fn from_pipeline_path(path: impl AsRef<Path>) -> Result<Self> {
194 Self::from_pipeline_path_with_plugin_policy(path, PluginExecutionPolicy::default())
195 }
196
197 pub fn from_path_with_plugin_policy(
198 path: impl AsRef<Path>,
199 plugin_policy: PluginExecutionPolicy,
200 ) -> Result<Self> {
201 let path = path.as_ref();
202 if looks_like_pipeline_path(path) {
203 return Self::from_pipeline_path_with_plugin_policy(path, plugin_policy);
204 }
205 if let Some(pipeline_path) = resolve_pipeline_manifest_input(path)? {
206 return Self::from_pipeline_path_with_plugin_policy(pipeline_path, plugin_policy);
207 }
208 if looks_like_artifact_path(path) {
209 return Self::from_artifact_path(path);
210 }
211
212 if path.is_file() {
213 let content = fs::read_to_string(path)?;
214 let value: Value = serde_json::from_str(&content)?;
215 if value.get("pipeline_version").is_some() {
216 return Self::from_pipeline_path_with_plugin_policy(path, plugin_policy);
217 }
218 if value.get("ir_version").is_some() || value.get("files").is_some() {
219 return Self::from_artifact_path(path);
220 }
221 }
222
223 if path.is_dir() {
224 if path.join("pipeline.json").exists() {
225 return Self::from_pipeline_path_with_plugin_policy(
226 path.join("pipeline.json"),
227 plugin_policy,
228 );
229 }
230 if path.join("artifact.json").exists() || path.join("pearl.ir.json").exists() {
231 return Self::from_artifact_path(path);
232 }
233 }
234
235 Err(LogicPearlError::message(format!(
236 "could not determine whether {} is a LogicPearl artifact or pipeline",
237 path.display()
238 )))
239 }
240
241 pub fn from_pipeline_path_with_plugin_policy(
242 path: impl AsRef<Path>,
243 plugin_policy: PluginExecutionPolicy,
244 ) -> Result<Self> {
245 let path = path.as_ref();
246 let resolved_path = if path.is_dir() {
247 path.join("pipeline.json")
248 } else {
249 path.to_path_buf()
250 };
251 let pipeline = PipelineDefinition::from_path(&resolved_path)?;
252 let base_dir = resolved_path.parent().unwrap_or_else(|| Path::new("."));
253 let prepared = pipeline.prepare_with_plugin_policy(base_dir, plugin_policy)?;
254 Ok(Self {
255 kind: EngineKind::Pipeline,
256 source_path: resolved_path,
257 prepared: PreparedExecution::Pipeline(prepared),
258 })
259 }
260
261 pub fn kind(&self) -> EngineKind {
262 self.kind
263 }
264
265 pub fn source_path(&self) -> &Path {
266 &self.source_path
267 }
268
269 pub fn run_single_json(&self, input: &Value) -> Result<EngineSingleExecution> {
270 match &self.prepared {
271 PreparedExecution::Artifact(artifact) => {
272 Ok(EngineSingleExecution::Artifact(ArtifactExecution {
273 gate_id: artifact.gate.gate_id.clone(),
274 evaluation: evaluate_artifact_single(&artifact.gate, input)?,
275 }))
276 }
277 PreparedExecution::ActionArtifact(artifact) => Ok(
278 EngineSingleExecution::ActionArtifact(ActionArtifactExecution {
279 action_policy_id: artifact.policy.action_policy_id.clone(),
280 evaluation: evaluate_action_artifact_single(&artifact.policy, input)?,
281 }),
282 ),
283 PreparedExecution::Pipeline(pipeline) => {
284 Ok(EngineSingleExecution::Pipeline(pipeline.run(input)?))
285 }
286 }
287 }
288
289 pub fn run_batch_json(&self, inputs: &[Value]) -> Result<EngineBatchExecution> {
290 match &self.prepared {
291 PreparedExecution::Artifact(artifact) => {
292 Ok(EngineBatchExecution::Artifact(ArtifactBatchExecution {
293 gate_id: artifact.gate.gate_id.clone(),
294 evaluations: inputs
295 .iter()
296 .map(|input| evaluate_artifact_single(&artifact.gate, input))
297 .collect::<Result<Vec<_>>>()?,
298 }))
299 }
300 PreparedExecution::ActionArtifact(artifact) => Ok(
301 EngineBatchExecution::ActionArtifact(ActionArtifactBatchExecution {
302 action_policy_id: artifact.policy.action_policy_id.clone(),
303 evaluations: inputs
304 .iter()
305 .map(|input| evaluate_action_artifact_single(&artifact.policy, input))
306 .collect::<Result<Vec<_>>>()?,
307 }),
308 ),
309 PreparedExecution::Pipeline(pipeline) => {
310 Ok(EngineBatchExecution::Pipeline(pipeline.run_batch(inputs)?))
311 }
312 }
313 }
314
315 pub fn run_json_value(&self, input: &Value) -> Result<EngineExecutionEnvelope> {
316 match input {
317 Value::Array(items) => self
318 .run_batch_json(items)
319 .map(EngineExecutionEnvelope::Batch),
320 _ => self
321 .run_single_json(input)
322 .map(|execution| EngineExecutionEnvelope::Single(Box::new(execution))),
323 }
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328#[serde(tag = "mode", rename_all = "snake_case")]
329pub enum EngineExecutionEnvelope {
330 Single(Box<EngineSingleExecution>),
331 Batch(EngineBatchExecution),
332}
333
334fn evaluate_artifact_single(gate: &LogicPearlGateIr, input: &Value) -> Result<ArtifactEvaluation> {
335 let parsed = parse_input_payload(input.clone())?;
336 if parsed.len() != 1 {
337 return Err(LogicPearlError::message(
338 "artifact single execution expects one feature object",
339 ));
340 }
341 evaluate_gate_with_explanation(gate, &parsed[0])
342}
343
344fn evaluate_action_artifact_single(
345 policy: &LogicPearlActionIr,
346 input: &Value,
347) -> Result<ActionEvaluationResult> {
348 let parsed = parse_input_payload(input.clone())?;
349 if parsed.len() != 1 {
350 return Err(LogicPearlError::message(
351 "action artifact single execution expects one feature object",
352 ));
353 }
354 evaluate_action_policy(policy, &parsed[0])
355}
356
357#[derive(Debug, Clone)]
358struct ResolvedArtifactInput {
359 pearl_ir: PathBuf,
360}
361
362fn resolve_action_artifact_input(path: &Path) -> Result<Option<PathBuf>> {
363 if path.is_dir() {
364 let manifest_path = path.join("artifact.json");
365 if manifest_path.exists() {
366 return resolve_action_manifest_input(&manifest_path);
367 }
368 let pearl_ir = path.join("pearl.ir.json");
369 if pearl_ir.exists() && is_action_policy_ir(&pearl_ir)? {
370 return Ok(Some(pearl_ir));
371 }
372 return Ok(None);
373 }
374
375 if path
376 .file_name()
377 .is_some_and(|name| name == std::ffi::OsStr::new("artifact.json"))
378 {
379 return resolve_action_manifest_input(path);
380 }
381
382 if path.is_file() && is_action_policy_ir(path)? {
383 return Ok(Some(path.to_path_buf()));
384 }
385
386 Ok(None)
387}
388
389fn resolve_pipeline_manifest_input(path: &Path) -> Result<Option<PathBuf>> {
390 let manifest_path = if path.is_dir() {
391 let candidate = path.join("artifact.json");
392 if candidate.exists() {
393 candidate
394 } else {
395 return Ok(None);
396 }
397 } else if path
398 .file_name()
399 .is_some_and(|name| name == std::ffi::OsStr::new("artifact.json"))
400 {
401 path.to_path_buf()
402 } else {
403 return Ok(None);
404 };
405 let content = fs::read_to_string(&manifest_path)?;
406 let value: Value = serde_json::from_str(&content)?;
407 if value.get("artifact_kind").and_then(Value::as_str) != Some("pipeline") {
408 return Ok(None);
409 }
410 let ir = value
411 .get("files")
412 .and_then(|files| files.get("ir").or_else(|| files.get("pearl_ir")))
413 .and_then(Value::as_str)
414 .ok_or_else(|| {
415 LogicPearlError::message("pipeline artifact manifest is missing files.ir")
416 })?;
417 Ok(Some(resolve_manifest_path(&manifest_path, ir)?))
418}
419
420fn resolve_action_manifest_input(manifest_path: &Path) -> Result<Option<PathBuf>> {
421 let content = fs::read_to_string(manifest_path)?;
422 let value: Value = serde_json::from_str(&content)?;
423 if !matches!(
424 value.get("artifact_kind").and_then(Value::as_str),
425 Some("action") | Some("action_policy")
426 ) {
427 return Ok(None);
428 }
429 let manifest: ActionArtifactManifest = serde_json::from_value(value)?;
430 Ok(Some(resolve_manifest_path(
431 manifest_path,
432 &manifest.files.pearl_ir,
433 )?))
434}
435
436fn is_action_policy_ir(path: &Path) -> Result<bool> {
437 let content = fs::read_to_string(path)?;
438 let value: Value = serde_json::from_str(&content)?;
439 Ok(value.get("action_policy_id").is_some())
440}
441
442fn resolve_artifact_input(path: &Path) -> Result<ResolvedArtifactInput> {
443 if path.is_dir() {
444 let manifest_path = path.join("artifact.json");
445 if manifest_path.exists() {
446 let manifest = load_named_artifact_manifest(&manifest_path)?;
447 return Ok(ResolvedArtifactInput {
448 pearl_ir: resolve_manifest_path(&manifest_path, &manifest.files.pearl_ir)?,
449 });
450 }
451 let pearl_ir = path.join("pearl.ir.json");
452 if pearl_ir.exists() {
453 return Ok(ResolvedArtifactInput { pearl_ir });
454 }
455 return Err(LogicPearlError::message(format!(
456 "artifact directory {} is missing artifact.json and pearl.ir.json",
457 path.display()
458 )));
459 }
460
461 if path
462 .file_name()
463 .is_some_and(|name| name == std::ffi::OsStr::new("artifact.json"))
464 {
465 let manifest = load_named_artifact_manifest(path)?;
466 return Ok(ResolvedArtifactInput {
467 pearl_ir: resolve_manifest_path(path, &manifest.files.pearl_ir)?,
468 });
469 }
470
471 Ok(ResolvedArtifactInput {
472 pearl_ir: path.to_path_buf(),
473 })
474}
475
476fn load_named_artifact_manifest(path: &Path) -> Result<NamedArtifactManifest> {
477 let content = fs::read_to_string(path)?;
478 let manifest = serde_json::from_str(&content)?;
479 Ok(manifest)
480}
481
482fn looks_like_pipeline_path(path: &Path) -> bool {
483 path.file_name()
484 .and_then(|value| value.to_str())
485 .is_some_and(|name| name.ends_with(".pipeline.json") || name == "pipeline.json")
486}
487
488fn looks_like_artifact_path(path: &Path) -> bool {
489 if path.is_dir() {
490 return path.join("artifact.json").exists() || path.join("pearl.ir.json").exists();
491 }
492 path.file_name()
493 .and_then(|value| value.to_str())
494 .is_some_and(|name| {
495 name == "artifact.json" || name == "pearl.ir.json" || name.ends_with(".ir.json")
496 })
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use serde_json::json;
503 use tempfile::tempdir;
504
505 fn repo_root() -> PathBuf {
506 Path::new(env!("CARGO_MANIFEST_DIR"))
507 .parent()
508 .and_then(|path| path.parent())
509 .expect("crate should live under workspace/crates/logicpearl-engine")
510 .to_path_buf()
511 }
512
513 #[test]
514 fn loads_and_runs_artifact_from_direct_ir_path() {
515 let repo_root = repo_root();
516 let artifact = repo_root.join("fixtures/ir/valid/auth-demo-v1.json");
517 let engine = LogicPearlEngine::from_artifact_path(&artifact).expect("artifact loads");
518 let result = engine
519 .run_single_json(&json!({
520 "action": "delete",
521 "resource_archived": true,
522 "user_role": "viewer",
523 "failed_attempts": 99
524 }))
525 .expect("artifact runs");
526 match result {
527 EngineSingleExecution::Artifact(output) => {
528 assert_eq!(output.gate_id, "auth_demo_v1");
529 assert!(!output.evaluation.allow);
530 assert_eq!(output.evaluation.bitmask.as_u64(), Some(7));
531 }
532 _ => panic!("expected artifact result"),
533 }
534 }
535
536 #[test]
537 fn loads_artifact_from_manifest() {
538 let repo_root = repo_root();
539 let dir = tempdir().expect("tempdir should exist");
540 let ir_path = repo_root.join("fixtures/ir/valid/auth-demo-v1.json");
541 fs::copy(&ir_path, dir.path().join("pearl.ir.json")).expect("fixture should copy");
542 fs::write(
543 dir.path().join("artifact.json"),
544 serde_json::to_string_pretty(&json!({
545 "artifact_version": "1.0",
546 "artifact_name": "auth-demo",
547 "gate_id": "auth_demo_v1",
548 "files": {
549 "pearl_ir": "pearl.ir.json"
550 }
551 }))
552 .expect("manifest encodes"),
553 )
554 .expect("manifest writes");
555
556 let engine =
557 LogicPearlEngine::from_path(dir.path()).expect("manifest-backed artifact loads");
558 assert_eq!(engine.kind(), EngineKind::Artifact);
559 }
560
561 #[test]
562 fn loads_manifest_with_redundant_artifact_dir_prefix() {
563 let repo_root = repo_root();
564 let dir = tempdir().expect("tempdir should exist");
565 let artifact_dir = dir.path().join("gate");
566 fs::create_dir_all(&artifact_dir).expect("artifact dir should exist");
567 let ir_path = repo_root.join("fixtures/ir/valid/auth-demo-v1.json");
568 fs::copy(&ir_path, artifact_dir.join("pearl.ir.json")).expect("fixture should copy");
569 fs::write(
570 artifact_dir.join("artifact.json"),
571 serde_json::to_string_pretty(&json!({
572 "artifact_version": "1.0",
573 "artifact_name": "auth-demo",
574 "gate_id": "auth_demo_v1",
575 "files": {
576 "pearl_ir": "gate/pearl.ir.json"
577 }
578 }))
579 .expect("manifest encodes"),
580 )
581 .expect("manifest writes");
582
583 let engine =
584 LogicPearlEngine::from_path(&artifact_dir).expect("prefixed manifest path loads");
585 assert_eq!(engine.kind(), EngineKind::Artifact);
586 }
587
588 #[test]
589 fn rejects_manifest_members_that_escape_artifact_dir() {
590 let repo_root = repo_root();
591 let dir = tempdir().expect("tempdir should exist");
592 let artifact_dir = dir.path().join("gate");
593 fs::create_dir_all(&artifact_dir).expect("artifact dir should exist");
594 let outside = dir.path().join("outside.ir.json");
595 fs::copy(
596 repo_root.join("fixtures/ir/valid/auth-demo-v1.json"),
597 &outside,
598 )
599 .expect("fixture should copy");
600 fs::write(
601 artifact_dir.join("artifact.json"),
602 serde_json::to_string_pretty(&json!({
603 "artifact_version": "1.0",
604 "artifact_name": "auth-demo",
605 "gate_id": "auth_demo_v1",
606 "files": {
607 "pearl_ir": outside.display().to_string()
608 }
609 }))
610 .expect("manifest encodes"),
611 )
612 .expect("manifest writes");
613
614 let err = LogicPearlEngine::from_path(&artifact_dir)
615 .expect_err("absolute manifest member should fail")
616 .to_string();
617 assert!(err.contains("must be relative"));
618 }
619
620 #[cfg(unix)]
621 #[test]
622 fn rejects_manifest_member_symlinks_that_escape_artifact_dir() {
623 let repo_root = repo_root();
624 let dir = tempdir().expect("tempdir should exist");
625 let artifact_dir = dir.path().join("gate");
626 fs::create_dir_all(&artifact_dir).expect("artifact dir should exist");
627 let outside = dir.path().join("outside.ir.json");
628 fs::copy(
629 repo_root.join("fixtures/ir/valid/auth-demo-v1.json"),
630 &outside,
631 )
632 .expect("fixture should copy");
633 std::os::unix::fs::symlink(&outside, artifact_dir.join("pearl.ir.json"))
634 .expect("symlink should be created");
635 fs::write(
636 artifact_dir.join("artifact.json"),
637 serde_json::to_string_pretty(&json!({
638 "artifact_version": "1.0",
639 "artifact_name": "auth-demo",
640 "gate_id": "auth_demo_v1",
641 "files": {
642 "pearl_ir": "pearl.ir.json"
643 }
644 }))
645 .expect("manifest encodes"),
646 )
647 .expect("manifest writes");
648
649 let err = LogicPearlEngine::from_path(&artifact_dir)
650 .expect_err("escaping symlink should fail")
651 .to_string();
652 assert!(err.contains("escapes bundle directory"));
653 }
654
655 #[test]
656 fn loads_and_runs_action_artifact_from_manifest() {
657 let dir = tempdir().expect("tempdir should exist");
658 fs::write(
659 dir.path().join("pearl.ir.json"),
660 serde_json::to_string_pretty(&json!({
661 "ir_version": "1.0",
662 "action_policy_id": "garden_actions",
663 "action_policy_type": "priority_rules",
664 "action_column": "next_action",
665 "default_action": "do_nothing",
666 "actions": ["do_nothing", "water"],
667 "input_schema": {
668 "features": [
669 {"id": "soil_moisture_pct", "type": "float", "description": null, "values": null, "min": null, "max": null, "editable": null}
670 ]
671 },
672 "rules": [
673 {
674 "id": "rule_000",
675 "bit": 0,
676 "action": "water",
677 "priority": 0,
678 "when": {"feature": "soil_moisture_pct", "op": "<=", "value": 0.18},
679 "label": "Soil is dry",
680 "message": null,
681 "severity": null,
682 "counterfactual_hint": null,
683 "verification_status": null
684 }
685 ],
686 "evaluation": {"selection": "first_match"},
687 "verification": null,
688 "provenance": null
689 }))
690 .expect("action policy encodes"),
691 )
692 .expect("action policy writes");
693 fs::write(
694 dir.path().join("artifact.json"),
695 serde_json::to_string_pretty(&json!({
696 "artifact_version": "1.0",
697 "artifact_kind": "action_policy",
698 "artifact_name": "garden_actions",
699 "action_column": "next_action",
700 "default_action": "do_nothing",
701 "actions": ["do_nothing", "water"],
702 "files": {
703 "pearl_ir": "pearl.ir.json",
704 "action_report": "action_report.json"
705 }
706 }))
707 .expect("manifest encodes"),
708 )
709 .expect("manifest writes");
710
711 let engine = LogicPearlEngine::from_path(dir.path()).expect("action artifact should load");
712 assert_eq!(engine.kind(), EngineKind::ActionArtifact);
713 let result = engine
714 .run_single_json(&json!({"soil_moisture_pct": "14%"}))
715 .expect("action artifact should run");
716 match result {
717 EngineSingleExecution::ActionArtifact(output) => {
718 assert_eq!(output.action_policy_id, "garden_actions");
719 assert_eq!(output.evaluation.action, "water");
720 assert_eq!(output.evaluation.bitmask.as_u64(), Some(1));
721 }
722 _ => panic!("expected action artifact result"),
723 }
724 }
725
726 #[test]
727 fn loads_and_runs_pipeline() {
728 let repo_root = repo_root();
729 let pipeline =
730 repo_root.join("examples/pipelines/observer_membership_verify/pipeline.json");
731 let input = json!({
732 "age": 34,
733 "member": true,
734 "country": "US"
735 });
736 let engine = LogicPearlEngine::from_path(&pipeline).expect("pipeline loads");
737 let result = engine.run_single_json(&input).expect("pipeline runs");
738 match result {
739 EngineSingleExecution::Pipeline(output) => {
740 assert_eq!(output.output.get("allow"), Some(&json!(true)));
741 assert_eq!(
742 output.output.get("audit_status"),
743 Some(&json!("clean_pass"))
744 );
745 }
746 _ => panic!("expected pipeline result"),
747 }
748 }
749
750 #[test]
751 fn loads_and_runs_pipeline_from_artifact_manifest_v1() {
752 let repo_root = repo_root();
753 let source_dir = repo_root.join("examples/pipelines/observer_membership_verify");
754 let dir = tempdir().expect("tempdir should exist");
755 fs::create_dir_all(dir.path().join("artifacts")).expect("artifact dir should exist");
756 fs::create_dir_all(dir.path().join("plugins/python_observer"))
757 .expect("observer plugin dir should exist");
758 fs::create_dir_all(dir.path().join("plugins/python_pipeline_verify"))
759 .expect("verify plugin dir should exist");
760 fs::copy(
761 source_dir.join("pipeline.json"),
762 dir.path().join("pipeline.json"),
763 )
764 .expect("pipeline should copy");
765 fs::copy(
766 source_dir.join("artifacts/membership-demo-v1.json"),
767 dir.path().join("artifacts/membership-demo-v1.json"),
768 )
769 .expect("artifact should copy");
770 for file in ["manifest.json", "plugin.py"] {
771 fs::copy(
772 source_dir.join("plugins/python_observer").join(file),
773 dir.path().join("plugins/python_observer").join(file),
774 )
775 .expect("observer plugin should copy");
776 fs::copy(
777 source_dir.join("plugins/python_pipeline_verify").join(file),
778 dir.path().join("plugins/python_pipeline_verify").join(file),
779 )
780 .expect("verify plugin should copy");
781 }
782 fs::write(
783 dir.path().join("artifact.json"),
784 serde_json::to_string_pretty(&json!({
785 "schema_version": "logicpearl.artifact_manifest.v1",
786 "artifact_id": "observer_membership_verify_pipeline",
787 "artifact_kind": "pipeline",
788 "engine_version": "0.1.5",
789 "ir_version": "1.0",
790 "created_at": "2026-04-12T00:00:00Z",
791 "artifact_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
792 "files": {
793 "ir": "pipeline.json"
794 }
795 }))
796 .expect("manifest encodes"),
797 )
798 .expect("manifest writes");
799
800 let input = json!({
801 "age": 34,
802 "member": true,
803 "country": "US"
804 });
805 let engine = LogicPearlEngine::from_path(dir.path()).expect("pipeline manifest loads");
806 assert_eq!(engine.kind(), EngineKind::Pipeline);
807 let result = engine.run_single_json(&input).expect("pipeline runs");
808 match result {
809 EngineSingleExecution::Pipeline(output) => {
810 assert_eq!(output.output.get("allow"), Some(&json!(true)));
811 }
812 _ => panic!("expected pipeline result"),
813 }
814 }
815}