//! Publish-draft data model. //! //! A draft is a server-side, non-public, in-progress publish composition. It is //! persisted as a [`DraftThingInput`]: a mirror of the generated //! [`ThingInput`](polymodel_api::space_polymodel::library::publish_thing::ThingInput) //! with every field optional, so a partial draft (saved before a name, license, //! or any model exists) round-trips losslessly. The same camelCase field names //! keep the payload shape aligned with the publishable record. //! //! Leaf media/reference types (`Image`, `File`, `Bbox`, `StrongRef`) are reused //! from the generated API at their default owned-string type, so an uploaded //! image/file ref stored in a draft is byte-identical to what `publishThing` //! consumes. //! //! [`DraftThingInput::assemble`] is the single conversion point from a draft to //! a publishable `ThingInput`: it validates required structure (which is //! enforced *structurally* by the typed record — `name`/`license`/`models`, //! `name`/`parts`, `name`+`file` are not optional there) and then defers the //! length/grapheme limits to the generated [`LexiconSchema::validate`], so draft //! validation cannot drift from the lexicon. use jacquard::common::types::string::AtUri; use jacquard_common::DefaultStr; use jacquard_lexicon::schema::LexiconSchema; use polymodel_api::com_atproto::repo::strong_ref::StrongRef; use crate::thing_detail::thing_uri_from_route; use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput}; use polymodel_api::space_polymodel::library::{File, Image}; use polymodel_renderer_protocol::{MeshFormat, ModelLoadBundle, ModelResource, PROTOCOL_VERSION}; use serde::{Deserialize, Serialize}; use sha2::Digest; // Re-export leaf types the publish UI edits directly. pub use polymodel_api::space_polymodel::library::Bbox; /// The owned string type the generated records use (`DefaultStr`). type S = DefaultStr; // ---------------------------------------------------------------------------- // Draft endpoint wire types // // Defined here (cross-target) rather than in the server-only `appview::drafts` // module so the wasm client can share them with the server. // ---------------------------------------------------------------------------- /// Response for create/upsert: the opaque draft id the client should keep. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DraftIdResponse { pub draft_id: String, } /// A draft listing row, rendered from denormalized columns (no payload parse). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DraftSummary { pub draft_id: String, pub name: Option, pub model_count: i64, pub created_at: i64, pub updated_at: i64, } /// Response for delete: whether a row actually existed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DeleteDraftResponse { pub deleted: bool, } /// Response for image upload: a fully-formed `#image` ready to attach. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ImageUploadResponse { pub image: Image, } /// A `ThingInput`-shaped draft payload with every field optional. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DraftThingInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option>, /// Remix provenance; design pending. Carried pass-through, not yet surfaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option, /// Thing-level compatibility links, surfaced as resolvable "Works with" chips. #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option>, #[serde(default)] pub models: Vec, } /// Metadata-only reference to owner-scoped geometry in `upload_staging`. /// Geometry bytes are reacquired through the authenticated staging boundary and /// never serialized into draft JSON. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DraftStagedResource { pub resource_id: String, pub upload_id: String, /// Immutable filename supplied when the source was staged. pub source_name: String, pub mime_type: String, pub digest: Vec, pub byte_length: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option, } /// Ephemeral verified bytes paired with persisted staging metadata. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AcquiredDraftResource { pub metadata: DraftStagedResource, pub bytes: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct GeneratedPreview { pub source_resource_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub operation_id: Option, pub image: Image, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum DraftBundlePlan { Complete(ModelLoadBundle), Incomplete(String), } pub const MAX_PREVIEW_RESOURCES: usize = 64; pub const MAX_PREVIEW_RESOURCE_BYTES: u64 = 32 * 1024 * 1024; pub const MAX_PREVIEW_AGGREGATE_BYTES: u64 = 128 * 1024 * 1024; /// Plan a complete transitive draft-local LDraw bundle. Stable IDs choose the /// primary; immutable source names participate only in canonical path resolution. pub fn plan_draft_ldraw_bundle( primary_resource_id: &str, staged: &[AcquiredDraftResource], ) -> DraftBundlePlan { if staged.is_empty() || staged.len() > MAX_PREVIEW_RESOURCES { return DraftBundlePlan::Incomplete("preview resource count exceeds bounds".into()); } let mut aggregate = 0_u64; let Some(primary) = staged .iter() .find(|resource| resource.metadata.resource_id == primary_resource_id) else { return DraftBundlePlan::Incomplete("primary staged resource is missing".into()); }; let primary_name = &primary.metadata.source_name; let primary_path = if primary_name.contains('/') { primary_name.clone() } else { format!("models/{primary_name}") }; let primary_path = match polymodel_ldraw_core::NormalizedPath::new(&primary_path) { Ok(path) => path, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; let mut resources = Vec::with_capacity(staged.len()); let mut acquired_by_path = std::collections::HashMap::new(); for acquired in staged { let metadata = &acquired.metadata; if metadata.digest.len() != 32 || metadata.byte_length != acquired.bytes.len() as u64 || metadata.byte_length > MAX_PREVIEW_RESOURCE_BYTES || metadata.digest.as_slice() != &sha2::Sha256::digest(&acquired.bytes)[..] { return DraftBundlePlan::Incomplete("staged resource integrity mismatch".into()); } aggregate = match aggregate.checked_add(metadata.byte_length) { Some(value) if value <= MAX_PREVIEW_AGGREGATE_BYTES => value, _ => return DraftBundlePlan::Incomplete("preview aggregate exceeds bounds".into()), }; let raw_path = if metadata.resource_id == primary_resource_id { primary_path.as_str().to_owned() } else if metadata.source_name.contains('/') { metadata.source_name.clone() } else { format!("p/{}", metadata.source_name) }; let path = match polymodel_ldraw_core::NormalizedPath::new(&raw_path) { Ok(path) => path, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; let key = path.as_str().to_owned(); if acquired_by_path.insert(key.clone(), acquired).is_some() { return DraftBundlePlan::Incomplete(format!("duplicate staged path: {key}")); } let root = key.split('/').next().unwrap_or("models").to_owned(); let resource = ModelResource { key: key.clone(), path: key, root, byte_length: metadata.byte_length, sha256: match metadata.digest.as_slice().try_into() { Ok(digest) => digest, Err(_) => { return DraftBundlePlan::Incomplete( "staged resource digest is not SHA-256".into(), ); } }, mime_type: metadata.mime_type.clone(), target_cids: Box::default(), bytes: acquired.bytes.clone().into_boxed_slice(), }; if let Err(error) = resource.validate() { return DraftBundlePlan::Incomplete(format!("staged resource rejected: {error:?}")); } resources.push(resource); } let mut pending = std::collections::VecDeque::from([primary_path.clone()]); let mut visited = std::collections::HashSet::new(); while let Some(current) = pending.pop_front() { if !visited.insert(current.as_str().to_owned()) { continue; } let Some(source) = acquired_by_path.get(current.as_str()) else { return DraftBundlePlan::Incomplete(format!("staged companion is missing: {current}")); }; let references = match polymodel_ldraw_core::extract_references(&source.bytes) { Ok(references) => references, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; for reference in references { if reference.target_virtual_file { continue; } let resolved = match polymodel_ldraw_core::resolve_reference_path(¤t, &reference) { Ok(resolved) => resolved, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; if !acquired_by_path.contains_key(resolved.path.as_str()) { return DraftBundlePlan::Incomplete(format!( "staged companion is missing: {}", resolved.path )); } pending.push_back(resolved.path); } } let bundle = ModelLoadBundle { version: PROTOCOL_VERSION, format: MeshFormat::CompoundLdraw, primary: primary_path.as_str().to_owned(), resources: resources.into_boxed_slice(), source_identities: Vec::new(), target_selections: Vec::new(), }; match bundle.validate() { Ok(()) => DraftBundlePlan::Complete(bundle), Err(error) => DraftBundlePlan::Incomplete(format!("model bundle rejected: {error:?}")), } } /// A model within a draft. `parts` are ordered; an empty draft model is allowed /// (it simply fails publish validation until it has a name and at least one part). #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DraftModelInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub primary_resource_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub generated_preview: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, /// Per-model license override. #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option>, #[serde(default)] pub parts: Vec, /// All geometry staged for this model, including files that are companions /// rather than publishable parts. This is the draft-local preview set. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub staged_resources: Vec, } /// A part within a draft model. A part is publishable once it has a name and a /// successfully-staged file (an inline `file` manifest or a stage `upload_id`). #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DraftPartInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub resource_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub generated_preview: Option, /// stageFile upload id; the server resolves it to a `File` manifest at publish. #[serde(default, skip_serializing_if = "Option::is_none")] pub upload_id: Option, /// Inline file manifest (alternative to `upload_id`). #[serde(default, skip_serializing_if = "Option::is_none")] pub file: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub notes: Option, /// Unit label for this part's dimensions (distinct from `dimensions.unit`). #[serde(default, skip_serializing_if = "Option::is_none")] pub units: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub print_settings: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub dimensions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option>, /// Remix provenance; design pending. Carried pass-through, not yet surfaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option, /// Part-level compatibility links; not yet surfaced (thing-level only). #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option>, } /// One actionable validation problem, addressed to a draft field path. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ValidationIssue { pub field: String, pub message: String, } /// All validation problems found while assembling a draft into a `ThingInput`. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ValidationErrors { pub issues: Vec, } impl ValidationErrors { fn push(&mut self, field: impl Into, message: impl Into) { self.issues.push(ValidationIssue { field: field.into(), message: message.into(), }); } pub fn is_empty(&self) -> bool { self.issues.is_empty() } /// Whether any issue targets the given field path exactly. pub fn has_field(&self, field: &str) -> bool { self.issues.iter().any(|i| i.field == field) } } impl std::fmt::Display for ValidationErrors { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut first = true; for issue in &self.issues { if !first { write!(f, "; ")?; } write!(f, "{}: {}", issue.field, issue.message)?; first = false; } Ok(()) } } fn nonempty(value: &Option) -> bool { value .as_deref() .map(|v| !v.trim().is_empty()) .unwrap_or(false) } fn into_s(value: &str) -> S { S::from(value.trim()) } fn opt_s(value: &Option) -> Option { value .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .map(S::from) } fn opt_vec_s(value: &Option>) -> Option> { value .as_ref() .map(|items| items.iter().map(|s| S::from(s.trim())).collect::>()) } /// Convert a freeform multi-line text field (stored raw, one entry per line) /// into a list of non-empty steps. Whitespace-only lines are dropped here, at /// assembly, so the live textarea can keep spaces and blank lines while typing. fn clean_lines(value: &Option>) -> Option> { let lines: Vec = value .as_ref()? .iter() .map(|line| line.trim_end()) .filter(|line| !line.trim().is_empty()) .map(S::from) .collect(); if lines.is_empty() { None } else { Some(lines) } } impl DraftPartInput { /// A part is backed by a staged file when it has either an inline manifest /// or a non-empty upload id. pub fn has_file(&self) -> bool { self.file.is_some() || nonempty(&self.upload_id) } /// A part is publishable when it has a non-empty name and a staged file. #[allow(dead_code)] pub fn is_publishable(&self) -> bool { nonempty(&self.name) && self.has_file() } } impl DraftModelInput { /// A model is publishable when it has a non-empty name and at least one /// publishable part. #[allow(dead_code)] pub fn is_publishable(&self) -> bool { nonempty(&self.name) && !self.parts.is_empty() && self.parts.iter().all(|p| p.is_publishable()) } } impl DraftThingInput { /// Display name for draft listings, if set. pub fn display_name(&self) -> Option<&str> { self.name .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) } /// Number of models (for the denormalized `model_count` listing column). pub fn model_count(&self) -> i64 { self.models.len() as i64 } /// Basics is complete once the thing has a name. License has an effective /// default and is chosen in the details step, so it never gates Basics. #[allow(dead_code)] pub fn basics_complete(&self) -> bool { nonempty(&self.name) } /// Step 2 (Files & parts) is complete when there is at least one model and /// every model is publishable. #[allow(dead_code)] pub fn files_complete(&self) -> bool { !self.models.is_empty() && self.models.iter().all(|m| m.is_publishable()) } /// Validate the draft and convert it into a publishable `ThingInput`. /// /// Required structure is checked here (the draft is all-optional); the /// length/grapheme limits are delegated to the generated /// [`LexiconSchema::validate`] on the assembled records so this code never /// re-declares the lexicon's constraints. pub fn assemble(&self) -> Result, ValidationErrors> { let mut errors = ValidationErrors::default(); if !nonempty(&self.name) { errors.push("name", "A name is required to publish."); } if self.models.is_empty() { errors.push("models", "Add at least one model with a file."); } for (mi, model) in self.models.iter().enumerate() { if !nonempty(&model.name) { errors.push(format!("models[{mi}].name"), "Each model needs a name."); } if model.parts.is_empty() { errors.push( format!("models[{mi}].parts"), "Each model needs at least one part with a file.", ); } for (pi, part) in model.parts.iter().enumerate() { if !nonempty(&part.name) { errors.push( format!("models[{mi}].parts[{pi}].name"), "Each part needs a name.", ); } if !part.has_file() { errors.push( format!("models[{mi}].parts[{pi}].file"), "Upload a file for this part.", ); } // Dimensions are all-or-none: if any axis is set, all three // must be non-empty. The lexicon only requires the fields exist, // so this presence check is enforced here, not by the validator. if let Some(bbox) = &part.dimensions { let axes = [bbox.x.as_str(), bbox.y.as_str(), bbox.z.as_str()]; let set_count = axes.iter().filter(|a| !a.trim().is_empty()).count(); if set_count != 0 && set_count != 3 { errors.push( format!("models[{mi}].parts[{pi}].dimensions"), "Set all three dimensions (X, Y, Z), or none.", ); } } } } if !errors.is_empty() { return Err(errors); } // Presence is satisfied above; this pass only constructs typed records. let models = self .models .iter() .map(|model| { let parts = model .parts .iter() .map(|part| { PartInput::builder() .name(into_s(part.name.as_deref().unwrap_or_default())) .upload_id(opt_s(&part.upload_id)) .file(part.file.clone()) .format(opt_s(&part.format)) .notes(opt_s(&part.notes)) .units(opt_s(&part.units)) .print_settings(opt_vec_s(&part.print_settings)) .dimensions(clean_bbox(part.dimensions.clone())) .previews(part.previews.clone()) .derived_from(part.derived_from.clone()) .intended_to_work_with(part.intended_to_work_with.clone()) .build() }) .collect(); ModelInput { cover: model.cover.clone(), derived_from: model.derived_from.clone(), instructions: opt_vec_s(&model.instructions), intended_to_work_with: model.intended_to_work_with.clone(), license: opt_s(&model.license), name: into_s(model.name.as_deref().unwrap_or_default()), parts, previews: model.previews.clone(), summary: opt_s(&model.summary), tags: opt_vec_s(&model.tags), extra_data: None, } }) .collect::>(); let thing = ThingInput { cover: self.cover.clone(), derived_from: self.derived_from.clone(), instructions: clean_lines(&self.instructions), intended_to_work_with: self.intended_to_work_with.clone(), license: into_s( self.license .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .unwrap_or(super::DEFAULT_LICENSE), ), models, name: into_s(self.name.as_deref().unwrap_or_default()), previews: self.previews.clone(), summary: opt_s(&self.summary), tags: opt_vec_s(&self.tags), extra_data: None, }; // Defer length/grapheme limits to the generated lexicon validators so // this code never duplicates (and cannot drift from) the constraints. if let Err(err) = thing.validate() { errors.push("name", lexicon_message(&err)); } for (mi, model) in thing.models.iter().enumerate() { if let Err(err) = model.validate() { errors.push(format!("models[{mi}]"), lexicon_message(&err)); } for (pi, part) in model.parts.iter().enumerate() { if let Err(err) = part.validate() { errors.push(format!("models[{mi}].parts[{pi}]"), lexicon_message(&err)); } } } if errors.is_empty() { Ok(thing) } else { Err(errors) } } } fn lexicon_message(err: &jacquard_lexicon::validation::ConstraintError) -> String { format!("Exceeds a lexicon limit: {err}") } /// Drop a `Bbox` whose axes are all empty so an in-progress edit does not /// serialize as a partial/empty dimensions object. Unit alone is not a dimension. fn clean_bbox(bbox: Option) -> Option { bbox.and_then(|b| { let empty = b.x.as_str().trim().is_empty() && b.y.as_str().trim().is_empty() && b.z.as_str().trim().is_empty(); if empty { None } else { Some(b) } }) } /// Derive a thing/model name from an uploaded filename: drop the extension and /// trim surrounding whitespace. A bare extension (e.g. `.stl`) or extensionless /// name is returned trimmed. pub fn name_from_filename(filename: &str) -> String { let trimmed = filename.trim(); match trimmed.rsplit_once('.') { Some((stem, _ext)) if !stem.trim().is_empty() => stem.trim().to_string(), _ => trimmed.to_string(), } } /// Give a freshly-uploaded-into draft the structure `assemble()` requires: a /// thing name derived from the first filename (only when currently unset) and at /// least one model carrying a non-empty name. Pure and idempotent — a later drop /// neither renames the thing nor adds a second default model. pub fn ensure_default_model_for_upload(thing: &mut DraftThingInput, first_filename: &str) { let derived = name_from_filename(first_filename); if !nonempty(&thing.name) && !derived.is_empty() { thing.name = Some(derived.clone()); } if thing.models.is_empty() { let model_name = thing .display_name() .map(str::to_string) .filter(|name| !name.is_empty()) .or_else(|| (!derived.is_empty()).then(|| derived.clone())) .unwrap_or_else(|| "Model 1".to_string()); thing.models.push(DraftModelInput { name: Some(model_name), ..Default::default() }); } } /// Parse one "works with" token — an `at://` URI, a full Polymodel thing URL, or /// a bare `/{repo}/thing/{rkey}` route path — into a thing `AtUri`. Pure; the /// network resolution to a `StrongRef` happens separately. Query strings and /// fragments are ignored; anything that is not a thing link is rejected. pub fn parse_thing_token(token: &str) -> Result { let token = token.trim(); if token.is_empty() { return Err("Empty link.".into()); } if token.starts_with("at://") { let uri = AtUri::new_owned(token).map_err(|e| format!("Invalid at:// URI: {e}"))?; // Only thing-record URIs are valid "Works with" links; reject model/part // or other NSIDs early with a clear message rather than a decode error. let is_thing = uri .path() .map(|p| p.collection.as_str() == "space.polymodel.library.thing") .unwrap_or(false); if !is_thing { return Err("Not a thing link.".to_string()); } return Ok(uri); } let rest = match token.split_once("://") { Some((_scheme, rest)) => rest.split_once('/').map(|(_host, path)| path).unwrap_or(""), None => token, }; let path = rest .split(['?', '#']) .next() .unwrap_or("") .trim_matches('/'); let segments: Vec<&str> = path.split('/').collect(); match segments.as_slice() { [repo, "thing", rkey] if !repo.is_empty() && !rkey.is_empty() => { thing_uri_from_route(repo, rkey) } _ => Err("Not a Polymodel thing link.".to_string()), } } #[cfg(test)] mod tests { use super::*; fn resource(id: &str, name: &str, bytes: &[u8]) -> AcquiredDraftResource { AcquiredDraftResource { metadata: DraftStagedResource { resource_id: id.into(), upload_id: id.into(), source_name: name.into(), mime_type: "application/x-ldraw".into(), digest: sha2::Sha256::digest(bytes).to_vec(), byte_length: bytes.len() as u64, format: Some("ldraw".into()), }, bytes: bytes.to_vec(), } } const MAIN: &[u8] = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n"; const CHILD: &[u8] = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 grand.dat\n"; const GRAND: &[u8] = b"0 Grand\n3 16 0 0 0 1 0 0 0 1 0 0\n"; #[test] fn draft_staging_json_contains_metadata_not_geometry_bytes() { let acquired = resource("upload-main", "main.ldr", MAIN); let json = serde_json::to_string(&acquired.metadata).unwrap(); assert!(json.contains("upload-main")); assert!(!json.contains("bytes")); assert!(!json.contains("49,16")); } #[test] fn ldraw_preview_requires_complete_transitive_closure_in_any_upload_order() { let main = resource("main-id", "main.ldr", MAIN); let child = resource("child-id", "child.dat", CHILD); let grand = resource("grand-id", "grand.dat", GRAND); assert!(matches!( plan_draft_ldraw_bundle("main-id", &[main.clone(), child.clone()]), DraftBundlePlan::Incomplete(_) )); for resources in [ vec![main.clone(), child.clone(), grand.clone()], vec![grand.clone(), child.clone(), main.clone()], ] { let plan = plan_draft_ldraw_bundle("main-id", &resources); let DraftBundlePlan::Complete(bundle) = plan else { panic!("transitive bundle should be complete: {plan:?}") }; assert_eq!(bundle.primary, "models/main.ldr"); assert_eq!(bundle.resources.len(), 3); } } #[test] fn ldraw_preview_handles_cycles_but_rejects_duplicate_paths_and_bad_integrity() { let main = resource("main-id", "main.ldr", MAIN); let child_cycle = resource( "child-id", "child.dat", b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 models/main.ldr\n", ); assert!(matches!( plan_draft_ldraw_bundle("main-id", &[main.clone(), child_cycle]), DraftBundlePlan::Complete(_) )); let duplicate = resource("other-id", "child.dat", GRAND); assert!(matches!( plan_draft_ldraw_bundle( "main-id", &[ main.clone(), resource("child-id", "child.dat", GRAND), duplicate ] ), DraftBundlePlan::Incomplete(_) )); let mut bad = resource("main-id", "main.ldr", MAIN); bad.metadata.digest = vec![0; 32]; assert!(matches!( plan_draft_ldraw_bundle("main-id", &[bad]), DraftBundlePlan::Incomplete(_) )); } fn part_with_upload(name: &str) -> DraftPartInput { DraftPartInput { name: Some(name.to_string()), upload_id: Some("abcdef0123456789-42".to_string()), ..Default::default() } } fn complete_draft() -> DraftThingInput { DraftThingInput { name: Some("Desk Organizer".to_string()), license: Some("CC-BY-4.0".to_string()), summary: Some("A tidy desk caddy.".to_string()), models: vec![DraftModelInput { name: Some("Tray".to_string()), parts: vec![part_with_upload("tray.stl")], ..Default::default() }], ..Default::default() } } #[test] fn partial_draft_round_trips_through_json() { let draft = DraftThingInput { name: Some("WIP".to_string()), ..Default::default() }; let json = serde_json::to_string(&draft).unwrap(); let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); assert_eq!(draft, restored); // A bare partial draft must not serialize required-but-absent fields. assert!(!json.contains("license")); assert!(json.contains("\"name\":\"WIP\"")); } #[test] fn empty_draft_round_trips_and_is_not_publishable() { let draft = DraftThingInput::default(); let json = serde_json::to_string(&draft).unwrap(); let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); assert_eq!(draft, restored); assert!(!draft.basics_complete()); assert!(!draft.files_complete()); assert!(draft.assemble().is_err()); } #[test] fn complete_draft_assembles_to_valid_thing_input() { let draft = complete_draft(); assert!(draft.basics_complete()); assert!(draft.files_complete()); let thing = draft.assemble().expect("complete draft assembles"); assert_eq!(thing.name.as_str(), "Desk Organizer"); assert_eq!(thing.license.as_str(), "CC-BY-4.0"); assert_eq!(thing.models.len(), 1); assert_eq!(thing.models[0].name.as_str(), "Tray"); assert_eq!(thing.models[0].parts.len(), 1); assert_eq!(thing.models[0].parts[0].name.as_str(), "tray.stl"); // The assembled record passes the generated lexicon validators. assert!(thing.validate().is_ok()); } #[test] fn missing_thing_fields_are_reported() { let draft = DraftThingInput::default(); let errors = draft.assemble().expect_err("empty draft fails"); assert!(errors.has_field("name")); assert!(errors.has_field("models")); // License is not required: it defaults at assembly, never an error. assert!(!errors.has_field("license")); } #[test] fn unset_license_defaults_at_assembly() { let mut draft = complete_draft(); draft.license = None; let thing = draft.assemble().expect("unset license defaults"); assert_eq!(thing.license.as_str(), "CC-BY-4.0"); } #[test] fn missing_model_name_and_parts_are_reported() { let mut draft = complete_draft(); draft.models = vec![DraftModelInput::default()]; let errors = draft .assemble() .expect_err("model with no name/parts fails"); assert!(errors.has_field("models[0].name")); assert!(errors.has_field("models[0].parts")); } #[test] fn part_without_file_is_reported() { let mut draft = complete_draft(); draft.models[0].parts = vec![DraftPartInput { name: Some("loose.stl".to_string()), ..Default::default() }]; let errors = draft.assemble().expect_err("part with no file fails"); assert!(errors.has_field("models[0].parts[0].file")); assert!(!errors.has_field("models[0].parts[0].name")); } #[test] fn shared_name_limit_accepts_long_model_name() { let name = "1 x 5 Tile with Groove without Understud with Track (6mm) (Lego-compatible)"; let mut draft = complete_draft(); draft.name = Some(name.to_string()); draft.models[0].name = Some(name.to_string()); draft.models[0].parts[0].name = Some(name.to_string()); draft.models[0].tags = Some(vec!["a".repeat(128)]); draft .assemble() .expect("shared name and tag limits accept valid values"); } #[test] fn over_long_name_trips_lexicon_validator() { let mut draft = complete_draft(); draft.name = Some("x".repeat(257)); let errors = draft .assemble() .expect_err("over-long name fails lexicon validate"); assert!(errors.has_field("name")); } #[test] fn whitespace_only_name_is_not_complete() { let mut draft = complete_draft(); draft.name = Some(" ".to_string()); assert!(!draft.basics_complete()); assert!(draft.assemble().is_err()); } #[test] fn name_from_filename_strips_extension_and_trims() { assert_eq!( name_from_filename(" Desk Organizer.stl "), "Desk Organizer" ); assert_eq!(name_from_filename("a.b.glb"), "a.b"); assert_eq!(name_from_filename("noext"), "noext"); // A bare dotfile keeps its name rather than collapsing to empty. assert_eq!(name_from_filename(".stl"), ".stl"); } #[test] fn ensure_default_model_makes_a_one_file_drop_assemblable() { let mut draft = DraftThingInput::default(); ensure_default_model_for_upload(&mut draft, "Bracket.stl"); draft.models[0].parts.push(part_with_upload("Bracket.stl")); assert_eq!(draft.name.as_deref(), Some("Bracket")); assert_eq!(draft.models.len(), 1); assert!(nonempty(&draft.models[0].name)); assert!(draft.assemble().is_ok(), "auto-created draft must publish"); } #[test] fn ensure_default_model_does_not_overwrite_name_or_add_second_model() { let mut draft = DraftThingInput { name: Some("Chosen".to_string()), ..Default::default() }; ensure_default_model_for_upload(&mut draft, "First.stl"); ensure_default_model_for_upload(&mut draft, "Second.stl"); assert_eq!(draft.name.as_deref(), Some("Chosen")); assert_eq!(draft.models.len(), 1); } #[test] fn parse_thing_token_accepts_at_uri_url_and_path() { let at = parse_thing_token("at://did:plc:abc123/space.polymodel.library.thing/rkey1") .expect("at:// passes through"); assert_eq!(at.authority().as_str(), "did:plc:abc123"); let url = parse_thing_token("https://polymodel.app/alice.test/thing/rkey2") .expect("full URL resolves"); assert_eq!(url.authority().as_str(), "alice.test"); assert_eq!( url.path() .and_then(|p| p.rkey) .map(|r| r.as_ref().to_string()), Some("rkey2".to_string()) ); let path = parse_thing_token("/alice.test/thing/rkey3").expect("bare path resolves"); assert_eq!(path.authority().as_str(), "alice.test"); // Query strings and fragments are ignored. let q = parse_thing_token("https://host/alice.test/thing/rkey4?ref=x#frag") .expect("query/fragment stripped"); assert_eq!( q.path() .and_then(|p| p.rkey) .map(|r| r.as_ref().to_string()), Some("rkey4".to_string()) ); } #[test] fn parse_thing_token_rejects_non_thing_links() { assert!(parse_thing_token("").is_err()); assert!(parse_thing_token("not a link").is_err()); assert!(parse_thing_token("https://host/alice.test/profile").is_err()); assert!(parse_thing_token("at://garbage").is_err()); } #[test] fn per_model_and_per_part_fields_map_through_assemble() { let draft = DraftThingInput { name: Some("Rig".to_string()), models: vec![DraftModelInput { name: Some("Frame".to_string()), summary: Some("Main frame".to_string()), license: Some("MIT".to_string()), tags: Some(vec!["frame".to_string()]), instructions: Some(vec!["Print at 0.2mm".to_string()]), parts: vec![DraftPartInput { name: Some("frame.stl".to_string()), upload_id: Some("abcdef0123456789-1".to_string()), notes: Some("Supports off".to_string()), units: Some("mm".to_string()), print_settings: Some(vec!["0.2mm layers".to_string()]), dimensions: Some(Bbox { unit: Some("mm".into()), x: "10".into(), y: "20".into(), z: "30".into(), extra_data: None, }), ..Default::default() }], ..Default::default() }], ..Default::default() }; let thing = draft.assemble().expect("rich draft assembles"); let model = &thing.models[0]; assert_eq!(model.summary.as_ref().unwrap().as_str(), "Main frame"); assert_eq!(model.license.as_ref().unwrap().as_str(), "MIT"); assert_eq!(model.tags.as_ref().unwrap()[0].as_str(), "frame"); let part = &model.parts[0]; assert_eq!(part.notes.as_ref().unwrap().as_str(), "Supports off"); assert_eq!(part.units.as_ref().unwrap().as_str(), "mm"); assert_eq!( part.print_settings.as_ref().unwrap()[0].as_str(), "0.2mm layers" ); let bbox = part.dimensions.as_ref().unwrap(); assert_eq!(bbox.x.as_str(), "10"); assert_eq!(bbox.unit.as_ref().unwrap().as_str(), "mm"); assert!(thing.validate().is_ok()); } #[test] fn partial_bbox_is_rejected_by_assemble() { let mut draft = complete_draft(); // Only X set → must fail validation (all-or-none). draft.models[0].parts[0].dimensions = Some(Bbox { unit: Some("mm".into()), x: "10".into(), y: "".into(), z: "".into(), extra_data: None, }); let errors = draft .assemble() .expect_err("partial bbox must fail assemble"); assert!(errors.has_field("models[0].parts[0].dimensions")); } #[test] fn empty_bbox_axes_are_dropped_at_assemble() { let mut draft = complete_draft(); // All axes empty (unit set) → dimensions dropped entirely, no error. draft.models[0].parts[0].dimensions = Some(Bbox { unit: Some("mm".into()), x: "".into(), y: "".into(), z: "".into(), extra_data: None, }); let thing = draft.assemble().expect("empty-axes bbox is dropped"); assert!(thing.models[0].parts[0].dimensions.is_none()); } }