ifc-author 0.2.2

Schema-checked IFC authoring: construct entities by attribute name with arity and type validation.
Documentation
//! Build one entity by naming its attributes.
//!
//! # The problem this solves
//!
//! `Model::push` takes a positional `Vec<Value>`. Authoring an `IfcAnnotation`
//! therefore means knowing that it has seven slots, that slot 0 is the
//! GlobalId, slot 2 the name, and that slots 1 and 3..6 are optional. Get the
//! count wrong and the file still writes -- and is rejected downstream.
//!
//! Here the caller names attributes and the schema decides the positions.
//!
//! # Attribute names match case-insensitively
//!
//! Names are compared with `eq_ignore_ascii_case`, so an IFC4 and IFC4X3
//! spelling that differs only in case resolves to the same slot and is not a
//! bug. A true rename between releases is a different name and is refused as
//! unknown in the release that lacks it.

use ifc_model::{Entity, Value};
use ifc_schema::Schema;

use crate::check::{aggregate_element, describe_value, is_derived_slot, judge_value, Verdict};
use crate::error::{AuthorError, AuthorResult};

/// A partially-specified entity, checked against the schema on [`build`].
///
/// [`build`]: EntityBuilder::build
#[derive(Debug, Clone)]
pub struct EntityBuilder<'a> {
    schema: &'a Schema,
    entity: String,
    /// Attribute name and value, in the order the caller set them. Kept as a
    /// list rather than a map so a duplicate set is detectable and the error
    /// can name the attribute.
    set: Vec<(String, Value)>,
}

impl<'a> EntityBuilder<'a> {
    /// Start building `entity`, whose type must be declared by `schema`.
    ///
    /// The type is not checked here: an unknown type is reported by [`build`],
    /// so that a caller assembling several entities gets every failure at the
    /// same point in its code rather than some at construction and some later.
    ///
    /// [`build`]: EntityBuilder::build
    pub fn new(schema: &'a Schema, entity: impl Into<String>) -> Self {
        Self {
            schema,
            entity: entity.into(),
            set: Vec::new(),
        }
    }

    /// Set one attribute by its declared name.
    ///
    /// Names are matched case-insensitively, because EXPRESS declares
    /// `GlobalId` while STEP files and much prose write `GLOBALID`.
    #[must_use]
    pub fn set(mut self, attribute: impl Into<String>, value: Value) -> Self {
        self.set.push((attribute.into(), value));
        self
    }

    /// Set a text attribute.
    pub fn text(self, attribute: impl Into<String>, text: impl Into<std::sync::Arc<str>>) -> Self {
        self.set(attribute, Value::Text(text.into()))
    }

    /// Set a real-valued attribute.
    pub fn real(self, attribute: impl Into<String>, value: f64) -> Self {
        self.set(attribute, Value::Real(value))
    }

    /// Set an entity-reference attribute.
    pub fn reference(self, attribute: impl Into<String>, id: ifc_model::EntityId) -> Self {
        self.set(attribute, Value::Ref(id))
    }

    /// Set an enumeration attribute (written `.PLAN_VIEW.` in STEP).
    pub fn enumeration(
        self,
        attribute: impl Into<String>,
        constant: impl Into<std::sync::Arc<str>>,
    ) -> Self {
        self.set(attribute, Value::Enum(constant.into()))
    }
}

impl EntityBuilder<'_> {
    /// Resolve every named attribute to its STEP slot and produce the entity.
    ///
    /// # Errors
    ///
    /// Refuses an unknown entity type, an attribute the schema does not
    /// declare, an attribute set twice, a required attribute left unset, a
    /// scalar/aggregate confusion, a value whose shape contradicts the declared
    /// type, and a malformed GlobalId.
    pub fn build(self) -> AuthorResult<Entity> {
        let declared = self.schema.attributes(&self.entity);
        if declared.is_empty() && self.schema.entity(&self.entity).is_none() {
            return Err(AuthorError::UnknownEntity {
                entity: self.entity,
            });
        }

        // Positional order comes from the schema: inherited attributes first,
        // which is what makes a STEP record readable by anything else. A slot
        // the schema derives for this entity is written `*` unless set.
        let mut slots: Vec<Value> = declared
            .iter()
            .map(|attribute| {
                if is_derived_slot(self.schema, &self.entity, &attribute.name) {
                    Value::Derived
                } else {
                    Value::Null
                }
            })
            .collect();
        let mut filled = vec![false; declared.len()];

        for (name, value) in &self.set {
            let Some(index) = declared
                .iter()
                .position(|a| a.name.eq_ignore_ascii_case(name))
            else {
                return Err(AuthorError::UnknownAttribute {
                    entity: self.entity.clone(),
                    attribute: name.clone(),
                    known: declared.iter().map(|a| a.name.clone()).collect(),
                });
            };
            if filled[index] {
                return Err(AuthorError::DuplicateAttribute {
                    entity: self.entity.clone(),
                    attribute: declared[index].name.clone(),
                });
            }
            check_value(self.schema, &self.entity, declared[index], value)?;
            slots[index] = value.clone();
            filled[index] = true;
        }

        for (index, attribute) in declared.iter().enumerate() {
            if !filled[index] && !attribute.optional && !matches!(slots[index], Value::Derived) {
                return Err(AuthorError::MissingRequired {
                    entity: self.entity.clone(),
                    attribute: attribute.name.clone(),
                });
            }
        }

        Ok(Entity::new(self.entity.to_ascii_uppercase(), slots))
    }

    /// Build the entity and append it to `model`, returning its new id.
    ///
    /// The model is left untouched when construction fails, so a rejected
    /// entity cannot leave a half-written record behind.
    ///
    /// # Errors
    ///
    /// Any failure from [`build`](EntityBuilder::build).
    pub fn insert(self, model: &mut ifc_model::Model) -> AuthorResult<ifc_model::EntityId> {
        let entity = self.build()?;
        Ok(model.push(entity))
    }
}

