Skip to main content

logicpearl_engine/
lib.rs

1// SPDX-License-Identifier: MIT
2//! Application-facing loader and execution facade.
3//!
4//! This crate is the library entrypoint for services that want to run
5//! LogicPearl bundles without invoking the CLI. It resolves artifact
6//! manifests with the shared bundle path policy, loads gate, action, and
7//! pipeline artifacts, and delegates deterministic evaluation to the runtime.
8//! It does not learn new artifacts or execute build-time discovery.
9//!
10//! ```no_run
11//! use logicpearl_engine::LogicPearlEngine;
12//! use serde_json::json;
13//!
14//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
15//! let engine = LogicPearlEngine::from_path("artifacts/access_policy")?;
16//! let result = engine.run_single_json(&json!({
17//!     "clearance_ok": false,
18//!     "mfa_enabled": true
19//! }))?;
20//! println!("{result:?}");
21//! # Ok(())
22//! # }
23//! ```
24
25use 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}