Skip to main content

bnto_core/control/assert/
mod.rs

1// assert — Fail the run when a comparison doesn't hold.
2//
3// A generic control node: compares `value` to `expected` with `op` and, when
4// the comparison holds, passes its files on unchanged. It runs as a batch so
5// it checks even when no files arrive. When `value` or `expected` reads
6// `{{file}}` or `{{item.*}}`, the comparison runs once per incoming file and
7// every failing file is named in the one error; with no files it checks
8// nothing. A dry run checks nothing.
9
10mod params;
11
12use crate::compare;
13use crate::metadata::{InputCardinality, NodeCategory, NodeMetadata};
14use crate::processor::{BatchFile, BatchInput, NodeInput, NodeOutput, NodeProcessor};
15use crate::progress::ProgressReporter;
16use crate::{BntoError, ProcessContext};
17
18use super::pass_through::pass_through;
19use super::placeholder;
20use params::{AssertParams, build_assert_parameters};
21
22/// Assert processor — a comparison that fails the run when it doesn't hold.
23pub struct Assert;
24
25impl Assert {
26    pub fn new() -> Self {
27        Self
28    }
29}
30
31impl Default for Assert {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl NodeProcessor for Assert {
38    fn name(&self) -> &str {
39        "assert"
40    }
41
42    fn metadata(&self) -> NodeMetadata {
43        NodeMetadata {
44            node_type: "assert".to_string(),
45            name: "Assert".to_string(),
46            description: "Assert a comparison on each item (equals, not equals, after, before); \
47                          fail the run when it doesn't hold, otherwise pass files through \
48                          unchanged."
49                .to_string(),
50            category: NodeCategory::Control,
51            accepts: vec![],
52            platforms: vec![
53                "browser".to_string(),
54                "cli".to_string(),
55                "desktop".to_string(),
56            ],
57            parameters: build_assert_parameters(),
58            input_cardinality: InputCardinality::Batch,
59            requires: vec![],
60        }
61    }
62
63    fn validate(&self, params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
64        AssertParams::read(params).err().into_iter().collect()
65    }
66
67    fn resolves_items(&self) -> bool {
68        true
69    }
70
71    /// Required by the trait; the executor always runs this node as a batch.
72    fn process(
73        &self,
74        input: NodeInput,
75        progress: &ProgressReporter,
76        ctx: &dyn ProcessContext,
77    ) -> Result<NodeOutput, BntoError> {
78        let file = BatchFile {
79            data: input.data,
80            filename: input.filename,
81            mime_type: input.mime_type,
82            metadata: serde_json::Map::new(),
83        };
84        let batch = BatchInput {
85            files: vec![file],
86            params: input.params,
87        };
88        self.process_batch(batch, progress, ctx)
89    }
90
91    fn process_batch(
92        &self,
93        input: BatchInput,
94        progress: &ProgressReporter,
95        ctx: &dyn ProcessContext,
96    ) -> Result<NodeOutput, BntoError> {
97        let params = AssertParams::read(&input.params).map_err(BntoError::InvalidInput)?;
98        if ctx.dry_run() {
99            progress.report_output(&format!("Would check: {}", params.describe()));
100            return Ok(pass_through(input.files));
101        }
102        let reasons = failures(&params, &input.files);
103        if !reasons.is_empty() {
104            let lead = params.message.as_deref().unwrap_or("Assertion failed");
105            return Err(BntoError::ProcessingFailed(format!(
106                "{lead}: {}",
107                reasons.join("; ")
108            )));
109        }
110        Ok(pass_through(input.files))
111    }
112}
113
114/// Every reason the check fails: once, or once per file when it reads
115/// `{{file}}` or `{{item.*}}` (no files, nothing to check).
116fn failures(params: &AssertParams, files: &[BatchFile]) -> Vec<String> {
117    if !(placeholder::reads_file(&params.value) || placeholder::reads_file(&params.expected)) {
118        return compare::compare(&params.value, params.op, &params.expected)
119            .err()
120            .into_iter()
121            .collect();
122    }
123    files
124        .iter()
125        .filter_map(|f| file_failure(params, f))
126        .collect()
127}
128
129/// Why the check fails for one file, named by it; `None` when it holds.
130fn file_failure(params: &AssertParams, file: &BatchFile) -> Option<String> {
131    let resolve = |text: &str| {
132        placeholder::resolve_file(text, &file.filename, &file.metadata)
133            .map_err(|key| format!("has no item \"{key}\""))
134    };
135    let checked = resolve(&params.value).and_then(|value| {
136        let expected = resolve(&params.expected)?;
137        compare::compare(&value, params.op, &expected)
138    });
139    checked.err().map(|why| format!("{}: {why}", file.filename))
140}
141
142#[cfg(test)]
143mod tests;