/// Check one value against one declared attribute.
///
/// Split out as a free function because it borrows the schema and the entity
/// name while the builder's `set` list is being consumed.
pub(crate) fn check_value(
    schema: &Schema,
    entity: &str,
    attribute: &ifc_schema::Attribute,
    value: &Value,
) -> AuthorResult<()> {
    // A derived slot admits exactly `*`, and `*` fits nowhere else.
    let derived = is_derived_slot(schema, entity, &attribute.name);
    match (derived, value) {
        (true, Value::Derived) => return Ok(()),
        (true, other) => {
            return Err(AuthorError::DerivedAttribute {
                entity: entity.to_owned(),
                attribute: attribute.name.clone(),
                found: describe_value(other),
            })
        }
        (false, Value::Derived) => {
            return Err(AuthorError::NotDerived {
                entity: entity.to_owned(),
                attribute: attribute.name.clone(),
            })
        }
        (false, _) => {}
    }

    // An aggregate declaration wants a list and a scalar declaration does not.
    // The `LIST` may sit in the attribute declaration or in a defined type the
    // attribute is declared as (`IfcCompoundPlaneAngleMeasure`). `$` is exempt:
    // an unset optional aggregate is still `$`, not `()`.
    //
    // An empty `()` is invalid for a `LIST [1:?]` / `SET [1:?]` attribute: a
    // caller writes `$` when the attribute is optional and must not build the
    // entity when it is required. That cannot be refused here, because the
    // schema tables keep no aggregate bounds. TODO(#111)
    let aliased = if attribute.aggregate {
        None
    } else {
        aggregate_element(schema, &attribute.type_name)
    };
    let expected_aggregate = attribute.aggregate || aliased.is_some();
    if !matches!(value, Value::Null) {
        let supplied_aggregate = matches!(value, Value::List(_));
        if supplied_aggregate != expected_aggregate {
            return Err(AuthorError::AggregateMismatch {
                entity: entity.to_owned(),
                attribute: attribute.name.clone(),
                expected_aggregate,
            });
        }
    }

    // GlobalId is the one attribute whose *content* is worth checking here: it
    // is the only stable cross-file identity an element has, and a malformed
    // one silently breaks diffing and issue tracking rather than failing loudly.
    if attribute.name.eq_ignore_ascii_case("GlobalId") {
        if let Value::Text(text) = value {
            if ifc_model::guid::Guid::parse(text).is_none() {
                return Err(AuthorError::InvalidGlobalId {
                    entity: entity.to_owned(),
                    found: text.to_string(),
                });
            }
        }
    }

    // Aggregate element types are checked per item; the declaration names the
    // element type, not the container. For an aliased aggregate the element
    // type is the alias's, e.g. `INTEGER` for `IfcCompoundPlaneAngleMeasure`.
    //
    // The first refused item is the one reported; a wrong type is reported
    // before a wrong form, since rewrapping would not fix it.
    let element_type = aliased.as_deref().unwrap_or(&attribute.type_name);
    let verdicts: Vec<(Verdict, &Value)> = match value {
        Value::List(items) => items
            .iter()
            .map(|item| (judge_value(schema, element_type, item), item))
            .collect(),
        scalar => vec![(judge_value(schema, &attribute.type_name, scalar), scalar)],
    };
    if verdicts
        .iter()
        .any(|(verdict, _)| *verdict == Verdict::WrongType)
    {
        return Err(AuthorError::TypeMismatch {
            entity: entity.to_owned(),
            attribute: attribute.name.clone(),
            expected: attribute.type_name.clone(),
            found: describe_value(value),
        });
    }
    if let Some((Verdict::WrongForm { typed_required }, offending)) = verdicts
        .into_iter()
        .find(|(verdict, _)| matches!(verdict, Verdict::WrongForm { .. }))
    {
        return Err(AuthorError::ValueForm {
            entity: entity.to_owned(),
            attribute: attribute.name.clone(),
            declared: element_type.to_owned(),
            typed_required,
            found: describe_value(offending),
        });
    }
    Ok(())
}