taktwerk 0.6.0

Fixed-step model execution engine for Linux with its own OPC UA server
//! The recorders of `taktwerk run`: manifests, rings and the record service's configuration.

use std::hash::{BuildHasher as _, Hasher as _};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;

use anyhow::Context as _;
use taktwerk_core::plan::Plan;
use taktwerk_core::project::Project;
use taktwerk_core::record::{FORMAT, Invocation, Manifest, ModelInfo, Records};
use taktwerk_core::schedule::ModelAdapters;
use taktwerk_record::ServiceConfig;
use taktwerk_record::util;

use crate::setup::model_path;

/// A random id of this engine process: 16 hex digits.
pub fn engine_run() -> String {
    let mut h = std::collections::hash_map::RandomState::new().build_hasher();
    let now = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap_or_default();
    h.write_u128(now.as_nanos());
    h.write_u32(std::process::id());
    format!("{:016x}", h.finish())
}

/// The recorders of `plan`, with a manifest each, and the record service's configuration;
/// `None` when the project has none.
///
/// # Errors
/// The project file or a model package cannot be read for its hash.
pub fn records(
    project_file: &Path,
    project: &Project,
    adapters: &ModelAdapters,
    plan: &Plan,
    invocation: &Invocation,
) -> anyhow::Result<Option<(Arc<Records>, ServiceConfig)>> {
    if plan.recorders.is_empty() {
        return Ok(None);
    }
    let bytes =
        std::fs::read(project_file).with_context(|| format!("read {}", project_file.display()))?;
    let name = project_file.file_stem().map_or_else(
        || "project".to_owned(),
        |s| s.to_string_lossy().into_owned(),
    );
    let mut models = Vec::new();
    for (id, model) in &project.models {
        let path = model_path(project_file, &model.path);
        let hash = util::sha256_package(&path)
            .with_context(|| format!("hash model `{id}` at {}", path.display()))?;
        models.push(ModelInfo {
            id: id.clone(),
            kind: model.kind.clone(),
            path: model.path.display().to_string(),
            name: adapters
                .get(id)
                .map(|a| a.interface().name.clone())
                .unwrap_or_default(),
            hash,
        });
    }
    let base = Manifest {
        format: FORMAT,
        project: name,
        project_hash: util::sha256(&bytes),
        taktwerk: env!("CARGO_PKG_VERSION").to_owned(),
        engine_run: engine_run(),
        recorder: String::new(),
        tick_s: plan.tick.as_secs_f64(),
        start_time: plan.start_time,
        every: 1,
        buffer_s: 0.0,
        rotate: Default::default(),
        retain: Default::default(),
        models,
        columns: Vec::new(),
        invocation: Some(invocation.clone()),
    };
    let mut manifests = Vec::new();
    let mut dirs: Vec<PathBuf> = Vec::new();
    for rec in &plan.recorders {
        let c = &rec.config;
        let manifest = Manifest {
            recorder: c.id.clone(),
            every: c.every,
            buffer_s: c.buffer_s,
            rotate: c.rotate.clone(),
            retain: c.retain.clone(),
            columns: Manifest::columns(rec, &plan.layout),
            ..base.clone()
        };
        manifests.push(serde_json::to_string(&manifest).context("manifest")?);
        dirs.push(model_path(project_file, &c.dir));
    }
    let instances = plan.instances.iter().map(|i| i.id.clone()).collect();
    let records = Records::new(plan.recorders.clone(), manifests);
    Ok(Some((
        Arc::new(records),
        ServiceConfig::local(instances, dirs),
    )))
}

#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
    use super::*;

    #[test]
    fn engine_runs_differ_and_are_hex() {
        let (a, b) = (engine_run(), engine_run());
        assert_ne!(a, b);
        assert_eq!(a.len(), 16);
        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
    }
}