master
Drew Owens Share frozen native Rust mechanical development checkpoint and qualification scope b2d7cdb 26d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
//! Bounded native assembly collector. Identity equality, traversal, metadata,
//! revision witnesses and calculation policy are Rust. No SDK mutation or I/O.
use fusion_bom_core as core;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::BTreeMap;

pub const MAX_COMPONENTS: usize = 128;
pub const MAX_OCCURRENCES: usize = 512;
pub const MAX_BODIES: usize = 2048;
pub const MAX_DEPTH: usize = 32;
pub const MAX_BYTES: usize = 512 * 1024;
pub const REVISION_SCOPE: &str = "bom-projection-v1: two exact-object assembly observations, component revisions, AdomMechanical metadata, material identity and high-accuracy definition/occurrence body properties; not a complete geometry or document revision";
pub type Result<T> = std::result::Result<T, String>;

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Design {
    pub active: bool,
    pub hybrid: bool,
    pub configured: bool,
    pub configuration: bool,
    pub table_present: bool,
    pub row_id: String,
    pub components: usize,
    pub timeline_count: usize,
    pub timeline_position: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Definition {
    pub name: String,
    pub part_number: String,
    pub description: String,
    pub revision: String,
    pub token: String,
    pub attributes: BTreeMap<String, String>,
    pub material: Option<core::Material>,
    pub mesh_bodies: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Instance {
    pub path: String,
    pub token: String,
    pub referenced: bool,
    pub configured: bool,
    pub timeline_present: bool,
    pub suppressed: bool,
    pub rolled_back: bool,
    pub visible: bool,
}
/// Handle is a typed retained view index, never an address or identity. The
/// backend retains all acquisitions until checked owner-thread close. same()
/// compares retained SDK references of the same static type, not token text.
pub trait Backend {
    type Handle: Copy;
    fn is_offline(&self) -> Result<bool>;
    fn design(&self) -> Result<Design>;
    fn root(&self) -> Result<Self::Handle>;
    fn same(&self, a: Self::Handle, b: Self::Handle) -> Result<bool>;
    fn definition(&self, component: Self::Handle) -> Result<Definition>;
    fn component(&self, occurrence: Self::Handle) -> Result<Self::Handle>;
    fn instance(&self, occurrence: Self::Handle) -> Result<Instance>;
    fn child_count(&self, owner: Self::Handle) -> Result<usize>;
    fn child(&self, owner: Self::Handle, index: usize) -> Result<Self::Handle>;
    fn body_count(&self, owner: Self::Handle) -> Result<usize>;
    /// Must read occurrence-context bodies when owner is an occurrence; high
    /// accuracy and solid-body support must be verified, never guessed.
    fn body(&self, owner: Self::Handle, index: usize) -> Result<core::PhysicalProperties>;
    fn subtree_properties(&self, component: Self::Handle) -> Result<core::PhysicalProperties>;
}
#[derive(Serialize, PartialEq)]
struct Def {
    data: Definition,
    metadata: core::Metadata,
    bodies: Vec<core::PhysicalProperties>,
    subtree: Option<core::PhysicalProperties>,
}
#[derive(Serialize, PartialEq)]
struct Occ {
    data: Instance,
    component: usize,
    children: Vec<usize>,
    bodies: Vec<core::PhysicalProperties>,
}
#[derive(Serialize, PartialEq)]
struct Projection {
    design: Design,
    components: Vec<Def>,
    occurrences: Vec<Occ>,
    children: Vec<usize>,
}
struct Collector<'a, B: Backend> {
    backend: &'a B,
    projection: Projection,
    definitions: Vec<B::Handle>,
    instances: Vec<B::Handle>,
    body_reads: usize,
    text_bytes: usize,
}
fn require(ok: bool, code: &str) -> Result<()> {
    if ok {
        Ok(())
    } else {
        Err(code.into())
    }
}
fn physical(value: core::PhysicalProperties) -> Result<core::PhysicalProperties> {
    require(
        value.mass_kg.is_finite()
            && value.volume_mm3.is_finite()
            && value.mass_kg >= 0.0
            && value.volume_mm3 >= 0.0,
        "native_bom_invalid_physical",
    )?;
    Ok(value)
}
impl<'a, B: Backend> Collector<'a, B> {
    fn text(&mut self, text: &str) -> Result<()> {
        require(
            text.len() <= 16 * 1024 && !text.contains('\0'),
            "native_bom_text_bound",
        )?;
        self.text_bytes = self
            .text_bytes
            .checked_add(text.len())
            .ok_or("native_bom_byte_bound")?;
        require(self.text_bytes <= MAX_BYTES / 2, "native_bom_byte_bound")
    }
    fn bodies(&mut self, owner: B::Handle) -> Result<Vec<core::PhysicalProperties>> {
        let count = self.backend.body_count(owner)?;
        self.body_reads = self
            .body_reads
            .checked_add(count)
            .ok_or("native_bom_body_bound")?;
        require(self.body_reads <= MAX_BODIES, "native_bom_body_bound")?;
        (0..count)
            .map(|i| self.backend.body(owner, i).and_then(physical))
            .collect()
    }
    fn definition(&mut self, handle: B::Handle) -> Result<usize> {
        for (index, previous) in self.definitions.iter().enumerate() {
            if self.backend.same(*previous, handle)? {
                return Ok(index);
            }
        }
        require(
            self.definitions.len() < MAX_COMPONENTS,
            "native_bom_component_bound",
        )?;
        let data = self.backend.definition(handle)?;
        require(
            data.mesh_bodies == 0 && !data.revision.is_empty() && !data.token.is_empty(),
            "native_bom_definition_scope",
        )?;
        for text in [
            &data.name,
            &data.part_number,
            &data.description,
            &data.revision,
            &data.token,
        ] {
            self.text(text)?;
        }
        require(data.attributes.len() <= 128, "native_bom_metadata_bound")?;
        for (key, value) in &data.attributes {
            self.text(key)?;
            self.text(value)?;
        }
        if let Some(material) = &data.material {
            self.text(&material.id)?;
            self.text(&material.name)?;
        }
        let metadata = core::Metadata::from_attributes(
            data.name.clone(),
            data.part_number.clone(),
            data.description.clone(),
            data.attributes.clone(),
            data.material.clone(),
        )
        .map_err(|_| "native_bom_invalid_metadata")?;
        let bodies = self.bodies(handle)?;
        let subtree = if metadata.as_purchased == Some(true) {
            Some(physical(self.backend.subtree_properties(handle)?)?)
        } else {
            None
        };
        let index = self.definitions.len();
        self.definitions.push(handle);
        self.projection.components.push(Def {
            data,
            metadata,
            bodies,
            subtree,
        });
        Ok(index)
    }
    fn children(
        &mut self,
        owner: B::Handle,
        depth: usize,
        ancestry: &[usize],
    ) -> Result<Vec<usize>> {
        require(depth <= MAX_DEPTH, "native_bom_depth_bound")?;
        let count = self.backend.child_count(owner)?;
        require(
            count <= MAX_OCCURRENCES.saturating_sub(self.instances.len()),
            "native_bom_occurrence_bound",
        )?;
        let mut result = Vec::with_capacity(count);
        for index in 0..count {
            let handle = self.backend.child(owner, index)?;
            for previous in &self.instances {
                require(
                    !self.backend.same(*previous, handle)?,
                    "native_bom_duplicate_occurrence",
                )?;
            }
            let data = self.backend.instance(handle)?;
            require(
                !data.referenced && !data.configured && !data.suppressed && !data.rolled_back,
                "native_bom_occurrence_scope",
            )?;
            require(
                !data.path.is_empty() && !data.token.is_empty(),
                "native_bom_missing_reference",
            )?;
            self.text(&data.path)?;
            self.text(&data.token)?;
            let component_handle = self.backend.component(handle)?;
            let component = self.definition(component_handle)?;
            require(!ancestry.contains(&component), "native_bom_cycle")?;
            let bodies = self.bodies(handle)?;
            require(
                bodies.len() == self.projection.components[component].bodies.len(),
                "native_bom_context_body_mismatch",
            )?;
            let serial = self.instances.len();
            require(serial < MAX_OCCURRENCES, "native_bom_occurrence_bound")?;
            self.instances.push(handle);
            self.projection.occurrences.push(Occ {
                data,
                component,
                children: vec![],
                bodies,
            });
            let mut next = ancestry.to_vec();
            next.push(component);
            let children = self.children(handle, depth + 1, &next)?;
            self.projection.occurrences[serial].children = children;
            result.push(serial);
        }
        require(
            self.backend.child_count(owner)? == count,
            "native_bom_changed_collection",
        )?;
        Ok(result)
    }
    fn capture(backend: &'a B) -> Result<Self> {
        require(
            !backend.is_offline()?,
            "native_bom_online_required_for_component_metadata",
        )?;
        let design = backend.design()?;
        require(
            design.active
                && design.hybrid
                && !design.configured
                && !design.configuration
                && !design.table_present
                && design.row_id.is_empty(),
            "native_bom_design_scope",
        )?;
        require(
            design.components > 0
                && design.components <= MAX_COMPONENTS
                && design.timeline_count <= 1024
                && design.timeline_position == design.timeline_count,
            "native_bom_design_bound_or_rollback",
        )?;
        let mut capture = Self {
            backend,
            projection: Projection {
                design,
                components: vec![],
                occurrences: vec![],
                children: vec![],
            },
            definitions: vec![],
            instances: vec![],
            body_reads: 0,
            text_bytes: 0,
        };
        let root = backend.root()?;
        require(capture.definition(root)? == 0, "native_bom_root_identity")?;
        capture.projection.children = capture.children(root, 0, &[0])?;
        require(
            capture.definitions.len() == capture.projection.design.components,
            "native_bom_unreachable_definition",
        )?;
        require(
            backend.design()? == capture.projection.design,
            "native_bom_changed_design",
        )?;
        Ok(capture)
    }
}
fn entity(
    target: &core::Target,
    token: &str,
    kind: &str,
    path: Option<&str>,
    root: &str,
) -> core::EntityRef {
    core::EntityRef {
        document_id: target.document_id.clone(),
        revision: target.revision.clone(),
        token: token.into(),
        object_type: format!("adsk::fusion::{kind}"),
        occurrence_token: if kind == "Occurrence" {
            Some(token.into())
        } else {
            None
        },
        occurrence_path: path.map(str::to_owned),
        root_component_id: Some(root.into()),
    }
}
/// Complete only for the explicitly supported local/unconfigured/unsuppressed
/// BRep subset. Two passes compare actual references as well as all BOM fields.
pub fn generate<B: Backend>(
    backend: &B,
    session: &str,
    document: &str,
    expected_revision: Option<&str>,
    explode_purchased: bool,
) -> Result<Value> {
    capture(
        backend,
        session,
        document,
        expected_revision,
        explode_purchased,
    )
    .map(|result| result.data)
}
#[derive(Debug)]
pub struct Captured {
    pub(crate) data: Value,
    pub(crate) calculation: core::Bom,
}
impl Captured {
    pub fn data(&self) -> &Value {
        &self.data
    }
    pub fn into_data(self) -> Value {
        self.data
    }
}
pub fn capture<B: Backend>(
    backend: &B,
    session: &str,
    document: &str,
    expected_revision: Option<&str>,
    explode_purchased: bool,
) -> Result<Captured> {
    require(
        !session.is_empty() && !document.is_empty(),
        "native_bom_identity_missing",
    )?;
    let first = Collector::capture(backend)?;
    let second = Collector::capture(backend)?;
    require(
        first.projection == second.projection
            && first.definitions.len() == second.definitions.len()
            && first.instances.len() == second.instances.len(),
        "native_bom_changed_snapshot",
    )?;
    for (a, b) in first
        .definitions
        .iter()
        .zip(&second.definitions)
        .chain(first.instances.iter().zip(&second.instances))
    {
        require(backend.same(*a, *b)?, "native_bom_replaced_identity")?;
    }
    let projection = first.projection;
    let encoded = serde_json::to_vec(&projection).map_err(|_| "native_bom_encoding_failed")?;
    require(encoded.len() <= MAX_BYTES, "native_bom_byte_bound")?;
    let revision = format!(
        "bom-projection-v1:{}",
        fusion_mechanical_journal::request_digest(&encoded)
    );
    require(
        expected_revision.is_none_or(|expected| expected == revision),
        "target_revision_mismatch",
    )?;
    let target = core::Target {
        document_id: document.into(),
        revision,
    };
    // IDs are explicitly scoped to this successful capture. A fresh nonce
    // prevents accidentally treating collection slots as cross-capture identity.
    let mut nonce = [0u8; 16];
    getrandom::getrandom(&mut nonce).map_err(|_| "native_bom_entropy_unavailable")?;
    let scope: String = nonce.iter().map(|byte| format!("{byte:02x}")).collect();
    let cid = |index| format!("bom:{scope}:c:{index}");
    let oid = |index| format!("bom:{scope}:o:{index}");
    let root_id = cid(0);
    let components = projection
        .components
        .iter()
        .enumerate()
        .map(|(index, c)| core::Component {
            id: cid(index),
            aliases: vec![],
            metadata: c.metadata.clone(),
            reference: entity(&target, &c.data.token, "Component", None, &root_id),
            direct_bodies: c.bodies.clone(),
            subtree_properties: c.subtree,
        })
        .collect();
    let occurrences = projection
        .occurrences
        .iter()
        .enumerate()
        .map(|(index, o)| core::Occurrence {
            id: oid(index),
            path: o.data.path.clone(),
            component_id: cid(o.component),
            reference: entity(
                &target,
                &o.data.token,
                "Occurrence",
                Some(&o.data.path),
                &root_id,
            ),
            source: core::SourceLink {
                entity_id: oid(index),
                object_type: "adsk::fusion::Occurrence".into(),
                owner_id: cid(o.component),
                owner_scope: core::OwnerScope::Component,
                occurrence_id: Some(oid(index)),
            },
            timeline_suppressed: o.data.timeline_present.then_some(false),
            visible: o.data.visible,
            direct_bodies: o.bodies.clone(),
            children: o.children.iter().map(|i| oid(*i)).collect(),
        })
        .collect();
    let snapshot = core::AssemblySnapshot {
        target: target.clone(),
        witness: core::SnapshotWitness {
            session_id: session.into(),
            design_id: format!("bom:{scope}:design"),
            target: target.clone(),
            complete: true,
            read_errors: vec![],
            calculation_accuracy: core::CalculationAccuracy::High,
        },
        configuration: core::ConfigurationWitness {
            target: target.clone(),
            configured: false,
            is_configuration: false,
            configuration_row_id: None,
            complete: true,
            unsupported: vec![],
            read_errors: vec![],
            configured_occurrences: vec![],
            table: None,
        },
        root: core::Root {
            component_id: root_id,
            children: projection.children.iter().map(|i| oid(*i)).collect(),
        },
        components,
        occurrences,
    };
    let mut bom = core::generate_with_limits(
        &snapshot,
        core::Options { explode_purchased },
        core::Limits {
            max_depth: MAX_DEPTH,
            max_component_visits: 2 * (MAX_OCCURRENCES + 1),
            max_physical_body_instances: MAX_BODIES,
        },
    )
    .map_err(|error| error.code.to_owned())?;
    bom.identity_scope = format!("one complete native BOM snapshot {scope}; exact retained SDK equality within capture; no cross-capture definition-ID stability");
    let mut result = serde_json::to_value(&bom).map_err(|_| "native_bom_encoding_failed")?;
    result["revisionScope"] = json!(REVISION_SCOPE);
    result["nativeScope"] = json!("online Fusion; local unconfigured hybrid assemblies; unsuppressed current timeline; solid BRep only; no meshes or external/configured occurrences");
    result["legacyParity"] = json!(false);
    require(
        serde_json::to_vec(&result)
            .map_err(|_| "native_bom_encoding_failed")?
            .len()
            <= MAX_BYTES,
        "native_bom_byte_bound",
    )?;
    Ok(Captured {
        data: result,
        calculation: bom,
    })
}

#[cfg(test)]
pub(crate) mod tests;

/// Preserve the exact ABI getter scope in an SDK refusal. No success or scope guard is relaxed.
pub(crate) fn sdk_status(call: &str, value: i32) -> Result<()> {
    if value == 0 {
        Ok(())
    } else {
        Err(format!("native_bom_sdk_{call}_status_{value}"))
    }
}

/// Diagnostic metadata never changes the original failed status into success.
/// Description is bounded and JSON escaped to keep arbitrary SDK text unambiguous.
pub(crate) fn sdk_diagnostic(
    call: &str,
    code: i32,
    stage: Option<&str>,
    detail: Option<(i32, u32, Option<&str>)>,
) -> String {
    let base = format!(
        "native_bom_sdk_{call}_{}_status_{code}",
        stage.unwrap_or("unavailable")
    );
    match detail {
        Some((number, object, description)) => {
            let bounded: String = description
                .unwrap_or("unavailable")
                .chars()
                .take(1024)
                .collect();
            format!(
                "{base}; sdkError={number}; definitionHandle={object}; description={}",
                serde_json::to_string(&bounded).unwrap_or_else(|_| "\"unavailable\"".into())
            )
        }
        None => base,
    }
}

/// Independent fields prevent optional-description failure from erasing stage.
#[derive(Default)]
pub(crate) struct DiagnosticFields {
    pub stage: Option<String>,
    pub description: Option<String>,
}
impl DiagnosticFields {
    pub fn store(&mut self, field: u32, bytes: &[u8]) -> std::result::Result<(), ()> {
        match field {
            0 if self.stage.is_none() && bytes.len() < 64 => {
                self.stage = Some(std::str::from_utf8(bytes).map_err(|_| ())?.to_owned());
            }
            1 if self.description.is_none() && bytes.len() <= 1024 => {
                self.description = Some(String::from_utf8_lossy(bytes).into_owned());
            }
            _ => return Err(()),
        }
        Ok(())
    }
}