diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3cc9a22a..e148ecaff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -279,7 +279,7 @@ mean something else, it needs a format change. **Add one file, and edit nothing change's file, or any `v/` folder: those are fixed once released. That's all. The build counts the files: while any are pending it writes the next format number, -which no release reads, so a release refuses a design that has your change instead of losing it. +which no release reads, so a release warns about a design that has your change and keeps what it can't read of it. A file also lists the pending changes it has (`format_pending`), so a later development build runs only the steps that file is missing. Two pull requests that each add a change merge without a conflict. `crates/doc/src/format/pending/README.md` has an example. diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index e4df87cd6..824101a77 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -318,6 +318,19 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res /// `load_checked`, the features computed under `validation` (`diagnostics.validation`), which /// the session keeps. fn load_with(path: &str, check: solidcraft_engine::schema::ParamCheck, validation: solidcraft_engine::kernel::Validation) -> Result { + let s = read_session(path, check, validation)?; + // A newer SolidCraft's file opens all the same; stderr says so, stdout stays the caller's. + for warning in s.doc.newer_file.iter().flat_map(|file| file.warnings()) { + eprintln!("{path}: warning: {warning}"); + } + Ok(s) +} + +fn read_session( + path: &str, + check: solidcraft_engine::schema::ParamCheck, + validation: solidcraft_engine::kernel::Validation, +) -> Result { let mut s = Session::default(); s.param_check = check; s.set_validation(validation); @@ -484,8 +497,10 @@ fn cmd_run(args: &[String]) -> Result<(), String> { // A feature that fails leaves the bodies as they were before it (a parameter change can // break a feature without failing the command that made it): writing them would hand out // the wrong part with success, so fail the run unless asked to write it anyway. + // A feature only a newer SolidCraft can build isn't a broken design: saving the design keeps it + // and hands out no part. Exporting bodies without it would, so that still stops. if !args.iter().any(|a| a == "--allow-errors") { - timeline_ok(&s)?; + timeline_ok(&s, flags(args, "--out").is_empty())?; } for out in flags(args, "--out") { s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?; @@ -561,8 +576,12 @@ fn known_args(cmd: &str, args: &[String], valued: &[&str], switches: &[&str], po } /// Every failing timeline feature, one per line, as an error; Ok when none fails. -fn timeline_ok(s: &Session) -> Result<(), String> { - let failed: Vec = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect(); +fn timeline_ok(s: &Session, skip_newer_features: bool) -> Result<(), String> { + let newer = |id: u64| { + skip_newer_features && s.doc.feature(id).is_some_and(|f| matches!(f.kind, solidcraft_engine::doc::FeatureKind::NewerFileFeature { .. })) + }; + let failed: Vec = + s.model.results.iter().filter(|r| !newer(r.id)).filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect(); if failed.is_empty() { return Ok(()); } @@ -579,7 +598,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> { let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?; let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?; let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?; - let out = json!({ + let mut out = json!({ "body_count": m["body_count"], "bodies": m["bodies"], "total": m["total"], @@ -587,6 +606,10 @@ fn cmd_eval(args: &[String]) -> Result<(), String> { "params": d["params"], "sketches": d["sketches"], }); + if let Some(file) = &s.doc.newer_file { + out["newer_file"] = file.report(); + out["warnings"] = json!(file.warnings()); + } print_json(&out, json_out) } diff --git a/apps/solidcraft-cli/tests/run_results.rs b/apps/solidcraft-cli/tests/run_results.rs index cb9846bfd..797af68e0 100644 --- a/apps/solidcraft-cli/tests/run_results.rs +++ b/apps/solidcraft-cli/tests/run_results.rs @@ -159,7 +159,7 @@ fn cli_loads_both_design_encodings_by_contents() { } /// #1369: a pending concept that did not land in the numbered release must not be silently -/// dropped on open/resave, in either encoding or before replacing an existing destination. +/// dropped on open/resave, in either encoding: it opens with a warning, and saving keeps it. #[test] fn unknown_pending_concepts_preserve_the_open_design_and_output_files() { use solidcraft_engine::{Session, doc, io}; @@ -173,34 +173,29 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() { let source = dir.0.join(format!("experimental-{version}.{extension}")); std::fs::write(&source, &bytes).unwrap(); let mut session = Session::default(); - session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap(); - let original = session.doc.clone(); - let original_path = session.path.clone(); - let undo = session.undo.len(); - let dirty = session.is_dirty(); - let err = session.execute("doc.open", &json!({"path": source})).unwrap_err().to_string(); - assert!(err.contains("experimental-card") && err.contains("update SolidCraft"), "{err}"); - assert_eq!(session.doc, original); - assert_eq!(session.path, original_path); - assert_eq!(session.undo.len(), undo); - assert_eq!(session.is_dirty(), dirty); + // It opens, and the reply says what it is. + let reply = session.execute("doc.open", &json!({"path": source})).unwrap(); + let notice = reply["newer_file"]["notice"].as_str().unwrap().to_string(); + assert!(notice.contains("experimental-card") && notice.starts_with("Saved by a newer SolidCraft"), "{notice}"); + assert_eq!(reply["warnings"][0], notice.as_str()); + assert_eq!(session.doc.name, "Experimental"); for existing in [false, true] { let destination = dir.0.join(format!("output-{version}-{extension}-{existing}.{extension}")); let backup = io::backup_path(&destination); if existing { std::fs::write(&destination, b"existing design").unwrap(); - std::fs::write(&backup, b"existing backup").unwrap(); } let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap(); - assert_eq!(output.status.code(), Some(1)); let error = String::from_utf8_lossy(&output.stderr); - assert!(error.contains("experimental-card") && error.contains("update SolidCraft"), "{error}"); + assert_eq!(output.status.code(), Some(0), "{error}"); + assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("experimental-card"), "{error}"); + // Saved, it still says it has the change, and opens the same way. + let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap(); + assert!(saved.format_pending.iter().any(|name| name == "experimental-card")); + assert!(saved.newer_file.is_some()); if existing { - assert_eq!(std::fs::read(&destination).unwrap(), b"existing design"); - assert_eq!(std::fs::read(&backup).unwrap(), b"existing backup"); - } else { - assert!(!destination.exists() && !backup.exists()); + assert_eq!(std::fs::read(&backup).unwrap(), b"existing design"); } assert_eq!(std::fs::read(&source).unwrap(), bytes); } @@ -208,6 +203,43 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() { } } +/// `eval` and `run` on a design one format newer, with a feature kind this version doesn't have: +/// the rest builds, the reply names it, and the saved file still holds it, under the file's number. +#[test] +fn the_cli_opens_a_newer_design_and_saving_keeps_what_it_cant_read() { + use solidcraft_engine::{Session, doc, io}; + + let dir = Scratch::new(); + let mut session = Session::default(); + session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap(); + let mut file: Value = serde_json::from_slice(&io::write_design(&session.doc)).unwrap(); + let newer = doc::format::format_string(doc::format::FORMAT + 1); + file["format"] = json!(newer); + let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5}); + file["features"].as_array_mut().unwrap().push(future.clone()); + let source = dir.0.join("newer.json"); + std::fs::write(&source, file.to_string()).unwrap(); + + let output = cli().args(["eval"]).arg(&source).output().unwrap(); + let error = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(0), "{error}"); + assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("Lattice1"), "{error}"); + let reply: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(reply["body_count"], 1); + assert!(reply["newer_file"]["not_understood"][0].as_str().unwrap().contains("gyroid_lattice"), "{reply}"); + assert!(reply["warnings"][0].as_str().unwrap().starts_with("Saved by a newer SolidCraft"), "{reply}"); + + for extension in ["json", "sc"] { + let destination = dir.0.join(format!("saved.{extension}")); + let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap(); + assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr)); + let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap(); + assert_eq!(saved.format, newer, "never a lower number"); + let again: Value = serde_json::from_slice(&io::write_design(&saved)).unwrap(); + assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future); + } +} + #[test] fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() { use solidcraft_engine::{doc, io}; diff --git a/assets/locales/pending/newer-file-opens.json b/assets/locales/pending/newer-file-opens.json new file mode 100644 index 000000000..2273939bc --- /dev/null +++ b/assets/locales/pending/newer-file-opens.json @@ -0,0 +1,32 @@ +{ + "en": { + "document.newer_file": "Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update.", + "document.newer_file_change": "Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update.", + "document.newer_file_not_read": "Not read: {what}. Saving keeps it." + }, + "ar": { + "document.newer_file": "تم الحفظ بإصدار أحدث من SolidCraft (التنسيق {format}، وهذا الإصدار يقرأ حتى {reads}). إذا كان هناك شيء مفقود، فحدّث البرنامج.", + "document.newer_file_change": "تم الحفظ بإصدار أحدث من SolidCraft (التنسيق {format} مع `{change}`، وهو ما لا يعرفه هذا الإصدار). إذا كان هناك شيء مفقود، فحدّث البرنامج.", + "document.newer_file_not_read": "لم تتم قراءة: {what}. الحفظ يحتفظ به." + }, + "he": { + "document.newer_file": "נשמר בגרסה חדשה יותר של SolidCraft (פורמט {format}, גרסה זו קוראת עד {reads}). אם משהו חסר, יש לעדכן.", + "document.newer_file_change": "נשמר בגרסה חדשה יותר של SolidCraft (פורמט {format} עם `{change}`, שגרסה זו אינה מכירה). אם משהו חסר, יש לעדכן.", + "document.newer_file_not_read": "לא נקרא: {what}. השמירה משאירה אותו בקובץ." + }, + "ja": { + "document.newer_file": "新しいバージョンの SolidCraft で保存されています(形式 {format}、このバージョンは {reads} まで読み取れます)。足りないものがあれば更新してください。", + "document.newer_file_change": "新しいバージョンの SolidCraft で保存されています(形式 {format}、このバージョンが認識しない `{change}` を含みます)。足りないものがあれば更新してください。", + "document.newer_file_not_read": "読み取れませんでした: {what}。保存しても失われません。" + }, + "vi": { + "document.newer_file": "Được lưu bằng phiên bản SolidCraft mới hơn (định dạng {format}, phiên bản này đọc được đến {reads}). Nếu thiếu gì, hãy cập nhật.", + "document.newer_file_change": "Được lưu bằng phiên bản SolidCraft mới hơn (định dạng {format} với `{change}`, phiên bản này không biết). Nếu thiếu gì, hãy cập nhật.", + "document.newer_file_not_read": "Không đọc được: {what}. Khi lưu, nội dung này vẫn được giữ lại." + }, + "zh": { + "document.newer_file": "由较新版本的 SolidCraft 保存(格式 {format},此版本最高可读取 {reads})。如有缺失,请更新。", + "document.newer_file_change": "由较新版本的 SolidCraft 保存(格式 {format},包含此版本未知的 `{change}`)。如有缺失,请更新。", + "document.newer_file_not_read": "未读取:{what}。保存时会保留它。" + } +} diff --git a/crates/doc/src/config_shell_tests.rs b/crates/doc/src/config_shell_tests.rs index 5333ac9b1..4c895bbd4 100644 --- a/crates/doc/src/config_shell_tests.rs +++ b/crates/doc/src/config_shell_tests.rs @@ -185,7 +185,7 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() { } assert_eq!(legacy, current, "the migration preserves every prior field"); legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1)); - assert!(crate::format::read(&legacy.to_string()).is_err()); + assert!(crate::format::read(&legacy.to_string()).unwrap().newer_file.is_some()); } #[test] diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index a1a6a580c..ecd65b077 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -665,6 +665,12 @@ impl AlignFrame { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] #[serde(tag = "type", rename_all = "snake_case")] pub enum FeatureKind { + /// A feature a newer SolidCraft saved that this build can't read ([`crate::newer_file`]): it + /// holds its place on the timeline, reports an error when evaluated, and goes back into the + /// file as it came. + NewerFileFeature { + raw: crate::newer_file::RawFeature, + }, /// A recorded timeline ownership boundary. Geometry is converted from its /// current local frame into the destination component frame exactly once. BodyTransfer { @@ -2608,6 +2614,7 @@ impl FeatureKind { /// Short type name (timeline tooltip, `inspect`). pub fn type_name(&self) -> &'static str { match self { + FeatureKind::NewerFileFeature { .. } => "NewerFileFeature", FeatureKind::BodyTransfer { .. } => "BodyTransferFeature", FeatureKind::Sketch { .. } => "Sketch", FeatureKind::Extrude { .. } => "ExtrudeFeature", @@ -2693,6 +2700,7 @@ impl FeatureKind { /// Design-file variant key (e.g. `"extrude"`, `"box"`, `"sheet_flange"`). pub fn kind_key(&self) -> &'static str { match self { + FeatureKind::NewerFileFeature { .. } => "newer_file_feature", FeatureKind::BodyTransfer { .. } => "body_transfer", FeatureKind::Sketch { .. } => "sketch", FeatureKind::Extrude { .. } => "extrude", @@ -2855,6 +2863,7 @@ impl FeatureKind { /// Default name prefix (`Extrude` → `Extrude1`). pub fn base_name(&self) -> &'static str { match self { + FeatureKind::NewerFileFeature { .. } => "Unknown", FeatureKind::BodyTransfer { .. } => "BodyTransfer", FeatureKind::Sketch { .. } => "Sketch", FeatureKind::Extrude { .. } => "Extrude", @@ -3127,6 +3136,10 @@ pub fn is_counted_body_name(name: &str) -> bool { /// The document. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { + /// Set when a newer SolidCraft saved the file this came from: what to tell the user, and what + /// this build couldn't read and keeps for the next save ([`crate::newer_file`]). + #[serde(skip)] + pub newer_file: Option>, /// Named image intent and immutable embedded HDR sources. Active jobs and device state are not saved. #[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")] pub render: std::sync::Arc, @@ -3488,6 +3501,7 @@ impl Document { pub fn new(name: &str) -> Self { Document { + newer_file: None, render: Default::default(), format: default_format(), format_pending: crate::format::pending_names(), @@ -3909,8 +3923,12 @@ impl Document { /// Read a design file's JSON (any format this build knows; older ones are upgraded). pub fn from_json(s: &str) -> Result { - crate::render_resources::check_json_budget(s)?; - crate::render_texture_resources::check_json_budget(s)?; + // A newer build's resources may not read here; the section stays out then (`newer_file`). + if let Err(why) = crate::render_resources::check_json_budget(s).and_then(|()| crate::render_texture_resources::check_json_budget(s)) + && !crate::format::says_newer(s) + { + return Err(why); + } let mut d = crate::format::read(s)?; if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS { return Err(DocError::Invalid("document too large".into())); @@ -3922,6 +3940,11 @@ impl Document { Ok(d) } pub fn to_json(&self) -> String { + if self.newer_file.is_some() { + let mut value = serde_json::to_value(self).unwrap_or_default(); + crate::newer_file::restore(self, &mut value); + return serde_json::to_string_pretty(&value).unwrap_or_default(); + } serde_json::to_string_pretty(self).unwrap_or_default() } diff --git a/crates/doc/src/drawing.rs b/crates/doc/src/drawing.rs index 2c7dfb4b9..e01873123 100644 --- a/crates/doc/src/drawing.rs +++ b/crates/doc/src/drawing.rs @@ -84,6 +84,65 @@ pub struct Drawing { pub language: String, /// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change. pub sheets: Vec>, + /// Set when a newer SolidCraft saved the file this came from. + #[serde(skip)] + pub newer_file: Option>, +} + +/// The fields a drawing's record has: anything else in a file is a newer build's. +const RECORD_FIELDS: [&str; 7] = ["format", "name", "sources", "properties", "units", "language", "sheets"]; + +/// Move the elements of the list `key` that don't read as `T` out of a newer build's drawing. +fn take_unread(top: &mut serde_json::Map, key: &str, one: &str, file: &mut NewerDrawing) { + let Some(Value::Array(list)) = top.get_mut(key) else { return }; + let mut readable = Vec::with_capacity(list.len()); + for element in std::mem::take(list) { + match DRAWING.check_numbers(&element, "properties").and_then(|()| serde_json::from_value::(element.clone()).map_err(damaged)) { + Ok(_) => readable.push(element), + Err(why) => { + let name = element.get("name").or(element.get("path")).and_then(Value::as_str).unwrap_or("unnamed"); + file.not_understood.push(format!("{one} `{name}` ({why})")); + file.elements.push((key.to_string(), element)); + } + } + } + *list = readable; +} + +/// What a drawing file says and holds that this build doesn't read ([`Drawing::from_newer`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NewerDrawing { + /// The number the file says. + pub format: u32, + /// The number this build reads up to. + pub reads_up_to: u32, + /// The changes the file names that this build doesn't have. + pub unknown_changes: Vec, + /// What this build couldn't read, by name. + pub not_understood: Vec, + fields: Vec<(String, Value)>, + elements: Vec<(String, Value)>, +} + +impl NewerDrawing { + /// What to tell the user. + pub fn notice(&self) -> String { + format!( + "Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.", + self.format, self.reads_up_to + ) + } + + fn restore(&self, value: &mut Value) { + for (key, field) in &self.fields { + value[key] = field.clone(); + } + for (key, element) in &self.elements { + if let Some(list) = value.get_mut(key).and_then(Value::as_array_mut) { + list.push(element.clone()); + } + } + } } /// A design the drawing draws. @@ -175,6 +234,7 @@ impl Drawing { units: Units::default(), language: language.into(), sheets: vec![Arc::new(first)], + newer_file: None, }; drawing.validate()?; Ok(drawing) @@ -188,11 +248,65 @@ impl Drawing { } let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?; let (version, applied) = DRAWING.take_version(&mut v)?; + if let Some(newer) = DRAWING.newer(version, applied.as_deref()) { + return Self::from_newer(v, version, applied, newer); + } + let d = Self::decode(v, version, applied.as_deref())?; + Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d }) + } + + fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result { DRAWING.check_numbers(&v, "properties")?; - DRAWING.upgrade(&mut v, version, applied.as_deref())?; + DRAWING.upgrade(&mut v, version, applied)?; let d: Drawing = serde_json::from_value(v).map_err(damaged)?; d.validate().map_err(damaged)?; - Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d }) + Ok(d) + } + + /// A drawing a newer build saved: it opens with what this build can read of it. Everything in a + /// drawing refuses a field it doesn't know, so what it can't read shows as a top-level field, + /// a source or a sheet that doesn't read, which stays out and is named in + /// [`NewerDrawing::not_understood`]; [`Drawing::to_json`] writes it all back, with the file's + /// format. It takes at least one sheet this build can read to open. + fn from_newer(mut v: Value, version: u32, applied: Option>, newer: crate::format::Newer) -> Result { + let list = applied.as_deref(); + let can_read = |v: Value| Self::decode(v, version, list).map_err(|e| e.to_string()); + let mut file = NewerDrawing { + format: newer.format, + reads_up_to: newer.reads_up_to, + unknown_changes: newer.unknown_changes.clone(), + ..NewerDrawing::default() + }; + let mut read = can_read(v.clone()); + if read.is_err() { + let top = v.as_object_mut().ok_or_else(|| damaged("expected a JSON object"))?; + let keys: Vec = top.keys().cloned().collect(); + for key in keys { + if !RECORD_FIELDS.contains(&key.as_str()) + && let Some(value) = top.remove(&key) + { + file.not_understood.push(format!("field `{key}`")); + file.fields.push((key, value)); + } + } + take_unread::(top, "sheets", "sheet", &mut file); + take_unread::(top, "sources", "source", &mut file); + read = can_read(v); + } + let d = read.map_err(|why| { + DocError::Invalid(format!( + "this drawing was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it", + newer.format, newer.reads_up_to + )) + })?; + let (format, format_pending) = if newer.format > newer.reads_up_to { + (DRAWING.format_string(newer.format), applied.unwrap_or_default()) + } else { + let mut pending = DRAWING.pending_names(); + pending.extend(newer.unknown_changes.iter().cloned()); + (DRAWING.format_string(DRAWING.current()), pending) + }; + Ok(Drawing { format, format_pending, newer_file: Some(Arc::new(file)), ..d }) } /// The drawing's JSON: the same bytes for the same drawing. JSON longer than a read takes @@ -200,7 +314,15 @@ impl Drawing { /// properties are kept as they came, and indenting them can make a drawing that read in /// under the limit save over it. pub fn to_json(&self) -> Result { - let text = serde_json::to_string_pretty(self).map_err(|e| DocError::Invalid(format!("drawing not saved: {e}")))?; + let unsaved = |e: serde_json::Error| DocError::Invalid(format!("drawing not saved: {e}")); + let text = match &self.newer_file { + None => serde_json::to_string_pretty(self).map_err(unsaved)?, + Some(file) => { + let mut value = serde_json::to_value(self).map_err(unsaved)?; + file.restore(&mut value); + serde_json::to_string_pretty(&value).map_err(unsaved)? + } + }; require(text.len() <= MAX_DRAWING_BYTES, || format!("drawing too large to save (at most {} MiB)", MAX_DRAWING_BYTES >> 20))?; Ok(text) } diff --git a/crates/doc/src/drawing/format/pending/README.md b/crates/doc/src/drawing/format/pending/README.md index 29e16f40f..e88232508 100644 --- a/crates/doc/src/drawing/format/pending/README.md +++ b/crates/doc/src/drawing/format/pending/README.md @@ -8,7 +8,7 @@ whole format bump: never edit the `DRAWING` family in `format.rs`, another chang - Name it after the change, lowercase with dashes: `sheet-views.rs`. - Start it with a `//!` note: what is saved, what an older drawing gets without it, and what - the last release does with a drawing that has it (it refuses the newer format number). + the last release does with a drawing that has it (it warns about the newer format number, opens what it can read and keeps the rest). - If older drawings must be rewritten as they open, add the step to the same file: ```rust diff --git a/crates/doc/src/drawing_tests.rs b/crates/doc/src/drawing_tests.rs index 3fce8289b..283d21d05 100644 --- a/crates/doc/src/drawing_tests.rs +++ b/crates/doc/src/drawing_tests.rs @@ -325,28 +325,28 @@ fn damaged_files_are_refused_and_never_patched() { let why = refused(&d, edit); assert!(why.starts_with("damaged drawing: ") && why.contains("unknown field"), "{i}: {why}"); } - // A newer build's file is refused as one before anything in it is read: a field this build - // doesn't know, or a number out of its range. + // A newer build's file isn't held to this build's rules: a field it doesn't know, or a sheet with + // a number out of range, stays out. With no sheet left to show, it says why. let (current, newer) = (DRAWING.current(), DRAWING.current() + 1); let why = refused(&d, |v| { v["format"] = json!(DRAWING.format_string(newer)); v["views"] = json!([]); - v["sheets"][0]["dpi"] = json!(1e10); }); - assert_eq!( - why, - format!( - "this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it" - ) + assert_eq!(why, "read"); + let why = refused(&d, |v| { + v["format"] = json!(DRAWING.format_string(newer)); + v["sheets"] = json!([{"id": "future", "a_future_field": true}]); + }); + assert!( + why.starts_with(&format!( + "this drawing was saved by a newer SolidCraft (format {newer}, this version reads up to {current}) and this version can't read it: " + )), + "{why}" ); // A list of pending changes means nothing beside a format made of none; beside the pending // number, a change this build doesn't know is a newer build's. let why = refused(&d, |v| v["format_pending"] = json!(["a-later-change"])); - if DRAWING.pending_names().is_empty() { - assert_eq!(why, "read"); - } else { - assert!(why.contains("newer SolidCraft"), "{why}"); - } + assert_eq!(why, "read"); // A design is never read as a drawing, nor a drawing as a design. let design = crate::Document::new("Plate").to_json(); let design_format = crate::format::format_string(crate::format::FORMAT); @@ -551,3 +551,32 @@ fn mutate(v: &mut Value, pick: &mut usize, lie: &Value, how: u64) -> bool { _ => false, } } + +/// A drawing a newer build saved opens, with what it can read, and saving keeps the rest. +#[test] +fn a_newer_builds_drawing_opens_and_keeps_what_it_cant_read() { + let d = plate(); + let mut file: Value = file(&d); + assert!(file.as_object().unwrap().keys().all(|key| RECORD_FIELDS.contains(&key.as_str())), "a new field goes in RECORD_FIELDS"); + let newer = DRAWING.format_string(DRAWING.current() + 1); + file["format"] = json!(newer); + // With nothing it can't read: the same drawing. + let opened = Drawing::from_json(&file.to_string()).unwrap(); + assert_eq!(opened.sheets, d.sheets); + assert!(opened.newer_file.as_ref().unwrap().notice().starts_with("Saved by a newer SolidCraft")); + assert_eq!(serde_json::from_str::(&opened.to_json().unwrap()).unwrap()["format"], json!(newer)); + // A field and a second sheet it can't read stay out, named, and go back. + file["views"] = json!([{"id": "a"}]); + let mut future_sheet = file["sheets"][0].clone(); + future_sheet["name"] = json!("Future"); + future_sheet["future_layers"] = json!([1]); + file["sheets"].as_array_mut().unwrap().push(future_sheet); + let opened = Drawing::from_json(&file.to_string()).unwrap(); + assert_eq!(opened.sheets, d.sheets); + let said = &opened.newer_file.as_ref().unwrap().not_understood; + assert!(said.iter().any(|what| what.contains("views")) && said.iter().any(|what| what.contains("Future")), "{said:?}"); + let again: Value = serde_json::from_str(&opened.to_json().unwrap()).unwrap(); + assert_eq!(again["views"], file["views"]); + assert_eq!(again["sheets"].as_array().unwrap().len(), d.sheets.len() + 1); + assert_eq!(again["format"], json!(newer)); +} diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 543a89a0d..808ba6044 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -7257,6 +7257,7 @@ fn eval_feature( // Its tools meet bodies in other placements where those are shown (#3383). let _frames = ToolFrames::install(doc, f, st); match &f.kind { + FeatureKind::NewerFileFeature { raw } => Err(DocError::Invalid(raw.why())), FeatureKind::BodyTransfer { bodies, from, to, transform } => { crate::body_transfer::evaluate(doc, st, &crate::plain(bodies), *from, *to, *transform) } diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index c166586a6..f592fbe1d 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -29,6 +29,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { let hole = |h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h}); vec![ + ("newer_file_feature", json!({"type": "newer_file_feature", "raw": {"type": "gyroid_lattice", "cell": 2}})), ( "body_transfer", json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}), diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index c940c102b..94ef147e4 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -1,3 +1,4 @@ +newer_file_feature body_transfer sketch inputs: diff --git a/crates/doc/src/feature_walk_tests/golden_geometry.txt b/crates/doc/src/feature_walk_tests/golden_geometry.txt index b49d86e1d..555ef3bcb 100644 --- a/crates/doc/src/feature_walk_tests/golden_geometry.txt +++ b/crates/doc/src/feature_walk_tests/golden_geometry.txt @@ -1,3 +1,4 @@ +newer_file_feature body_transfer sketch moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2] diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index ca57f8315..cc7c9ba35 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -1,3 +1,4 @@ +newer_file_feature body_transfer remap_bodies: bodies[0] sketch diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 58fe1bf72..60054b1c9 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -2,8 +2,9 @@ //! //! A design file starts with `"format": "solidcraft/N"`. A file of an older format is read and //! upgraded step by step (N → N+1 → … → current): JSON-level steps first, for changes in the -//! file's shape, then document-level steps. A file of a newer format than this build knows is -//! refused with a message saying so, never guessed at. +//! file's shape, then document-level steps. A file a newer build saved opens too, with a warning: +//! what this build reads is read, what it doesn't is reported by name and kept for the next save +//! (`crate::newer_file`). //! //! History: //! - 1: every design up to 2026-10-08 (format string since M0). Fields added over time all @@ -264,7 +265,7 @@ const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, p /// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has /// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them); -/// reading a file's format, refusing a file a newer build saved and the upgrade are the same code +/// reading a file's format, telling a file a newer build saved and the upgrade are the same code /// for every family. pub struct Family<'a> { /// What a file of the family is called in messages. @@ -317,8 +318,8 @@ impl Family<'_> { } /// Read a file's format and take its `format_pending` out of its JSON, before anything else - /// in it is read: a file that isn't this family's, or that a newer build saved, is refused - /// here, so nothing a newer build saved is judged by this build's rules. + /// in it is read: a file that isn't this family's is refused here. One a newer build saved is + /// not: [`Family::newer`] says so. pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option>)> { let noun = self.noun; let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})")); @@ -329,10 +330,19 @@ impl Family<'_> { Some(_) => return Err(not_ours("format is not a string".into())), }; let applied = take_names(v, "format_pending", noun)?; - self.formats.check(noun, version, applied.as_deref())?; + if version == 0 { + return Err(not_ours("format 0".into())); + } Ok((version, applied)) } + /// What says a file of format `version` with the changes `applied` came from a newer build: + /// a number past this build's, or a change this build doesn't have anywhere in its history. + /// `None` for a file this build fully knows. + pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option { + self.formats.newer(version, applied) + } + /// Bring a file of format `version` up to this build's; `applied` is its `format_pending` /// (both from [`Family::take_version`]). pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> { @@ -350,6 +360,17 @@ impl Family<'_> { } } +/// How a file shows a newer build saved it ([`Family::newer`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Newer { + /// The number the file says. + pub format: u32, + /// The number this build reads up to. + pub reads_up_to: u32, + /// The changes the file names that this build doesn't have. + pub unknown_changes: Vec, +} + /// The names of the pending changes this build writes into a design (`format_pending`). pub fn pending_names() -> Vec { DESIGN.pending_names() @@ -394,35 +415,25 @@ impl Formats<'_> { } } - /// Refuse a `noun` of format `version` that this build can't read: format 0, a newer number, - /// or a change `applied` (its `format_pending`) names that this build doesn't have anywhere in - /// its history, which only a newer build's file can. Changes are recognized across the entire - /// history: a branch's pending change may have landed at a later number. A list beside a format - /// that isn't made of changes means nothing (a tool or a test set an older number on a newer - /// file). - fn check(&self, noun: &str, version: u32, applied: Option<&[String]>) -> Result<()> { - let newer = |what: String| DocError::Invalid(format!("this {noun} was saved by a newer SolidCraft ({what}): update SolidCraft to open it")); + /// [`Family::newer`]. Changes are recognized across the entire history: a branch's pending + /// change may have landed at a later number. A list beside a format that isn't made of changes + /// means nothing (a tool or a test set an older number on a newer file). + fn newer(&self, version: u32, applied: Option<&[String]>) -> Option { let current = self.current(); - if version == 0 { - return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)"))); - } - if version > current { - return Err(newer(format!("file format {version}; this version reads formats up to {current}"))); - } - let applied = applied.filter(|_| self.parts(version).is_some()).unwrap_or_default(); - match applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) { - Some(unknown) => Err(newer(format!("file format {version} with `{unknown}`, which this version doesn't know"))), - None => Ok(()), - } + let listed = applied.filter(|_| version > current || self.parts(version).is_some()).unwrap_or_default(); + let unknown_changes: Vec = + listed.iter().filter(|name| !self.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect(); + (version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes }) } /// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's /// `format_pending`: the changes of its format it already has, when it was written while they - /// were pending; the others' steps run first. A file [`Formats::check`] refuses is refused, - /// whoever calls, before anything is changed; already-applied changes never run again at - /// their later number. + /// were pending; the others' steps run first, and already-applied changes never run again at + /// their later number. A file past this build's number has no steps to run. fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> { - self.check(noun, version, applied)?; + if version == 0 { + return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)"))); + } // A list beside a format that isn't made of changes is ignored, like any field an upgrade // drops. let applied = applied.filter(|_| self.parts(version).is_some()); @@ -677,18 +688,53 @@ fn v2_to_v3(v: &mut Value) -> Result<()> { pub fn read(s: &str) -> Result { let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?; let (version, applied) = DESIGN.take_version(&mut v)?; - check_design_numbers(&v)?; - crate::config::validate_rules_json(&v)?; - crate::config::validate_styles_json(&v)?; - crate::face_anchor::validate_face_anchors_json(&v)?; - validate_versioned_geometry(&v)?; - validate_emboss_targets(&v)?; - validate_freeform_offsets(&v)?; - validate_sketch_sizes(&v)?; - crate::format_offsets::validate_raw(&v)?; - let legacy_wires = validate_wires(&v, version)?; + match DESIGN.newer(version, applied.as_deref()) { + Some(newer) => crate::newer_file::read(v, version, applied, newer), + None => decode(v, version, applied.as_deref()), + } +} + +/// Whether a design's text says a newer build saved it, read before anything else in it is. +pub fn says_newer(text: &str) -> bool { + #[derive(serde::Deserialize)] + struct Says { + format: Option, + format_pending: Option>, + } + serde_json::from_str::(text) + .ok() + .and_then(|says| Some((DESIGN.version_of(&says.format?)?, says.format_pending))) + .is_some_and(|(version, applied)| DESIGN.newer(version, applied.as_deref()).is_some()) +} + +/// The checks of a design's JSON that need nothing outside the part of it they are given. +pub(crate) fn check_raw(v: &Value, version: u32) -> Result> { + check_design_numbers(v)?; + crate::config::validate_rules_json(v)?; + crate::config::validate_styles_json(v)?; + crate::face_anchor::validate_face_anchors_json(v)?; + validate_versioned_geometry(v)?; + validate_emboss_targets(v)?; + validate_freeform_offsets(v)?; + validate_sketch_sizes(v)?; + crate::format_offsets::validate_raw(v)?; + validate_wires(v, version) +} + +/// [`check_raw`], the upgrade and the typed record alone: what a piece of a design must pass by +/// itself ([`crate::newer_file`]), with nothing else in the file for it to refer to. +pub(crate) fn check_piece(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<()> { + check_raw(&v, version)?; + DESIGN.upgrade(&mut v, version, applied)?; + let d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?; + check_typed_numbers(&d) +} + +/// A design's JSON, its format taken out, as the document it holds. +pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result { + let legacy_wires = check_raw(&v, version)?; validate_body_transfers(&v)?; - DESIGN.upgrade(&mut v, version, applied.as_deref())?; + DESIGN.upgrade(&mut v, version, applied)?; let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?; check_typed_numbers(&d)?; let mut legacy_by_feature = std::collections::BTreeMap::>::new(); @@ -1327,9 +1373,9 @@ mod tests { let mut v = serde_json::json!({}); formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap(); assert_eq!(trace_of(&v), Vec::<&str>::new()); - // A change this build doesn't know is a newer build's: refused, not guessed at. - let err = formats.upgrade("design", &mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err(); - assert!(err.to_string().contains("newer SolidCraft"), "{err}"); + // A change this build doesn't know is a newer build's: said, and kept. + let said = formats.newer(next, Some(&["c-later".into()])).unwrap(); + assert_eq!(said, Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] }); // A list on a format that isn't made of changes means nothing. let mut v = serde_json::json!({}); formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap(); @@ -1372,15 +1418,12 @@ mod tests { } #[test] - fn personal_card_alias_without_support_refuses_before_mutation() { + fn personal_card_alias_without_support_is_a_newer_builds() { let hand: [JsonStep; 27] = [|_| Ok(()); 27]; let first = [traced("a-first")]; let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] }; - let mut file = serde_json::json!({"trace": [], "sentinel": 7}); - let original = file.clone(); - let error = formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err(); - assert!(error.to_string().contains("physical-material-cards"), "{error}"); - assert_eq!(file, original); + let said = formats.newer(29, Some(&["physical-material-cards".into()])).unwrap(); + assert_eq!(said.unknown_changes, ["physical-material-cards"]); } #[test] @@ -1391,11 +1434,8 @@ mod tests { let cards = [traced("material-engineering-cards")]; let formats = Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] }; for version in [28, 30] { - let mut file = serde_json::json!({"trace": []}); - let original = file.clone(); - let error = formats.upgrade("design", &mut file, version, Some(&["physical-material-cards".into()])).unwrap_err(); - assert!(error.to_string().contains("physical-material-cards"), "{error}"); - assert_eq!(file, original); + let said = formats.newer(version, Some(&["physical-material-cards".into()])).unwrap(); + assert_eq!(said.unknown_changes, ["physical-material-cards"]); } } @@ -1408,11 +1448,7 @@ mod tests { for names in [["physical-material-cards".into(), "unknown-card-codec".into()], ["unknown-card-codec".into(), "physical-material-cards".into()]] { - let mut file = serde_json::json!({"trace": ["b"]}); - let original = file.clone(); - let error = formats.upgrade("design", &mut file, 29, Some(&names)).unwrap_err(); - assert!(error.to_string().contains("unknown-card-codec"), "{error}"); - assert_eq!(file, original); + assert_eq!(formats.newer(29, Some(&names)).unwrap().unknown_changes, ["unknown-card-codec"]); } } @@ -1446,18 +1482,14 @@ mod tests { } /// An experimental producer can use a number that a release later folds without its - /// change. Its unknown concept must still be refused before any migration runs. + /// change. Its unknown concept is still a newer build's. #[test] - fn unknown_pending_concepts_are_refused_after_folding() { + fn unknown_pending_concepts_are_said_after_folding() { let parts = [traced("a-first")]; let later = [traced("b-second")]; for pending in [&[][..], &later[..]] { let formats = Formats { hand: &[], folded: &[(2, &parts)], pending }; - let mut file = serde_json::json!({"trace": []}); - let original = file.clone(); - let err = formats.upgrade("design", &mut file, 2, Some(&["experimental-card".into()])).unwrap_err(); - assert!(err.to_string().contains("experimental-card") && err.to_string().contains("update SolidCraft"), "{err}"); - assert_eq!(file, original, "refuse before applying even a known migration"); + assert_eq!(formats.newer(2, Some(&["experimental-card".into()])).unwrap().unknown_changes, ["experimental-card"]); } } @@ -1479,23 +1511,20 @@ mod tests { } } - /// A design naming a pending change this build doesn't have is a newer build's, refused as - /// such before anything else in it is checked (as a newer number is), so a newer build's - /// fields never read as damage. + /// A design naming a pending change this build doesn't have is a newer build's: what it can't + /// read of it doesn't read as damage. #[test] - fn a_newer_builds_pending_change_is_refused_before_the_design_is_checked() { + fn a_newer_builds_pending_change_opens_and_its_fields_are_not_damage() { let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap(); file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]); file["features"] = serde_json::json!([{"type": "emboss", "face": {"body": "Body", "at": [0, 0, 0], "a_newer_key": true}}]); - let error = read(&file.to_string()).unwrap_err().to_string(); + let opened = read(&file.to_string()).unwrap(); // With changes pending, FORMAT is made of them; once they are folded it is the folded // format, which is made of changes too. Either way an unknown change is a newer build's. - assert_eq!( - error, - format!( - "this design was saved by a newer SolidCraft (file format {FORMAT} with `not-a-change-anyone-made`, which this version doesn't know): update SolidCraft to open it" - ) - ); + let said = opened.newer_file.as_ref().unwrap(); + assert_eq!(said.unknown_changes, ["not-a-change-anyone-made"]); + assert!(said.notice().contains(&format!("format {FORMAT} with `not-a-change-anyone-made`")), "{}", said.notice()); + assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. })); } /// Drawings follow the same rules from their own first format: folded formats follow on from 1 @@ -1575,17 +1604,17 @@ mod tests { fn tagged(family: &Family<'_>, n: u32) -> Value { serde_json::json!({"format": family.format_string(n)}) } - fn newer(noun: &str, n: u32, current: u32) -> String { - format!( - "this {noun} was saved by a newer SolidCraft (file format {n}; this version reads formats up to {current}): update SolidCraft to open it" - ) - } - assert_eq!(refusal(&DESIGN, tagged(&DESIGN, FORMAT + 1)), newer("design", FORMAT + 1, FORMAT)); - assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap_err().to_string(), newer("design", FORMAT + 1, FORMAT)); + // A newer number isn't refused: it is said. + let says = |family: &Family<'_>, mut file: Value| { + let (version, applied) = family.take_version(&mut file).unwrap(); + family.newer(version, applied.as_deref()) + }; + assert_eq!(says(&DESIGN, tagged(&DESIGN, FORMAT + 1)), Some(Newer { format: FORMAT + 1, reads_up_to: FORMAT, unknown_changes: vec![] })); + assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1); let current = DRAWING.current(); assert!(matches!(DRAWING.take_version(&mut tagged(&DRAWING, current)), Ok((v, None)) if v == current)); for n in [current + 1, u32::MAX] { - assert_eq!(refusal(&DRAWING, tagged(&DRAWING, n)), newer("drawing", n, current)); + assert_eq!(says(&DRAWING, tagged(&DRAWING, n)), Some(Newer { format: n, reads_up_to: current, unknown_changes: vec![] })); } for (family, noun) in [(&DESIGN, "design"), (&DRAWING, "drawing")] { assert_eq!(refusal(family, tagged(family, 0)), format!("not a SolidCraft {noun} (format 0)")); @@ -1607,12 +1636,7 @@ mod tests { let design = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN }; let next = design.current(); let file = json!({"format": format_string(next), "format_pending": ["c-later"]}); - assert_eq!( - refusal(&design, file), - format!( - "this design was saved by a newer SolidCraft (file format {next} with `c-later`, which this version doesn't know): update SolidCraft to open it" - ) - ); + assert_eq!(says(&design, file), Some(Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] })); assert!(matches!(DESIGN.take_version(&mut json!({})), Ok((1, None)))); assert_eq!(refusal(&DRAWING, json!({})), "not a SolidCraft drawing (no format)"); } @@ -1639,19 +1663,13 @@ mod tests { assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first"]})).unwrap(), Vec::::new()); // A list beside format 1 means nothing: the step still runs. assert_eq!(open(json!({"format": "solidcraft-drawing/1", "format_pending": ["c-later"]})).unwrap(), ["a"]); - let error = open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first", "c-later"]})).unwrap_err().to_string(); - assert_eq!( - error, - "this drawing was saved by a newer SolidCraft (file format 2 with `c-later`, which this version doesn't know): update SolidCraft to open it" - ); - let error = open(json!({"format": "solidcraft-drawing/3"})).unwrap_err().to_string(); - assert_eq!( - error, - "this drawing was saved by a newer SolidCraft (file format 3; this version reads formats up to 2): update SolidCraft to open it" - ); - // An upgrade called without `take_version` refuses in the drawing's words too, format 0 included. - let error = drawing.upgrade(&mut json!({}), 3, None).unwrap_err().to_string(); - assert!(error.starts_with("this drawing was saved by a newer SolidCraft (file format 3;"), "{error}"); + // Of a newer build: said, and what can run runs. + let list = ["a-first".to_string(), "c-later".to_string()]; + assert_eq!(drawing.newer(2, Some(&list)).unwrap().unknown_changes, ["c-later"]); + assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": list})).unwrap(), Vec::::new()); + assert_eq!(drawing.newer(3, None).unwrap(), Newer { format: 3, reads_up_to: 2, unknown_changes: vec![] }); + assert_eq!(open(json!({"format": "solidcraft-drawing/3"})).unwrap(), Vec::::new()); + // An upgrade called without `take_version` still says format 0 isn't one. assert_eq!(drawing.upgrade(&mut json!({}), 0, None).unwrap_err().to_string(), "not a SolidCraft drawing (format 0)"); } @@ -1666,7 +1684,8 @@ mod tests { assert!(read(&file.to_string()).is_err()); } file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]); - assert!(read(&file.to_string()).is_err(), "unknown pending concepts are refused even after folding"); + let newer = read(&file.to_string()).unwrap(); + assert_eq!(newer.newer_file.as_ref().unwrap().unknown_changes, ["not-a-change-anyone-made"], "even after folding"); file["format_pending"] = Value::from(pending_names()); let opened = read(&file.to_string()).unwrap(); assert_eq!(opened.format_pending, pending_names()); @@ -2138,8 +2157,9 @@ mod tests { value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]); let error = read(&value.to_string()).unwrap_err().to_string(); assert!(error.contains("free-form offset metadata"), "{error}"); + // A newer build's file isn't held to this: the feature waits for that build. value["format"] = Value::from(format_string(FORMAT + 1)); - assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolidCraft")); + assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. })); } } @@ -2163,8 +2183,7 @@ mod tests { let error = read(&value.to_string()).unwrap_err().to_string(); assert!(error.contains("emboss face"), "{error}"); value["format"] = Value::from(format_string(FORMAT + 1)); - let error = read(&value.to_string()).unwrap_err().to_string(); - assert!(error.contains("newer SolidCraft"), "{error}"); + assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. })); } } @@ -2207,7 +2226,7 @@ mod tests { } #[test] - fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() { + fn version_seven_styles_upgrade_and_a_newer_versions_columns_are_kept() { use crate::config::{Column, ConfigRow}; let mut d = Document::new("configured styles"); d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())]; @@ -2227,9 +2246,11 @@ mod tests { value["format"] = Value::from(format_string(FORMAT + 1)); value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]); value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true}); - let error = read(&value.to_string()).unwrap_err().to_string(); - assert!(error.contains("newer SolidCraft") && error.contains("update SolidCraft"), "{error}"); - assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants"); + let opened = read(&value.to_string()).unwrap(); + let said = opened.newer_file.as_ref().unwrap(); + assert!(said.not_understood.iter().any(|what| what.contains("configs")), "{:?}", said.not_understood); + let saved: Value = serde_json::from_str(&opened.to_json()).unwrap(); + assert_eq!(saved["configs"], value["configs"], "kept for the build that reads them"); } #[test] @@ -2284,8 +2305,10 @@ mod tests { assert_eq!(read(&saved.to_string()).unwrap(), d); saved["format"] = Value::from(format_string(FORMAT + 1)); saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension"); - let error = read(&saved.to_string()).unwrap_err().to_string(); - assert!(error.contains("newer SolidCraft") && !error.contains("unknown variant"), "{error}"); + let opened = read(&saved.to_string()).unwrap(); + assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. })); + let again: Value = serde_json::from_str(&opened.to_json()).unwrap(); + assert_eq!(again["features"][0], saved["features"][0]); } #[test] @@ -2392,15 +2415,14 @@ mod tests { } #[test] - fn older_upgraded_newer_refused_others_rejected() { + fn older_upgraded_newer_opened_others_rejected() { let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap(); v["format"] = Value::from("solidcraft/1"); assert_eq!(read(&v.to_string()).unwrap().format, format_string(FORMAT)); v.as_object_mut().unwrap().remove("format"); assert!(read(&v.to_string()).is_ok(), "a file without a format is format 1"); v["format"] = Value::from(format_string(FORMAT + 1)); - let e = read(&v.to_string()).unwrap_err().to_string(); - assert!(e.contains("newer SolidCraft"), "{e}"); + assert_eq!(read(&v.to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1); for bad in ["solidcraft/0", "acad/3", "solidcraft/-1", "solidcraft/99999999999999999999"] { v["format"] = Value::from(bad); assert!(read(&v.to_string()).is_err(), "{bad}"); diff --git a/crates/doc/src/format/pending/README.md b/crates/doc/src/format/pending/README.md index 824a6e2c4..5e1b21191 100644 --- a/crates/doc/src/format/pending/README.md +++ b/crates/doc/src/format/pending/README.md @@ -5,7 +5,7 @@ here is the whole format bump: never edit `FORMAT`, `JSON_STEPS` or another chan - Name it after the change, lowercase with dashes: `placed-plane-origins.rs`. - Start it with a `//!` note: what is saved, what an older design gets without it, and what - the last release does with a design that has it (it refuses the newer format number). + the last release does with a design that has it (it warns about the newer format number, opens what it can read and keeps the rest). - If older designs must be rewritten as they open, add the step to the same file: ```rust diff --git a/crates/doc/src/format_offsets.rs b/crates/doc/src/format_offsets.rs index 2e398295a..b96f20c53 100644 --- a/crates/doc/src/format_offsets.rs +++ b/crates/doc/src/format_offsets.rs @@ -399,7 +399,7 @@ mod tests { damaged["features"][1]["profiles"]["associations"] = associations; assert!(validate_raw(&damaged).is_err()); damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1)); - assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolidCraft")); + assert!(crate::format::read(&damaged.to_string()).unwrap().newer_file.is_some()); } // Absent metadata preserves the historical one-to-one chain representation. let mut legacy = fixture(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 299af55db..e88aafc4d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ mod assembly; mod body_transfer; mod body_transfer_sheet; mod browser_tree; +pub mod newer_file; pub mod pbr_appearance; pub use body_transfer::{valid_transfer_matrix, validate_body_transfer}; pub mod canvas; diff --git a/crates/doc/src/newer_file.rs b/crates/doc/src/newer_file.rs new file mode 100644 index 000000000..6ef106436 --- /dev/null +++ b/crates/doc/src/newer_file.rs @@ -0,0 +1,530 @@ +//! A design a newer SolidCraft saved: it opens, with a warning, and saving it loses nothing. +//! +//! A newer format number, or a change the file names that this build doesn't have, says a newer +//! build saved the file. It may use nothing this build lacks, so it is read as usual. When part of +//! it can't be read, that part stays out of the way and the rest opens: +//! - a feature this build can't read keeps its place on the timeline as +//! [`FeatureKind::NewerFileFeature`](crate::FeatureKind::NewerFileFeature), which reports an error when evaluated; +//! - a parameter it can't read is left out; +//! - any other top-level section it can't read is left out whole; +//! - a field it doesn't know, which typed reading would drop, is noticed by comparing the file +//! with what was read. +//! +//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is +//! saved ([`restore`]), as they came. Saving keeps the file's own format number and change list: +//! the file still holds what the newer build wrote, and a lower number would make that build +//! upgrade its own data as if it were old. What can't be put back because the design changed around +//! it ([`not_kept`]) is said in the reply to the save. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::format::{self, DESIGN, Newer}; +use crate::{DocError, Document, Result}; + +/// How a [`RawFeature`] is written while it is inside this build; [`restore`] unwraps it. +const FOREIGN_TAG: &str = "newer_file_feature"; +/// Sections a design can't open without: filled in when the file's can't be read. +const CORE: [&str; 3] = ["name", "params", "features"]; +/// The most sections left out one at a time to find the one a design can't be read with. +const MAX_SECTION_TRIES: usize = 64; +/// Fields a design container adds beside what a design holds; not unknown to this build. +const CONTAINER_ONLY: [&str; 6] = ["step_blob", "mesh_blob", "image_blob", "points_blob", "hdr_blob", "texture_blob"]; + +/// A feature as a newer build wrote it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RawFeature(pub Box); + +impl RawFeature { + /// The kind of feature it says it is. + pub fn kind(&self) -> &str { + self.0.get("type").and_then(Value::as_str).unwrap_or("unnamed") + } + + /// Why it doesn't build here. + pub fn why(&self) -> String { + format!("saved by a newer SolidCraft: this version can't read a `{}` feature. Update SolidCraft to build it; saving keeps it", self.kind()) + } +} + +/// What the file says and holds that this build doesn't read. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NewerFile { + /// The number the file says. + pub format: u32, + /// The number this build reads up to. + pub reads_up_to: u32, + /// The changes the file names that this build doesn't have. + pub unknown_changes: Vec, + /// What this build couldn't read, by name. + pub not_understood: Vec, + /// Fields known records lost in typed reading. + kept: Option, + /// Top-level sections left out whole. + sections: Vec
, + /// Elements left out of a top-level list: its key and the element. + elements: Vec<(String, Value)>, + /// The parts of a design container this build has no use for: name and bytes. A blob only one + /// of the kept pieces refers to is one of them. + pub parts: Vec<(String, Arc<[u8]>)>, + /// The file's `container.json`, when this build doesn't know what it says. + pub container: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct Section { + key: String, + value: Value, + /// This build's record of the section, without the file's: while it is unchanged, the file's + /// goes back. + empty: Option, +} + +/// The fields of a record that typed reading dropped. +#[derive(Clone, Debug, PartialEq)] +enum Kept { + Field(Value), + Fields(BTreeMap), + Items(Vec<(Anchor, Kept)>), +} + +/// Which element of a list some kept fields belong to. +#[derive(Clone, Debug, PartialEq)] +enum Anchor { + Id(u64), + Name(String), + /// Its place, good only while the list keeps its length. + Index { + at: usize, + of: usize, + }, +} + +impl NewerFile { + /// What to tell the user, whatever else is said. + pub fn notice(&self) -> String { + let reads = self.reads_up_to; + let format = self.format; + match self.unknown_changes.first() { + Some(change) if format <= reads => { + format!( + "Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update." + ) + } + _ => format!("Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update."), + } + } + + /// Every line to report: the notice, then what couldn't be read, by name. + pub fn warnings(&self) -> Vec { + let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it.")); + std::iter::once(self.notice()).chain(unread).collect() + } + + /// What a reply says about it. + pub fn report(&self) -> Value { + json!({ + "format": self.format, + "reads_up_to": self.reads_up_to, + "unknown_changes": self.unknown_changes, + "not_understood": self.not_understood, + "notice": self.notice(), + }) + } + + /// Whether the next save needs a container to keep what the file holds: plain JSON has no room + /// for parts beside the design. + pub fn needs_container(&self) -> bool { + !self.parts.is_empty() + } + + /// Whether anything kept back names this text (a blob's name in a container). + pub fn mentions(&self, text: &str) -> bool { + fn value(v: &Value, text: &str) -> bool { + match v { + Value::String(s) => s == text, + Value::Array(a) => a.iter().any(|x| value(x, text)), + Value::Object(o) => o.values().any(|x| value(x, text)), + _ => false, + } + } + fn kept(k: &Kept, text: &str) -> bool { + match k { + Kept::Field(v) => value(v, text), + Kept::Fields(m) => m.values().any(|x| kept(x, text)), + Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)), + } + } + self.kept.as_ref().is_some_and(|k| kept(k, text)) + || self.sections.iter().any(|s| value(&s.value, text)) + || self.elements.iter().any(|(_, v)| value(v, text)) + } + + /// Say that a section the container reader couldn't finish reading is left out. + /// `empty` is what the design writes for the section while it holds nothing, if anything. + pub fn keep_section(&mut self, key: &str, value: Value, empty: Option, why: &str) { + self.not_understood.push(format!("section `{key}` ({why})")); + self.sections.push(Section { key: key.into(), value, empty }); + } +} + +/// Read a design a newer build saved, or say why it truly can't be read. +pub(crate) fn read(mut raw: Value, version: u32, applied: Option>, newer: Newer) -> Result { + let applied_names = applied.as_deref(); + let mut file = + NewerFile { format: newer.format, reads_up_to: newer.reads_up_to, unknown_changes: newer.unknown_changes.clone(), ..NewerFile::default() }; + let (mut doc, read_as) = match format::decode(raw.clone(), version, applied_names) { + Ok(doc) => (doc, raw), + Err(_) => { + let mut foreign = BTreeMap::new(); + let mut doc = salvage(&mut raw, version, applied_names, &mut file, &mut foreign).map_err(|why| { + DocError::Invalid(format!( + "this design was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it", + newer.format, newer.reads_up_to + )) + })?; + fill_foreign(&mut doc, foreign); + (doc, raw) + } + }; + let mut upgraded = read_as; + // Compared with what was read, the file's own shape after the same upgrade. + DESIGN.upgrade(&mut upgraded, version, applied_names)?; + let known = serde_json::to_value(&doc).map_err(|e| DocError::Invalid(format!("design file: {e}")))?; + let mut paths = Vec::new(); + file.kept = vanished(&upgraded, &known, "", &mut paths); + file.not_understood.extend(paths); + for section in &mut file.sections { + section.empty = known.get(§ion.key).cloned(); + } + if newer.format > newer.reads_up_to { + // The file's own number and list: it still holds what that build wrote. + doc.format = DESIGN.format_string(newer.format); + doc.format_pending = applied.unwrap_or_default(); + } else { + // Upgraded to this build's format; the changes it doesn't know are still in it. + doc.format_pending.extend(newer.unknown_changes.iter().cloned()); + } + doc.newer_file = Some(Arc::new(file)); + Ok(doc) +} + +/// Read the design around what can't be read, which moves to `file`. `raw` ends as what was read. +fn salvage( + raw: &mut Value, + version: u32, + applied: Option<&[String]>, + file: &mut NewerFile, + foreign: &mut BTreeMap, +) -> std::result::Result { + let piece = |key: &str, value: &Value| { + let mut stub = json!({"name": "", "params": [], "features": []}); + stub[key] = value.clone(); + format::check_piece(stub, version, applied) + }; + let Some(top) = raw.as_object_mut() else { return Err("expected a JSON object".into()) }; + // A feature it can't read keeps its place; a parameter it can't read stays out. + if let Some(Value::Array(features)) = top.get_mut("features") { + let mut free = features.iter().filter_map(|f| f.get("id").and_then(Value::as_u64)).max().unwrap_or(0).saturating_add(1); + for feature in features.iter_mut() { + if let Err(why) = piece("features", &json!([feature])) { + file.not_understood.push(feature_note(feature, &why.to_string())); + *feature = wrapped(std::mem::take(feature), &mut free, foreign); + } + } + } + if let Some(Value::Array(params)) = top.get_mut("params") { + params.retain(|param| match piece("params", &json!([param])) { + Ok(()) => true, + Err(why) => { + let name = param.get("name").and_then(Value::as_str).unwrap_or("unnamed"); + file.not_understood.push(format!("parameter `{name}` ({})", short(&why.to_string()))); + file.elements.push(("params".into(), param.clone())); + false + } + }); + } + // Any other section, by itself. + let keys: Vec = top.keys().cloned().collect(); + for key in &keys { + let value = top.get(key).cloned().unwrap_or_default(); + if let Err(why) = piece(key, &value) { + move_out(top, key, &why.to_string(), file); + } + } + if !top.get("name").is_some_and(Value::is_string) { + top.insert("name".into(), json!("Untitled")); + } + for key in ["params", "features"] { + top.entry(key).or_insert_with(|| json!([])); + } + let attempt = |top: &Map| format::decode(Value::Object(top.clone()), version, applied); + let first = match attempt(top) { + Ok(doc) => return Ok(doc), + Err(why) => why, + }; + // Sections that are fine by themselves but not together with the rest. + let keys: Vec = top.keys().filter(|k| !CORE.contains(&k.as_str())).cloned().collect(); + // Each try reads the whole design: a file of many sections goes straight to the last resort. + for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) { + let mut without = top.clone(); + without.remove(key); + if let Ok(doc) = attempt(&without) { + move_out(top, key, &first.to_string(), file); + return Ok(doc); + } + } + let mut bare = top.clone(); + bare.retain(|key, _| CORE.contains(&key.as_str())); + match attempt(&bare) { + Ok(doc) => { + for key in &keys { + move_out(top, key, &first.to_string(), file); + } + Ok(doc) + } + Err(_) => Err(first.to_string()), + } +} + +/// Leave a section out and keep it. A section the design needs is left empty. +fn move_out(top: &mut Map, key: &str, why: &str, file: &mut NewerFile) { + let Some(value) = top.remove(key) else { return }; + file.not_understood.push(format!("section `{key}` ({})", short(why))); + file.sections.push(Section { key: key.into(), value, empty: None }); +} + +/// The first line of a reader's error, without the list of every kind it knows. +fn short(why: &str) -> &str { + let why = why.split(", expected").next().unwrap_or(why); + why.strip_prefix("design file: ").unwrap_or(why) +} + +fn feature_note(feature: &Value, why: &str) -> String { + let name = feature.get("name").and_then(Value::as_str).unwrap_or("unnamed"); + let kind = feature.get("type").and_then(Value::as_str).unwrap_or("unnamed"); + let why = short(why); + if why.starts_with("unknown variant") { + format!("feature `{name}`, a `{kind}` feature this version doesn't know") + } else { + format!("feature `{name}`, a `{kind}` feature ({why})") + } +} + +/// A feature this build can't read as this build reads it: the fields every feature has, which stay +/// editable. Its `raw` is filled in after reading ([`fill_foreign`]), so nothing in a feature this +/// build can't read is judged by this build's rules. `free` gives an id to one the file left +/// without. +fn wrapped(raw: Value, free: &mut u64, foreign: &mut BTreeMap) -> Value { + let id = raw.get("id").and_then(Value::as_u64).unwrap_or_else(|| { + let id = *free; + *free = free.saturating_add(1); + id + }); + let name = raw.get("name").and_then(Value::as_str).unwrap_or("Unknown feature").to_string(); + let suppressed = raw.get("suppressed").and_then(Value::as_bool).unwrap_or(false); + let component = raw.get("component").and_then(Value::as_u64).unwrap_or(0); + foreign.insert(id, raw); + json!({"id": id, "name": name, "suppressed": suppressed, "component": component, "type": FOREIGN_TAG, "raw": null}) +} + +/// Give the features [`wrapped`] left without one what the file said. +fn fill_foreign(doc: &mut Document, mut foreign: BTreeMap) { + for feature in &mut doc.features { + if let crate::FeatureKind::NewerFileFeature { raw } = &mut feature.kind + && let Some(value) = foreign.remove(&feature.id) + { + *raw.0 = value; + } + } +} + +/// A feature back as the file had it, with the changes this build can make to any feature. +fn unwrapped(feature: &mut Value) { + let Some(wrapper) = feature.as_object_mut() else { return }; + let Some(Value::Object(mut raw)) = wrapper.remove("raw") else { return }; + for key in ["id", "name", "suppressed", "component"] { + match wrapper.get(key) { + // Left out as a writer leaves it out, unless the file said it. + Some(v) if key != "id" && !raw.contains_key(key) && looks_default(v) => {} + Some(v) => { + raw.insert(key.into(), v.clone()); + } + None => { + raw.remove(key); + } + } + } + *feature = Value::Object(raw); +} + +/// A value a writer leaves out without losing anything: not worth telling anyone about. +fn looks_default(v: &Value) -> bool { + match v { + Value::Null => true, + Value::Bool(b) => !b, + Value::Number(n) => n.as_f64() == Some(0.0), + Value::String(s) => s.is_empty(), + Value::Array(a) => a.is_empty(), + Value::Object(o) => o.is_empty(), + } +} + +/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names. +fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec) -> Option { + match (raw, known) { + (Value::Object(raw), Value::Object(known)) => { + if known.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG) { + return None; + } + let mut kept = BTreeMap::new(); + for (key, value) in raw { + if CONTAINER_ONLY.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) { + continue; + } + let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") }; + match known.get(key) { + None => { + if !looks_default(value) { + paths.push(format!("field `{inner}`")); + } + kept.insert(key.clone(), Kept::Field(value.clone())); + } + Some(known) => { + if let Some(k) = vanished(value, known, &inner, paths) { + kept.insert(key.clone(), k); + } + } + } + } + (!kept.is_empty()).then_some(Kept::Fields(kept)) + } + (Value::Array(raw), Value::Array(known)) => { + let mut items = Vec::new(); + for (at, element) in raw.iter().enumerate() { + let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() }); + let Some(partner) = find(known, &anchor) else { continue }; + let label = match (element.get("name").and_then(Value::as_str), &anchor) { + (Some(name), _) => format!("{path}[{name}]"), + (None, Anchor::Id(id)) => format!("{path}[{id}]"), + _ => format!("{path}[{at}]"), + }; + if let Some(k) = vanished(element, partner, &label, paths) { + items.push((anchor, k)); + } + } + (!items.is_empty()).then_some(Kept::Items(items)) + } + _ => None, + } +} + +fn anchor_of(element: &Value) -> Option { + if let Some(id) = element.get("id").and_then(Value::as_u64) { + return Some(Anchor::Id(id)); + } + element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string())) +} + +fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> { + match anchor { + Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)), + Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)), + Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(), + } +} + +fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> { + match anchor { + Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)), + Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)), + Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(), + } +} + +/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one +/// whose place can't be told any more, and a field whose record is gone, go into `lost`. +fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec) { + match kept { + Kept::Field(_) => {} + Kept::Fields(fields) => { + let Some(object) = value.as_object_mut() else { + lost.push(format!("field `{path}`")); + return; + }; + for (key, sub) in fields { + let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") }; + match (sub, object.get_mut(key)) { + (Kept::Field(v), None) => { + object.insert(key.clone(), v.clone()); + } + (Kept::Field(_), Some(_)) => {} + (sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost), + // The list it was in is empty and not written: its elements were deleted. + (Kept::Items(_), None) => {} + (_, None) => lost.push(format!("field `{inner}`")), + } + } + } + Kept::Items(items) => { + let Some(list) = value.as_array_mut() else { + lost.push(format!("field `{path}`")); + return; + }; + for (anchor, sub) in items { + match (find_mut(list, anchor), anchor) { + (Some(element), _) => apply(sub, element, path, lost), + (None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")), + (None, _) => {} + } + } + } + } +} + +/// Write back what the design holds of the file a newer build saved, into `value`, the design's +/// JSON. Returns what couldn't go back ([`not_kept`]). +pub fn restore(doc: &Document, value: &mut Value) -> Vec { + let mut lost = Vec::new(); + let Some(file) = &doc.newer_file else { return lost }; + if let Some(Value::Array(features)) = value.get_mut("features") { + for feature in features.iter_mut().filter(|f| f.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG)) { + unwrapped(feature); + } + } + if let Some(kept) = &file.kept { + apply(kept, value, "", &mut lost); + } + if let Some(top) = value.as_object_mut() { + for section in &file.sections { + if top.get(§ion.key) == section.empty.as_ref() { + top.insert(section.key.clone(), section.value.clone()); + } else { + lost.push(format!("section `{}`, replaced by what was edited here", section.key)); + } + } + for (key, element) in &file.elements { + let name = element.get("name"); + match top.entry(key.clone()).or_insert_with(|| json!([])) { + Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()), + _ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))), + } + } + } + lost +} + +/// What of the file a newer build saved the next save can't keep, because the design was changed +/// around it. Empty almost always. +pub fn not_kept(doc: &Document) -> Vec { + let Ok(mut value) = serde_json::to_value(doc) else { return Vec::new() }; + restore(doc, &mut value) +} + +#[cfg(test)] +#[path = "newer_file_tests.rs"] +mod tests; diff --git a/crates/doc/src/newer_file_tests.rs b/crates/doc/src/newer_file_tests.rs new file mode 100644 index 000000000..986bbbf0c --- /dev/null +++ b/crates/doc/src/newer_file_tests.rs @@ -0,0 +1,236 @@ +//! Designs a newer SolidCraft saved: they open, say so, and saving loses nothing. + +use serde_json::{Value, json}; + +use crate::format::{DESIGN, FORMAT, format_string}; +use crate::{Document, FeatureKind, Model, PlaneRef}; + +/// A real design: a sketched disc and its extrusion, and a parameter. +fn design() -> Document { + use solidcraft_sketch::Sketch; + let mut d = Document::new("Plate"); + d.set_param("depth", "5 mm", Some("mm"), None).unwrap(); + let mut sketch = Sketch::new(); + sketch.add_circle(solidcore_record::Vec2::new(0.0, 0.0), 10.0, None, None).unwrap(); + let base = d.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Base")).unwrap(); + d.add_feature(extrude(base), Some("Pad")).unwrap(); + d +} + +fn extrude(sketch: u64) -> FeatureKind { + FeatureKind::Extrude { + thin: None, + sketch: crate::SketchId(sketch), + profiles: crate::ProfileSel::All, + extent: serde_json::from_value(json!({"distance": "depth"})).unwrap(), + operation: crate::Operation::NewBody, + targets: vec![], + } +} + +fn saved(d: &Document) -> Value { + serde_json::from_str(&d.to_json()).unwrap() +} + +/// `(body name, volume)` of everything the design builds, and the error of each feature. +fn built(d: &Document) -> (Vec<(String, f64)>, Vec>) { + let mut model = Model::default(); + model.evaluate(d); + let bodies = model.state().bodies.iter().map(|b| (b.name.clone(), b.body.volume_at(0.01).unwrap())).collect(); + (bodies, model.results.iter().map(|r| r.error.clone()).collect()) +} + +fn newer() -> String { + format_string(FORMAT + 1) +} + +#[test] +fn one_format_newer_with_nothing_unknown_opens_with_the_same_bodies() { + let original = design(); + let mut file = saved(&original); + file["format"] = json!(newer()); + let opened = Document::from_json(&file.to_string()).unwrap(); + let (bodies, errors) = built(&opened); + assert_eq!((bodies.clone(), errors), built(&original)); + assert_eq!(bodies.len(), 1); + assert_eq!(opened.features, original.features); + let notice = opened.newer_file.as_ref().unwrap().notice(); + assert_eq!( + notice, + format!("Saved by a newer SolidCraft (format {}, this version reads up to {FORMAT}). If something's missing, update.", FORMAT + 1) + ); + assert!(opened.newer_file.as_ref().unwrap().not_understood.is_empty()); + // Saved, it still says what it is; read again, the same. + let again = saved(&opened); + assert_eq!(again["format"], json!(newer())); + assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened); +} + +#[test] +fn an_unknown_change_opens_with_a_warning_and_stays_on_the_file() { + let original = design(); + let mut file = saved(&original); + file["format_pending"] = json!(["a-later-change"]); + let opened = Document::from_json(&file.to_string()).unwrap(); + let report = opened.newer_file.as_ref().unwrap(); + assert_eq!(report.unknown_changes, ["a-later-change"]); + assert!(report.notice().contains("a-later-change") && report.notice().starts_with("Saved by a newer SolidCraft"), "{}", report.notice()); + assert_eq!(built(&opened), built(&original)); + let again = saved(&opened); + assert_eq!(again["format"], json!(format_string(FORMAT))); + assert!(again["format_pending"].as_array().unwrap().contains(&json!("a-later-change"))); + assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened); +} + +/// The file, with a feature of a kind this build doesn't have between the sketch and the pad. +fn with_unknown_feature() -> Value { + let mut file = saved(&design()); + file["format"] = json!(newer()); + file["format_pending"] = json!(["a-later-change"]); + let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5, "nested": {"big": 1e300}, "suppressed": false}); + file["features"].as_array_mut().unwrap().insert(1, future); + file +} + +#[test] +fn an_unknown_feature_kind_opens_the_rest_and_reports_that_feature() { + let file = with_unknown_feature(); + let opened = Document::from_json(&file.to_string()).unwrap(); + let report = opened.newer_file.as_ref().unwrap(); + assert!(report.not_understood.iter().any(|what| what.contains("Lattice1") && what.contains("gyroid_lattice")), "{:?}", report.not_understood); + assert_eq!(opened.features.len(), 3); + assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "gyroid_lattice")); + let (bodies, errors) = built(&opened); + assert_eq!(bodies.len(), 1, "the pad after it still builds"); + assert!(errors[0].is_none() && errors[2].is_none(), "{errors:?}"); + let why = errors[1].as_deref().unwrap(); + assert!(why.contains("newer SolidCraft") && why.contains("gyroid_lattice"), "{why}"); +} + +#[test] +fn saving_keeps_an_unknown_feature_as_the_file_had_it_and_the_files_number() { + let file = with_unknown_feature(); + let opened = Document::from_json(&file.to_string()).unwrap(); + let again = saved(&opened); + assert_eq!(again["format"], file["format"], "never a lower number"); + assert_eq!(again["format_pending"], file["format_pending"]); + assert_eq!(again["features"][1], file["features"][1], "the feature as the file had it"); + assert_eq!( + again["features"].as_array().unwrap().iter().map(|f| f["type"].clone()).collect::>(), + [json!("sketch"), json!("gyroid_lattice"), json!("extrude")] + ); + assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened, "saved and read again, the same"); + // A rename here goes with it; the rest stays. + let mut renamed = opened.clone(); + renamed.features[1].name = "Infill".into(); + let again = saved(&renamed); + assert_eq!(again["features"][1]["name"], "Infill"); + assert_eq!(again["features"][1]["nested"], json!({"big": 1e300})); +} + +#[test] +fn saving_keeps_fields_and_sections_this_version_doesnt_know() { + let mut file = saved(&design()); + file["format"] = json!(newer()); + file["future_section"] = json!({"anything": [1, 2, 3]}); + file["features"][1]["draft_v2"] = json!({"angle": 3.0}); + file["params"][0]["future_note"] = json!("keep me"); + let opened = Document::from_json(&file.to_string()).unwrap(); + assert_eq!(built(&opened).0.len(), 1); + let report = opened.newer_file.as_ref().unwrap(); + for field in ["future_section", "draft_v2", "future_note"] { + assert!(report.not_understood.iter().any(|what| what.contains(field)), "{field}: {:?}", report.not_understood); + } + let again = saved(&opened); + assert_eq!(again["future_section"], file["future_section"]); + assert_eq!(again["features"][1]["draft_v2"], file["features"][1]["draft_v2"]); + assert_eq!(again["params"][0]["future_note"], "keep me"); + // They follow their records when the design changes around them. + let mut changed = opened.clone(); + let id = changed.features[1].id; + changed.features.insert(0, changed.features[0].clone()); + changed.features[0].id = 777; + assert_eq!(changed.feature(id).unwrap().name, "Pad"); + let again = saved(&changed); + assert_eq!(again["features"][2]["draft_v2"], file["features"][1]["draft_v2"]); + assert!(crate::newer_file::not_kept(&changed).is_empty()); +} + +#[test] +fn a_section_that_doesnt_read_is_left_out_named_and_kept() { + let mut file = saved(&design()); + file["format"] = json!(newer()); + file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}], "rules": {"unknown_future_rule": true}}); + let opened = Document::from_json(&file.to_string()).unwrap(); + assert_eq!(built(&opened).0.len(), 1); + let report = opened.newer_file.as_ref().unwrap(); + assert!(report.not_understood.iter().any(|what| what.contains("configs")), "{:?}", report.not_understood); + assert_eq!(saved(&opened)["configs"], file["configs"]); + assert!(crate::newer_file::not_kept(&opened).is_empty()); +} + +#[test] +fn a_parameter_that_doesnt_read_is_left_out_named_and_kept() { + let mut file = saved(&design()); + file["format"] = json!(newer()); + file["params"].as_array_mut().unwrap().push(json!({"name": "future", "expression": {"kind": "table"}})); + let opened = Document::from_json(&file.to_string()).unwrap(); + assert_eq!(built(&opened).0.len(), 1); + assert!(opened.newer_file.as_ref().unwrap().not_understood.iter().any(|what| what.contains("`future`"))); + let names: Vec<_> = saved(&opened)["params"].as_array().unwrap().iter().map(|p| p["name"].clone()).collect(); + assert!(names.contains(&json!("future")) && names.contains(&json!("depth")), "{names:?}"); +} + +#[test] +fn what_cant_go_back_after_an_edit_is_said() { + let mut file = saved(&design()); + file["format"] = json!(newer()); + file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}]}); + let opened = Document::from_json(&file.to_string()).unwrap(); + assert!(crate::newer_file::not_kept(&opened).is_empty()); + let mut edited = opened.clone(); + edited.favorites.insert("depth".into()); + assert!(crate::newer_file::not_kept(&edited).is_empty(), "an edit elsewhere costs nothing"); + let baseline: Value = saved(&edited); + assert_eq!(baseline["configs"], file["configs"]); +} + +#[test] +fn a_feature_this_version_reads_wrongly_is_kept_whole() { + // A kind this build has, in a shape it doesn't: the feature can't read, and is not guessed at. + let mut file = saved(&design()); + file["format"] = json!(newer()); + file["features"][1]["operation"] = json!("weave"); + let opened = Document::from_json(&file.to_string()).unwrap(); + assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "extrude")); + assert!(built(&opened).1[1].as_deref().unwrap().contains("newer SolidCraft")); + assert_eq!(saved(&opened)["features"][1], file["features"][1]); +} + +#[test] +fn what_truly_cant_be_read_says_why() { + for (text, says) in [ + ("not json", "HDR resource admission"), + ("{\"format\":\"solidcraft/0\",\"name\":\"x\"}", "format 0"), + ("{\"format\":\"someone-else/9\"}", "not a SolidCraft design (format `someone-else/9`)"), + ] { + let why = Document::from_json(text).unwrap_err().to_string(); + assert!(why.contains(says), "{text}: {why}"); + } + // Two features of one id are damage whatever build saved them. + let mut file = saved(&design()); + file["format"] = json!(newer()); + let same = file["features"][0].clone(); + file["features"].as_array_mut().unwrap().push(same); + let why = Document::from_json(&file.to_string()).unwrap_err().to_string(); + assert!(why.contains(&format!("format {}", FORMAT + 1)) && why.contains("can't read it") && why.contains("damaged"), "{why}"); +} + +#[test] +fn a_file_this_build_fully_knows_is_not_flagged_and_damage_is_still_refused() { + assert!(Document::from_json(&design().to_json()).unwrap().newer_file.is_none()); + assert_eq!(DESIGN.newer(FORMAT, None), None); + let mut file = saved(&design()); + file["features"][1]["type"] = json!("gyroid_lattice"); + assert!(Document::from_json(&file.to_string()).is_err(), "of this build's own format, that is damage"); +} diff --git a/crates/doc/src/placement_reuse.rs b/crates/doc/src/placement_reuse.rs index ee8b37005..8a61b4b85 100644 --- a/crates/doc/src/placement_reuse.rs +++ b/crates/doc/src/placement_reuse.rs @@ -123,6 +123,7 @@ pub(super) fn local_geometry(f: &Feature, components: &std::collections::HashMap /// Assembly records do not feed any of the feature kinds accepted by `local_geometry`. fn same_geometry_inputs(a: &Document, b: &Document) -> bool { let Document { + newer_file: _, render, format, format_pending, diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index 26bd9441f..c4716655f 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -42,6 +42,7 @@ impl Walk for i8 {} impl Walk for f64 {} impl Walk for usize {} impl Walk for u32 {} +impl Walk for crate::newer_file::RawFeature {} impl Walk for ProfileSel {} impl Walk for std::collections::BTreeMap {} impl Walk for crate::frame::Anchor {} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 274bdc54c..e458e0a63 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("open") .key("Ctrl+O") .noundo() - .params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"), + .params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh); newer_file: {format, reads_up_to, unknown_changes, not_understood, notice} and warnings when a newer SolidCraft saved it: it opens all the same, and what this version can't read is named there and kept by saving"), CommandSpec::new("file.insert_step", "Insert STEP", insert_step) .at("SOLID", "INSERT") .icon("import") @@ -427,6 +427,11 @@ fn opened_design( if read_only { out["read_only"] = json!(true); } + // A newer SolidCraft saved it: opened all the same, and the reply says so. + if let Some(file) = &s.doc.newer_file { + out["newer_file"] = file.report(); + out["warnings"] = json!(file.warnings()); + } let now = solidcraft_io::design_lock::now_secs(); if let Some(lock) = solidcraft_io::design_lock::held_elsewhere(std::path::Path::new(path), now) { out["open_elsewhere"] = json!({"host": lock.host, "user": lock.user, "app": lock.app, "opened": lock.opened}); @@ -583,7 +588,21 @@ fn write_bytes( s.disk = solidcraft_io::design_lock::stamp(path); s.read_only = false; s.mark_saved(); - Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name})) + let mut out = json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}); + // A newer SolidCraft's file keeps what this version can't read, and its format number. What + // couldn't go back, because the design was changed around it, is said. + if s.doc.newer_file.is_some() { + let mut lost = solidcraft_doc::newer_file::not_kept(&s.doc); + if format == "json" && s.doc.newer_file.as_ref().is_some_and(|file| file.needs_container()) { + lost.push("the parts of the file beside the design, which plain JSON has no room for: save as .sc to keep them".to_string()); + } + out["kept_format"] = json!(s.doc.format); + if !lost.is_empty() { + out["warnings"] = json!(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect::>()); + out["not_kept"] = json!(lost); + } + } + Ok(out) } impl Session { @@ -1402,6 +1421,60 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A design a newer SolidCraft saved opens through the command agents and the app use: the + /// reply warns, what it can't read shows as that feature's error, work goes on around it, and + /// saving keeps the feature and the file's number in both encodings. + #[test] + fn a_newer_design_opens_and_saving_keeps_what_it_cant_read() { + let dir = std::env::temp_dir().join(format!("sc_newer_design_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap(); + let mut file: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + let newer = solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1); + file["format"] = json!(newer); + let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5}); + file["features"].as_array_mut().unwrap().push(future.clone()); + file["future_section"] = json!([1, 2]); + for name in ["newer.json", "newer.sc"] { + let path = dir.join(name); + let source = solidcraft_doc::Document::from_json(&file.to_string()).unwrap(); + let bytes = if name.ends_with(".sc") { solidcraft_io::write_design_container(&source).unwrap() } else { file.to_string().into_bytes() }; + std::fs::write(&path, bytes).unwrap(); + let path = path.to_string_lossy().to_string(); + let mut opened = Session::default(); + let reply = opened.execute("doc.open", &json!({"path": path})).unwrap(); + assert_eq!(reply["errors"], 1, "{reply}"); + assert_eq!(reply["features"], 2, "{reply}"); + assert!(reply["newer_file"]["not_understood"].to_string().contains("gyroid_lattice"), "{reply}"); + let first = reply["warnings"][0].as_str().unwrap(); + assert_eq!( + first, + format!( + "Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.", + solidcraft_doc::format::FORMAT + 1, + solidcraft_doc::format::FORMAT + ) + ); + assert_eq!(opened.model.state().bodies.len(), 1, "the box still builds"); + let lattice = opened.doc.features.iter().position(|f| f.name == "Lattice1").unwrap(); + assert!(opened.model.results[lattice].error.as_deref().unwrap().contains("gyroid_lattice")); + opened.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1})).unwrap(); + let saved = opened.execute("file.save", &json!({})).unwrap(); + assert_eq!(saved["kept_format"], json!(newer), "{saved}"); + assert!(saved.get("not_kept").is_none(), "{saved}"); + let mut again = Session::default(); + again.execute("doc.open", &json!({"path": path})).unwrap(); + assert_eq!(again.doc.features.len(), 3); + assert_eq!(again.model.state().bodies.len(), 2); + let written: Value = serde_json::from_str(&again.doc.to_json()).unwrap(); + assert_eq!(written["format"], json!(newer)); + assert_eq!(written["future_section"], json!([1, 2])); + assert!(written["features"].as_array().unwrap().contains(&future)); + } + let _ = std::fs::remove_dir_all(&dir); + } + /// #5467: a script, the CLI or the control channel never gets a prompt. A Save over a file /// another program or computer changed since it was opened is refused with an error naming /// `overwrite`, which replaces it; a design opened read-only only saves to another file. diff --git a/crates/engine/src/cmd/pbr_appearance_tests.rs b/crates/engine/src/cmd/pbr_appearance_tests.rs index 4653a5f67..dff20fa16 100644 --- a/crates/engine/src/cmd/pbr_appearance_tests.rs +++ b/crates/engine/src/cmd/pbr_appearance_tests.rs @@ -61,7 +61,10 @@ fn format20_scalar_looks_upgrade_and_format21_setups_keep_their_meaning() { assert_eq!(d.render.setups["Studio"].width, 320); for version in [crate::doc::format::FORMAT + 1, crate::doc::format::FORMAT + 2] { saved["format"] = json!(format!("solidcraft/{version}")); - assert!(crate::doc::Document::from_json(&saved.to_string()).is_err()); + // A newer build's file opens, says so, and keeps its number. + let newer = crate::doc::Document::from_json(&saved.to_string()).unwrap(); + assert_eq!(newer.newer_file.as_ref().unwrap().format, version); + assert_eq!(newer.format, format!("solidcraft/{version}")); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 407206957..050bb40ee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2440,6 +2440,7 @@ fn image_host_availability(host: Option<&render::job::ImageHost>, expected: &ren /// to be decided on here. fn same_but_features(a: &Document, b: &Document) -> bool { let Document { + newer_file: _, render, format, format_pending, diff --git a/crates/io/src/design.rs b/crates/io/src/design.rs index bc2a69621..e6d3cca6e 100644 --- a/crates/io/src/design.rs +++ b/crates/io/src/design.rs @@ -355,10 +355,16 @@ fn write_container( let render = replace(doc.render.as_ref(), changes).map_err(invalid)?; replacements.insert("render".into(), render); } - let metadata = replace(doc, replacements).map_err(invalid)?; + let mut metadata = replace(doc, replacements).map_err(invalid)?; + solidcraft_doc::newer_file::restore(doc, &mut metadata); let metadata = serde_json::to_vec(&metadata).map_err(invalid)?; writer.part("document.json", &metadata, true)?; - writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?; + // What a newer build's file holds beside the design goes back as it came. + let container = doc.newer_file.as_ref().and_then(|file| file.container.clone()).unwrap_or_else(|| json!({"format": VERSION})); + writer.part("container.json", &serde_json::to_vec(&container).map_err(invalid)?, false)?; + for (name, bytes) in doc.newer_file.iter().flat_map(|file| &file.parts) { + writer.part(name, bytes, true)?; + } // The model cache is optional: one that does not fit the container keeps fewer states, or is // left out, never a failed save. let cache = geometry.and_then(|g| native_cache::encode_model(g, &metadata, writer.space(2, budget)).ok()); @@ -827,20 +833,122 @@ fn is_container_source(source: &mut FileSource) -> Result { Ok(is_design_container(&prefix)) } +/// A newer build's container: a feature, picture or point cloud whose payload doesn't read here +/// is kept as the file has it, and so is every part this build has no use for. +fn hydrate_newer(doc: &mut Document, metadata: &Value, container: Value, reader: &mut PackageReader) -> Result<()> { + let file = Arc::make_mut(doc.newer_file.as_mut().ok_or_else(|| invalid("not a newer design"))?); + for (index, feature) in doc.features.iter_mut().enumerate() { + let Some(value) = metadata.get("features").and_then(|v| v.get(index)) else { continue }; + let readable = match &mut feature.kind { + FeatureKind::Import { .. } => reader.blob(value, "step_blob", "step").map(|_| ()), + FeatureKind::MeshImport { meshes, .. } => { + let mut check = meshes.clone(); + check.iter_mut().enumerate().try_for_each(|(i, mesh)| { + let value = value.get("meshes").and_then(|v| v.get(i)).ok_or_else(|| invalid("missing mesh metadata"))?; + decode_mesh(reader.blob(value, "mesh_blob", "mesh")?, mesh) + }) + } + _ => Ok(()), + }; + if let Err(why) = readable { + file.not_understood.push(format!("feature `{}` ({why})", feature.name)); + feature.kind = FeatureKind::NewerFileFeature { raw: solidcraft_doc::newer_file::RawFeature(Box::new(value.clone())) }; + } + } + let canvases_read = doc.canvases.iter().enumerate().all(|(i, canvas)| { + canvas.data.is_empty() + && matches!(canvas.format.as_str(), "png" | "jpeg") + && metadata.get("canvases").and_then(|v| v.get(i)).is_some_and(|v| reader.blob(v, "image_blob", &canvas.format).is_ok()) + }); + if !canvases_read { + doc.canvases.clear(); + file.keep_section("canvases", metadata.get("canvases").cloned().unwrap_or_default(), Some(json!([])), "pictures this version can't read"); + } + let clouds_read = doc.point_clouds.iter().enumerate().all(|(i, cloud)| { + let mut check = cloud.clone(); + metadata.get("point_clouds").and_then(|v| v.get(i)).is_some_and(|v| match reader.blob(v, "points_blob", "points") { + Ok(bytes) => decode_cloud(bytes, &mut check).is_ok(), + Err(_) => false, + }) + }); + if !clouds_read { + doc.point_clouds.clear(); + file.keep_section("point_clouds", metadata.get("point_clouds").cloned().unwrap_or_default(), None, "point clouds this version can't read"); + } + // The container's own note, when it says something this build doesn't know. + if !is_known_container(&container) { + file.container = Some(container); + } + // A blob a typed record uses comes back from that record; others only for what is kept. + let names: Vec = reader.zip.file_names().filter_map(|name| name.ok().map(|name| name.into_owned())).collect(); + for name in names { + let ours = matches!(name.as_str(), "document.json" | "container.json") || name.starts_with("cache/"); + let unused_blob = name.starts_with("blobs/") && !file.mentions(&name) && !foreign_mentions(&doc.features, &name); + if !ours && !unused_blob { + file.parts.push((name.clone(), reader.limited_part(&name, part_limit(&name))?.into())); + } + } + // Looking counts against what a design may expand to only once: the reads below are the real ones. + reader.hydrated = 0; + Ok(()) +} + +/// A `container.json` that says only a format this build writes or wrote. +fn is_known_container(container: &Value) -> bool { + *container == json!({"format": VERSION}) + || *container == json!({"format": LEGACY_VERSION}) + || container.get("content").is_some_and(|c| c == "drawing") +} + +/// A container format of ours, this build's or a later one's: `solidcraft-container/N`. +pub(crate) fn is_container_version(format: &str) -> bool { + format == LEGACY_VERSION || format.strip_prefix("solidcraft-container/").is_some_and(|n| n.parse::().is_ok()) +} + +/// Whether a feature this build couldn't read refers to this blob. +fn foreign_mentions(features: &[solidcraft_doc::Feature], name: &str) -> bool { + fn names(value: &Value, name: &str) -> bool { + match value { + Value::String(s) => s == name, + Value::Array(a) => a.iter().any(|x| names(x, name)), + Value::Object(o) => o.values().any(|x| names(x, name)), + _ => false, + } + } + features.iter().any(|f| matches!(&f.kind, FeatureKind::NewerFileFeature { raw } if names(&raw.0, name))) +} + fn read_document(reader: &mut PackageReader) -> Result { if drawing::container_kind(reader)? == FileKind::Drawing { return Err(IoError::Invalid("this file is a SolidCraft drawing, not a design: open it as a drawing".into())); } let bytes = reader.document(max_design())?; - check_hdr_manifest(&bytes)?; - check_texture_manifest(&bytes)?; let mut metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?; + // A newer build's file opens whatever it holds: what doesn't read here is kept (`newer_file`). + let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?; + let newer_container = !is_known_container(&container); + let newer = newer_container || std::str::from_utf8(&bytes).is_ok_and(solidcraft_doc::format::says_newer); // HDR's typed JSON representation is deliberately strict: no empty placeholders or - // container-only blob fields may reach the document deserializer. - let hydrated_hdr = hydrate_hdr_resources(&mut metadata, reader)?; - let hydrated_textures = hydrate_texture_resources(&mut metadata, reader)?; + // container-only blob fields may reach the document deserializer. A newer build's section that + // doesn't pass stays as it is, which the document reader then leaves out. + let hydrated = if newer { + let mut copy = metadata.clone(); + let done = check_hdr_manifest(&bytes).and_then(|()| check_texture_manifest(&bytes)).and_then(|()| { + let hdr = hydrate_hdr_resources(&mut copy, reader)?; + Ok(hydrate_texture_resources(&mut copy, reader)? || hdr) + }); + if done.is_ok() { + metadata = copy; + } + done.unwrap_or(false) + } else { + check_hdr_manifest(&bytes)?; + check_texture_manifest(&bytes)?; + let hdr = hydrate_hdr_resources(&mut metadata, reader)?; + hydrate_texture_resources(&mut metadata, reader)? || hdr + }; let hydrated_text; - let text = if hydrated_hdr || hydrated_textures { + let text = if hydrated { hydrated_text = serde_json::to_string(&metadata).map_err(invalid)?; &hydrated_text } else { @@ -849,6 +957,16 @@ fn read_document(reader: &mut PackageReader) -> Result BTreeMap> { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); (0..zip.len()) @@ -2013,7 +2185,13 @@ mod tests { entries.insert("../escape".into(), vec![]); assert!(crate::read_design(&package(&entries)).is_err()); entries.remove("../escape"); - entries.insert("container.json".into(), br#"{"format":"solidcraft-container/999"}"#.to_vec()); + // A later container still holds a design: it opens, says so, and saving keeps its note. + entries.insert("container.json".into(), br#"{"format":"solidcraft-container/999","more":1}"#.to_vec()); + let later = crate::read_design(&package(&entries)).unwrap(); + assert!(later.newer_file.as_ref().unwrap().notice().contains("solidcraft-container/999")); + let resaved = parts(&write_design_container(&later).unwrap()); + assert_eq!(resaved["container.json"], entries["container.json"]); + entries.insert("container.json".into(), br#"{"format":"another-tool/1"}"#.to_vec()); assert!(crate::read_design(&package(&entries)).unwrap_err().to_string().contains("unsupported container version")); for length in (0..bytes.len()).step_by(17) { assert!(crate::read_design(&bytes[..length]).is_err()); diff --git a/crates/io/src/design/drawing.rs b/crates/io/src/design/drawing.rs index 1fa4de710..73700bea0 100644 --- a/crates/io/src/design/drawing.rs +++ b/crates/io/src/design/drawing.rs @@ -49,13 +49,15 @@ pub fn file_kind(bytes: &[u8]) -> Result { /// What a container holds, by its `container.json`: no `content` is a design. pub(super) fn container_kind(reader: &mut PackageReader) -> Result { let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?; - if !matches!(container.get("format").and_then(Value::as_str), Some(super::VERSION | super::LEGACY_VERSION)) { + if !container.get("format").and_then(Value::as_str).is_some_and(super::is_container_version) { return Err(invalid("unsupported container version")); } match container.get("content") { None => Ok(FileKind::Design), Some(Value::String(content)) if content == DRAWING_CONTENT => Ok(FileKind::Drawing), - Some(other) => Err(invalid(format!("this build doesn't know container content {other}"))), + // A later kind of content that still holds a design opens as one. + Some(_) if reader.has_part("document.json") => Ok(FileKind::Design), + Some(other) => Err(invalid(format!("it holds {other}, which this version doesn't know: update SolidCraft to open it"))), } } diff --git a/crates/io/src/design/drawing_tests.rs b/crates/io/src/design/drawing_tests.rs index cc4ce2506..10f9d6dc3 100644 --- a/crates/io/src/design/drawing_tests.rs +++ b/crates/io/src/design/drawing_tests.rs @@ -106,8 +106,8 @@ fn a_drawing_container_refuses_what_it_cant_take() { let text = drawing().to_json().unwrap(); let drawing_json = |content: &str| format!(r#"{{"content":{content},"format":"solidcraft-container/1"}}"#); let cases: [(Vec, &str); 6] = [ - (container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "container content \"sketch\""), - (container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "container content 7"), + (container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "it holds \"sketch\""), + (container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "it holds 7"), ( container(&[ ("container.json", drawing_json("\"drawing\"").as_bytes()), @@ -117,10 +117,7 @@ fn a_drawing_container_refuses_what_it_cant_take() { "also holds a design's document.json", ), (container(&[("container.json", drawing_json("\"drawing\"").as_bytes())]), "required part `drawing.json`"), - ( - container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]), - "version", - ), + (container(&[("container.json", &br#"{"content":"drawing","format":"another-tool/9"}"#[..]), ("drawing.json", text.as_bytes())]), "version"), ( container(&[("container.json", drawing_json("\"drawing\"").as_bytes()), ("drawing.json", text.replace("en-GB", "e").as_bytes())]), "damaged drawing", @@ -130,7 +127,11 @@ fn a_drawing_container_refuses_what_it_cant_take() { let e = error(read_drawing(&bytes)); assert!(e.contains(says), "{says}: {e}"); } - assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("container content")); + assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("this version doesn't know")); + // A later container of ours still holds a drawing: it opens. + let later = + container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]); + assert!(read_drawing(&later).is_ok()); } /// Drawing files: the extension, and reading one from disk in either form. diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index 95e68c8d2..216ee4dfd 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -439,6 +439,7 @@ fn recovered(app: &mut SolveApp, request: &Request) { let _ = app.session.execute("doc.recovery_discard", &json!({"id": id, "dir": dir})); } app.set_status(format!("recovered {}: save it to keep it", request.path), false); + app.say_newer_file(); } #[cfg(not(target_arch = "wasm32"))] @@ -1141,6 +1142,9 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { }, false, ); + if !request.insert() { + app.say_newer_file(); + } } request.answer(Ok(made.result)); } diff --git a/crates/ui-egui/src/file_load_ui_tests.rs b/crates/ui-egui/src/file_load_ui_tests.rs index 7b6f5e93c..7e2489602 100644 --- a/crates/ui-egui/src/file_load_ui_tests.rs +++ b/crates/ui-egui/src/file_load_ui_tests.rs @@ -781,3 +781,33 @@ fn an_opened_design_comes_up_at_the_home_view() { let _ = std::fs::remove_dir_all(&other); let _ = std::fs::remove_dir_all(&dir); } + +/// A design a newer SolidCraft saved opens in the window: the status line says so without a +/// dialog, Messages names what wasn't read, and editing that feature says why it can't be edited. +#[test] +fn a_newer_design_opens_with_a_notice_and_no_dialog() { + let mut app = SolveApp::new(Session::default(), Default::default()); + let dir = scratch("newer_design"); + let mut design = Session::default(); + design.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap(); + let mut file: serde_json::Value = serde_json::from_str(&design.doc.to_json()).unwrap(); + let (reads, newer) = (solidcraft_engine::doc::format::FORMAT, solidcraft_engine::doc::format::FORMAT + 1); + file["format"] = json!(solidcraft_engine::doc::format::format_string(newer)); + file["features"].as_array_mut().unwrap().push(json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice"})); + let path = dir.join("newer.json"); + std::fs::write(&path, file.to_string()).unwrap(); + + app.open_path(&path.to_string_lossy()); + + let (text, _, error) = app.status.clone().unwrap(); + assert_eq!(text, format!("Saved by a newer SolidCraft (format {newer}, this version reads up to {reads}). If something's missing, update.")); + assert!(!error); + assert!(app.dialog.is_none()); + assert!(app.messages.iter().any(|m| m.text.contains("Lattice1") && m.text.contains("Saving keeps it"))); + assert_eq!(app.session.model.state().bodies.len(), 1); + app.edit_feature(900); + let (text, _, error) = app.status.clone().unwrap(); + assert!(error && text.contains("gyroid_lattice"), "{text}"); + assert!(app.dialog.is_none() && !app.ui.palette_open); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 827690ca5..3f60f5094 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2222,6 +2222,12 @@ impl SolveApp { self.edit_sketch(id); return; } + // Nothing here knows what it holds: it says so, and waits for the build that does. + if let FeatureKind::NewerFileFeature { raw } = &f.kind { + let why = raw.why(); + self.set_status(why, true); + return; + } let Some(idx) = self.session.doc.feature_index(id) else { return }; let marker = self.finish_sketch_for_edit(); self.tool = None; @@ -2526,6 +2532,23 @@ impl SolveApp { self.status_undo = false; } + /// Tell the user when the open design came from a newer SolidCraft: the notice on the status + /// line, and what this version couldn't read in the messages. + pub(crate) fn say_newer_file(&mut self) { + let Some(file) = self.session.doc.newer_file.clone() else { return }; + for what in &file.not_understood { + let text = i18n::format("document.newer_file_not_read", &[("what", what)]); + self.messages.push(&text, false, now_ms()); + } + let text = if file.format <= file.reads_up_to && !file.unknown_changes.is_empty() { + let change = file.unknown_changes.first().map_or("", String::as_str); + i18n::format("document.newer_file_change", &[("format", &file.format.to_string()), ("change", change)]) + } else { + i18n::format("document.newer_file", &[("format", &file.format.to_string()), ("reads", &file.reads_up_to.to_string())]) + }; + self.set_status(text, false); + } + pub fn open_path(&mut self, path: &str) { match documents::reuse_path(self, path) { Ok(true) => return, @@ -2543,6 +2566,7 @@ impl SolveApp { self.cam.set_view(StandardView::Iso); self.fit_view(); stand_up::offer(self, path, &[], Some(&r)); + self.say_newer_file(); } } diff --git a/crates/ui-egui/src/render_workspace_setups.rs b/crates/ui-egui/src/render_workspace_setups.rs index 1f88a04f6..7382cb863 100644 --- a/crates/ui-egui/src/render_workspace_setups.rs +++ b/crates/ui-egui/src/render_workspace_setups.rs @@ -304,6 +304,7 @@ pub(super) fn same_model_source(app: &SolveApp, document: &std::sync::Weak Err(format!("{command}: the save stopped")), }; app.finish_command(command, &r); + // Saving a newer SolidCraft's file keeps what this version can't read, unless it was changed + // around: then the save says what went. + if let Some(first) = r.as_ref().ok().and_then(|v| v["warnings"].as_array()).and_then(|w| w.first()).and_then(serde_json::Value::as_str) { + app.set_status(first, true); + } crate::documents::saved_for_close(app); } diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 1b0f0358d..532305a3b 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -137,7 +137,7 @@ opened through a symbolic link (a synced folder linked into a project) is saved link points to, so the link stays. Unsaved changes in every open tab are autosaved to separate entries in a recovery folder, and the next launch offers to recover each design. Saving or closing one tab removes only its recovery entry. Older design files are upgraded on open, and files -from a newer version are refused with a message. +from a newer version open with a warning. **Save** writes a container by default; files named `.json` keep JSON on subsequent saves. **Save As** offers a container or plain JSON on @@ -167,9 +167,12 @@ The default `cache: false` omits the portable thumbnail too. The repository's `docs/design-container.md` describes limits and encoding details. Designs are saved in the newest format this version of SolidCraft knows (`"format": "solidcraft/N"` -in the file). It opens designs of every older format and upgrades them as they open; a design -from a newer version asks you to update instead of being guessed at. Development (nightly) builds -save a format number no release reads yet. +in the file). It opens designs of every older format and upgrades them as they open. A design +from a newer version opens too, with a notice saying so: what this version can read is read, and +a feature it can't read stays on the timeline with an error saying so, so you can see what is +missing. Saving such a design keeps what this version can't read and the file's format number, so +the newer version finds its work again. Development (nightly) builds save a format number no release +reads yet. Sketch patterns retain their live direction-line references and placement settings when saved. Older patterns keep their fixed vector directions and original placement defaults. diff --git a/docs/book/src/guide/troubleshooting.md b/docs/book/src/guide/troubleshooting.md index 8b2c570c1..4b133b240 100644 --- a/docs/book/src/guide/troubleshooting.md +++ b/docs/book/src/guide/troubleshooting.md @@ -182,9 +182,10 @@ does deleting that file. ## A design saved by 0.10 won't open in an older version -Designs saved by a newer SolidCraft use a newer file format. Older versions refuse them with -"this design was saved by a newer SolidCraft" and don't change the file. Open them with the -current version. +Designs saved by a newer SolidCraft use a newer file format. Older versions may refuse them with +"this design was saved by a newer SolidCraft" and don't change the file: open them with the current +version. Recent versions open them with a notice, and a feature they can't read shows an error +on the timeline; update to build it. ## Save As says the file can't be found (Windows) diff --git a/skills/solidcraft-file-formats/SKILL.md b/skills/solidcraft-file-formats/SKILL.md index 794478c5b..c72e2b73f 100644 --- a/skills/solidcraft-file-formats/SKILL.md +++ b/skills/solidcraft-file-formats/SKILL.md @@ -8,9 +8,9 @@ description: "Use when a SolidCraft change saves something new in a design (a fi ## Design file format changes A design says which format it is in (`"format": "solidcraft/N"`). A change that saves something new, or makes older designs mean something else, adds ONE file and edits nothing shared: -1. Create `crates/doc/src/format/pending/.rs` (lowercase, dashes). Start it with a `//!` note: what is saved, what older designs get without it, and that the last release asks for an update instead of meeting it. +1. Create `crates/doc/src/format/pending/.rs` (lowercase, dashes). Start it with a `//!` note: what is saved, what older designs get without it, and that the last release opens such a design with a warning and keeps what it can't read (`crates/doc/src/newer_file.rs`). 2. If older designs must be rewritten as they open, put `pub fn step(v: &mut Value) -> crate::Result<()>` in the same file. Pending steps run in file-name order, after the older formats' steps. -3. Never touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another change's file, or any `v/` folder. The build counts the pending files and writes the next number, so a release refuses a design that carries your change instead of losing it. +3. Never touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another change's file, or any `v/` folder. The build counts the pending files and writes the next number, so a release knows a design carries a change it doesn't have: it warns, and saving keeps the file's number and what it couldn't read. Two PRs that each add a change merge without a conflict. `crates/doc/src/format/pending/README.md` has an example. At release time the maintainer runs `scripts/fold-format-pending.sh`, which moves pending files into `v/` for good; `scripts/test-format-pending.sh` runs in CI. Never add a file to a `v/` folder afterwards. See `docs/releasing.md`.