Skip to main content

aqc_file_engine_core/
engine.rs

1//! Engine traits: the typed `FileEngine<ResolvedRequirements>` and the erased `Engine`
2//! contract the runner dispatches over.
3
4use crate::finding::Finding;
5use crate::merge::ConflictEntry;
6use crate::requirement::EngineRequirement;
7use crate::types::{EngineOutput, Provenance};
8
9/// A file engine: reconciles bytes-on-disk against typed declarative
10/// requirements, returning both the bytes `init` would write and the
11/// findings `validate` would report.
12///
13/// Engines are pure functions. They do not perform I/O. They never
14/// return an error: catastrophic failures (parse failures, internal
15/// invariant violations) surface as `Finding`s inside `EngineOutput`.
16#[expect(
17    clippy::module_name_repetitions,
18    reason = "FileEngine is the canonical trait name; renaming it loses the connection to the file-engines abstraction in plans and call sites."
19)]
20pub trait FileEngine<ResolvedRequirements> {
21    /// Apply `resolved_requirements` against `current_bytes`, returning what `init`
22    /// would write and what `validate` would report.
23    fn reconcile(
24        current_bytes: Option<&[u8]>,
25        resolved_requirements: &ResolvedRequirements,
26    ) -> EngineOutput;
27}
28
29/// Erased engine contract the runner dispatches over.
30///
31/// The runner supplies the current bytes and the type-erased requirements for
32/// one target. The engine only reconciles bytes; it does not know file paths.
33pub trait Engine {
34    /// Stable engine id (matches the crate's `ENGINE_ID`).
35    fn id(&self) -> &'static str;
36    /// Reconcile current file state against the requirements routed to this
37    /// engine and target slot.
38    #[allow(
39        clippy::type_complexity,
40        reason = "`&[(Provenance, Box<dyn EngineRequirement>)]` is the erased multi-requirement input the registry dispatches; a type alias would hide the contract."
41    )]
42    fn reconcile(
43        &self,
44        current_bytes: Option<&[u8]>,
45        reqs: &[(Provenance, Box<dyn EngineRequirement>)],
46    ) -> EngineOutput;
47}
48
49/// The shared erased-reconcile body every engine's `Engine::reconcile` is.
50///
51/// Downcast the routed requirements to `Requirements`; with none, echo the
52/// current bytes back unchanged. Otherwise run the engine's merge phase, then
53/// reconcile the merged desired-state against the supplied bytes.
54#[allow(
55    clippy::type_complexity,
56    reason = "The erased multi-requirement input and merge/reconcile closures are the public dispatch shape."
57)]
58pub fn merged_reconcile<Requirements, ResolvedRequirements, Merge, Reconcile>(
59    current_bytes: Option<&[u8]>,
60    reqs: &[(Provenance, Box<dyn EngineRequirement>)],
61    merge: Merge,
62    reconcile_one: Reconcile,
63) -> EngineOutput
64where
65    Requirements: EngineRequirement + Clone,
66    Merge: Fn(Vec<(Provenance, Requirements)>) -> Result<ResolvedRequirements, Vec<ConflictEntry>>,
67    Reconcile: Fn(Option<&[u8]>, &ResolvedRequirements) -> EngineOutput,
68{
69    let typed: Vec<(Provenance, Requirements)> = reqs
70        .iter()
71        .filter_map(|(prov, r)| {
72            r.as_any()
73                .downcast_ref::<Requirements>()
74                .map(|req| (prov.clone(), req.clone()))
75        })
76        .collect();
77    if typed.len() != reqs.len() {
78        return EngineOutput {
79            expected_bytes: current_bytes.unwrap_or_default().to_vec(),
80            findings: vec![Finding::InternalError {
81                message: format!(
82                    "engine received a requirement incompatible with {}",
83                    core::any::type_name::<Requirements>()
84                ),
85            }],
86        };
87    }
88    if typed.is_empty() {
89        return EngineOutput {
90            expected_bytes: current_bytes.unwrap_or_default().to_vec(),
91            findings: Vec::new(),
92        };
93    }
94    match merge(typed) {
95        Ok(resolved) => reconcile_one(current_bytes, &resolved),
96        Err(conflicts) => EngineOutput {
97            expected_bytes: current_bytes.unwrap_or_default().to_vec(),
98            findings: conflicts
99                .into_iter()
100                .map(|conflict| Finding::ConflictingRequirements {
101                    key: conflict.key,
102                    reason: conflict.reason,
103                    contributors: conflict
104                        .contributors
105                        .into_iter()
106                        .map(|(provenance, value)| (provenance.policy, value))
107                        .collect(),
108                })
109                .collect(),
110        },
111    }
112}