Compare commits

..

1 Commits

Author SHA1 Message Date
jyviko 4db733ec68 Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)
A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 22:46:16 -05:00
8 changed files with 513 additions and 804 deletions
+10 -33
View File
@@ -6,48 +6,29 @@ use std::collections::HashMap;
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
use super::CommandSpec;
use super::sketch::{Left, edit_solved, ids_of};
use super::sketch::{edit, ids_of};
use super::sketch_store;
use crate::params::{bad, num, str_, vec2};
use crate::schema::sketch_import::{DrawingInserted, DxfExported, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams, SketchSolve};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const INSERT_DXF_CONTRACT: TypedContract<InsertDxfParams> = TypedContract::new();
const INSERT_SVG_CONTRACT: TypedContract<InsertSvgParams> = TypedContract::new();
const EXPORT_DXF_CONTRACT: TypedContract<ExportDxfParams> = TypedContract::new();
/// A command's reply as JSON, built from the type its output schema describes so the two can't
/// drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.insert_dxf", "Insert DXF", crate::checked!("sketch.insert_dxf", insert_dxf))
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
.at("SKETCH", "INSERT")
.icon("sketch_dxf")
.contract(&INSERT_DXF_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.insert_svg", insert_svg))
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
.at("SKETCH", "INSERT")
.icon("svg")
.contract(&INSERT_SVG_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.drawing_bounds", "Drawing Size", drawing_bounds)
.icon("svg")
.noundo()
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height in the file's own size"),
CommandSpec::new("sketch.export_dxf", "Save As DXF", crate::checked!("sketch.export_dxf", export_dxf))
.icon("sketch_dxf")
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
.noundo()
.contract(&EXPORT_DXF_CONTRACT)
.output(output_schema_for::<ExportDxfResult>)
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned); units?: \"design\" | \"in\" | \"inch\" | \"mm\" | \"cm\" | \"m\" | \"ft\" (default mm); layer?: profile layer name (default \"0\"); layers?: \"single\" | \"roles\" (default \"single\": all on `layer`; \"roles\": outer on \"OUTLINE\", holes on \"CUT\"); join?: bool (default false: R12 LINE/ARC; true: closed loops as LWPOLYLINE with arc bulges)"),
];
@@ -270,13 +251,10 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if geom.len() > solidcraft_sketch::MAX_POINTS {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
// `sketch::edit`, but keeping the solve as a typed value for the reply.
let target = sketch_store::target(s, p, cmd)?;
let ((curves, texts), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
Ok((ids_of(sk, &cs), texts))
})?;
let info = report.map(|rep| SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof });
// Text becomes sketch text.
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch"))?;
let mut n_text = 0;
@@ -301,7 +279,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
}
}
}
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info}))
}
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
@@ -544,10 +522,9 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let exported = DxfExported::Written { path: path.to_string(), entities: entity_count };
return reply(cmd, ExportDxfResult { exported, param_warnings: Vec::new() });
return Ok(json!({"path": path, "entities": entity_count}));
}
reply(cmd, ExportDxfResult { exported: DxfExported::Text { dxf: text, entities: entity_count }, param_warnings: Vec::new() })
Ok(json!({"dxf": text, "entities": entity_count}))
}
#[cfg(test)]
-1
View File
@@ -110,7 +110,6 @@ pub mod sketch_constraints;
pub mod sketch_create;
pub mod sketch_ends;
pub mod sketch_equation;
pub mod sketch_import;
pub mod sketch_inspect;
pub mod sketch_modify;
pub mod sketch_project;
-448
View File
@@ -1,448 +0,0 @@
//! Contracts and result types for the commands in `cmd/sketch_import.rs`: `sketch.insert_dxf`,
//! `sketch.insert_svg` and `sketch.export_dxf`.
//!
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
//! reads it and fails with the handler's message. Whether a file can be read or written, a sketch
//! exists or a drawing has anything to import stays the handler's to check. A call with several
//! problems may get a different one first in strict than with the check off (the check goes key
//! by key, the handlers in their own order).
//!
//! Two insert keys are read only in some cases, and the contract reads them in the same cases:
//! `path` only when `text` is not given, and of `width`, `height` and `scale` only the first one
//! given, in that order (a `height` next to a `width`, or a `scale` next to either, is never read).
//! A value of the wrong type that the handler reads as if the key were left out (`text: 5` next
//! to a good `path`, a text for `scale`) is refused by the contract, as schemas.md has it: that
//! difference is what warn and strict report.
//! `plane` and `name` are read only when no sketch is being edited, which the check can't see
//! (it has no session), so they take any value.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use solidcraft_io::MAX_DRAWING_BYTES;
use super::check::{located, whole};
use super::sketch_ends::SketchTarget;
use super::types::{Point2, not_blank_pattern};
/// A key that, once given (`null` included), must parse as `T`, as the handler reads it.
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
T::deserialize(d).map(Some)
}
/// The keys the two insert commands read. See [`InsertDxfParams`] and [`InsertSvgParams`], which
/// leave `path`, `height` and `scale` unread in the cases the handler does.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = source_required)]
pub struct InsertDrawingKeys {
/// The drawing itself, as text. Wins over `path`. At most 16 MiB (`MAX_DRAWING_BYTES`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<DrawingText>,
/// The drawing file to read (not blank, fewer than 4096 UTF-8 bytes). Used when `text` is
/// not given; ignored when it is.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "path_schema")]
pub path: Option<DrawingPath>,
/// An offset, in mm, [x, y]. Default: [0, 0]. With `align` "origin" it is where the file's
/// origin goes; with "center", where the drawing's middle goes.
#[serde(default)]
pub at: Option<Point2>,
/// A size factor, 1e-9 to 1e6 (a value outside is read as 1). Default: 1. Used only when
/// neither `width` nor `height` is given.
#[serde(default)]
pub scale: Option<f64>,
/// The drawing's width in mm after sizing, before rotation; the aspect ratio is kept. Wins
/// over `height` and `scale`. The drawing must have a width.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PositiveMm")]
pub width: Option<PositiveMm>,
/// The drawing's height in mm after sizing, before rotation; the aspect ratio is kept. Wins
/// over `scale`; ignored when `width` is given. The drawing must have a height.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PositiveMm")]
pub height: Option<PositiveMm>,
/// A turn in degrees, about the drawing's origin (with `align` "center": about its middle).
/// Default: 0.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "AngleDegrees")]
pub angle: Option<AngleDegrees>,
/// What `at` places: the file's origin ("origin") or the drawing's middle ("center", which it
/// then also turns and scales about). Default: origin.
#[serde(default)]
pub align: Option<DrawingAlign>,
/// The distance in mm within which ends join into one point, 1e-9 to 10 (a value outside is
/// brought inside; one that is not above 0 reads as the default). Default: 0.0001.
#[serde(default)]
pub tolerance: Option<f64>,
/// Read only when no sketch is being edited: the plane of the new sketch, as `sketch.create`
/// takes it. Default: XY. Ignored (and not checked) while a sketch is being edited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plane: Option<Value>,
/// Read only when no sketch is being edited: the new sketch's name. Ignored (and not checked)
/// while a sketch is being edited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<Value>,
/// The sketch to insert into: its feature id or its name. Default: the one being edited (a
/// new one when none is).
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// Leaves out the keys the handler never reads for this call: `path` next to a `text` that is
/// text, `height` next to a `width`, and `scale` next to either.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
if keys.get("text").is_some_and(Value::is_string) {
keys.remove("path");
}
if keys.contains_key("width") {
keys.remove("height");
}
if keys.contains_key("width") || keys.contains_key("height") {
keys.remove("scale");
}
}
fn read_keys<'de, D: Deserializer<'de>>(d: D) -> Result<InsertDrawingKeys, D::Error> {
let mut value = Value::deserialize(d)?;
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))
}
/// Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none
/// is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be
/// sized, turned and placed. Text in the drawing becomes sketch text. One undo step.
#[derive(Debug)]
pub struct InsertDxfParams(pub InsertDrawingKeys);
impl<'de> Deserialize<'de> for InsertDxfParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(InsertDxfParams)
}
}
impl JsonSchema for InsertDxfParams {
fn schema_name() -> Cow<'static, str> {
"InsertDxfParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertDrawingKeys::json_schema(generator);
schema.insert("description".into(), INSERT_DXF.into());
schema
}
}
const INSERT_DXF: &str = "Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
/// Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is
/// being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed.
/// Text in the drawing becomes sketch text. One undo step.
#[derive(Debug)]
pub struct InsertSvgParams(pub InsertDrawingKeys);
impl<'de> Deserialize<'de> for InsertSvgParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(InsertSvgParams)
}
}
impl JsonSchema for InsertSvgParams {
fn schema_name() -> Cow<'static, str> {
"InsertSvgParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertDrawingKeys::json_schema(generator);
schema.insert("description".into(), INSERT_SVG.into());
schema
}
}
const INSERT_SVG: &str = "Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
/// The insert commands need `path` or `text`; the handler says so when both are left out. A
/// `path` it reads (no `text`) is not blank and under 4096 bytes; `maxLength` counts
/// characters, so 4095 is as close as the schema gets. `height` is read only without a `width`
/// and `scale` only without either, so their shapes apply only then, as [`leave_unread`] has it.
fn source_required(schema: &mut Schema) {
let read_path = serde_json::json!({"type": "string", "maxLength": 4095, "pattern": not_blank_pattern()});
schema.insert("anyOf".into(), serde_json::json!([{"required": ["text"]}, {"required": ["path"], "properties": {"path": read_path}}]));
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
let mut read_only_without = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), serde_json::json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let height = read_only_without("height");
let scale = read_only_without("scale");
schema.insert(
"allOf".into(),
serde_json::json!([
{"anyOf": [{"required": ["width"]}, {"properties": {"height": height}}]},
{"anyOf": [{"required": ["width"]}, {"required": ["height"]}, {"properties": {"scale": scale}}]},
]),
);
}
/// `path` is checked by [`source_required`] only when it is read (no `text`).
fn path_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({})
}
/// A drawing's text, as `source_text` reads it: at most 16 MiB, or the handler's refusal.
#[derive(Clone, Debug, PartialEq)]
pub struct DrawingText(pub String);
impl<'de> Deserialize<'de> for DrawingText {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let text = String::deserialize(d)?;
if text.len() > MAX_DRAWING_BYTES {
return Err(D::Error::custom(whole("the drawing is too large")));
}
Ok(DrawingText(text))
}
}
impl JsonSchema for DrawingText {
fn schema_name() -> Cow<'static, str> {
"DrawingText".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "string"})
}
}
/// A drawing file name, as `source_text` reads `path`: not blank and under 4096 bytes, or the
/// handler's "give `path` or `text`" (anything but text reads as no `path`).
#[derive(Clone, Debug, PartialEq)]
pub struct DrawingPath(pub String);
impl<'de> Deserialize<'de> for DrawingPath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(DrawingPath(s)),
_ => Err(D::Error::custom(whole("give `path` or `text`"))),
}
}
}
/// A size in mm above 0, as the insert commands' `width` and `height` are read.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PositiveMm(pub f64);
/// The message the insert commands give for `width` or `height`: the check puts the key first.
const POSITIVE_MM: &str = "must be a positive number (mm)";
impl<'de> Deserialize<'de> for PositiveMm {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_f64().filter(|w| w.is_finite() && *w > 0.0).map(PositiveMm).ok_or_else(|| D::Error::custom(POSITIVE_MM))
}
}
impl JsonSchema for PositiveMm {
fn schema_name() -> Cow<'static, str> {
"PositiveMm".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "A size in mm, above 0.", "type": "number", "exclusiveMinimum": 0})
}
}
/// An angle in degrees as `angle` is read: a number, whatever its size.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AngleDegrees(pub f64);
impl<'de> Deserialize<'de> for AngleDegrees {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_f64().filter(|a| a.is_finite()).map(AngleDegrees).ok_or_else(|| D::Error::custom("must be a number (degrees)"))
}
}
impl JsonSchema for AngleDegrees {
fn schema_name() -> Cow<'static, str> {
"AngleDegrees".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "An angle in degrees.", "type": "number"})
}
}
/// What an insert's `at` places, as `align` is read (the words are case-sensitive).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DrawingAlign {
/// `at` is where the file's origin goes.
Origin,
/// `at` is where the drawing's middle goes, and it turns and scales about it.
Center,
}
impl JsonSchema for DrawingAlign {
fn schema_name() -> Cow<'static, str> {
"DrawingAlign".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "What `at` places: \"origin\" (the file's origin goes there) or \"center\" (the drawing's middle goes there, and it turns and scales about it). Lower case only.",
"type": "string",
"enum": ["origin", "center"]
})
}
}
impl<'de> Deserialize<'de> for DrawingAlign {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)?.as_str() {
Some("origin") => Ok(DrawingAlign::Origin),
Some("center") => Ok(DrawingAlign::Center),
_ => Err(D::Error::custom("must be \"origin\" or \"center\"")),
}
}
}
/// What `sketch.insert_dxf` and `sketch.insert_svg` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DrawingInserted {
/// The sketch the drawing went into, by feature id.
pub sketch: u64,
/// The new sketch's id when no sketch was being edited and one was made; else null.
pub created_sketch: Option<u64>,
/// How many curves were added.
pub curves: usize,
/// How many sketch texts were added.
pub texts: usize,
/// The sketch's solve afterwards; null when it was not solved.
pub info: Option<SketchSolve>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// A sketch's solve, as the sketch commands report it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SketchSolve {
/// Whether the solve kept the sketch's orientation.
pub solved: bool,
/// The degrees of freedom left (0 = fully constrained).
pub dof: usize,
}
/// Save a sketch's curves as an ASCII DXF: to the file `path`, or, without it, as text in the
/// reply. Construction curves are left out; splines and other curves are written as line
/// segments.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ExportDxfParams {
/// The sketch: its feature id or its name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// The file to write. Default (also when blank): none, and the DXF text is returned.
#[serde(default)]
pub path: Option<String>,
/// The drawing's units (any case): `design` (the design's length unit), `mm`, `in` or
/// `inch`, `cm`, `m` or `ft`. Default: mm.
#[serde(default, deserialize_with = "dxf_units")]
#[schemars(schema_with = "dxf_units_schema")]
pub units: Option<String>,
/// The layer of the profiles (blank: the default). Default: `0`.
#[serde(default, deserialize_with = "dxf_layer")]
#[schemars(schema_with = "dxf_layer_schema")]
pub layer: Option<String>,
/// `single` (any case, the default): everything on `layer`; `roles`: outer loops on
/// `OUTLINE` and holes on `CUT`.
#[serde(default, deserialize_with = "dxf_layers")]
#[schemars(schema_with = "dxf_layers_schema")]
pub layers: Option<String>,
/// `true` writes closed loops as LWPOLYLINE with arc bulges. Default: false (LINE and ARC).
#[serde(default, deserialize_with = "dxf_join")]
pub join: Option<bool>,
}
/// The units `sketch.export_dxf` takes, in lower case (the handler reads them in any case).
const DXF_UNITS: [&str; 7] = ["design", "mm", "in", "inch", "cm", "m", "ft"];
fn dxf_units<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::String(units)) => {
let lower = units.to_ascii_lowercase();
if DXF_UNITS.contains(&lower.as_str()) {
Ok(Some(lower))
} else {
Err(D::Error::custom(whole(format!("unsupported unit `{lower}` (use \"design\", \"mm\", \"in\", \"cm\", \"m\", or \"ft\")"))))
}
}
Some(_) => Err(D::Error::custom(whole("`units` must be a string"))),
}
}
fn dxf_units_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["string", "null"], "description": "design, mm, in, inch, cm, m or ft, in any case. Default: mm."})
}
fn dxf_layer<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::String(layer)) => Ok(Some(layer)),
Some(_) => Err(D::Error::custom(whole("`layer` must be a string"))),
}
}
fn dxf_layer_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["string", "null"], "description": "The profiles' layer (blank: the default). Default: 0."})
}
fn dxf_layers<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::String(layers)) if ["single", "roles"].contains(&layers.to_ascii_lowercase().as_str()) => Ok(Some(layers.to_ascii_lowercase())),
Some(_) => Err(D::Error::custom(whole("`layers` must be \"single\" or \"roles\""))),
}
}
fn dxf_layers_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["string", "null"], "description": "single or roles, in any case. Default: single."})
}
fn dxf_join<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(join)) => Ok(Some(join)),
Some(_) => Err(D::Error::custom(whole("`join` must be true or false"))),
}
}
/// What `sketch.export_dxf` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportDxfResult {
#[serde(flatten)]
pub exported: DxfExported,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Where the DXF went.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum DxfExported {
/// With a `path`: the file was written.
Written {
/// The file written, as given.
path: String,
/// How many entities it holds.
entities: usize,
},
/// Without one: the DXF as text.
Text {
/// The DXF file's text.
dxf: String,
/// How many entities it holds.
entities: usize,
},
}
#[cfg(test)]
#[path = "sketch_import_tests.rs"]
mod tests;
@@ -1,313 +0,0 @@
use serde_json::{Value, json};
use solidcraft_io::MAX_DRAWING_BYTES;
use super::{DrawingInserted, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
/// A 30 x 20 rectangle as DXF lines.
const DXF: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n30\n21\n0\n0\nLINE\n10\n30\n20\n0\n11\n30\n21\n20\n0\nLINE\n10\n30\n20\n20\n11\n0\n21\n20\n0\nLINE\n10\n0\n20\n20\n11\n0\n21\n0\n0\nENDSEC\n0\nEOF\n";
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10"><rect x="0" y="0" width="20" height="10"/><text x="2" y="5" font-size="3">Hi</text></svg>"#;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-12508-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn schemas_and_the_key_check_agree() {
let insert = ["align", "angle", "at", "height", "name", "path", "plane", "scale", "sketch", "text", "tolerance", "width"];
assert_keys(input_schema_for::<InsertDxfParams>(), &insert);
assert_keys(input_schema_for::<InsertSvgParams>(), &insert);
assert_keys(input_schema_for::<ExportDxfParams>(), &["join", "layer", "layers", "path", "sketch", "units"]);
}
/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `path` and
/// `text` included: the handler's words, not "is required"). The session has a sketch being edited.
#[test]
fn strict_keeps_todays_errors() {
let too_big = "x".repeat(MAX_DRAWING_BYTES + 1);
let mut cases: Vec<(&str, Value)> = vec![
("sketch.insert_dxf", json!({})),
("sketch.insert_dxf", json!({"path": ""})),
("sketch.insert_dxf", json!({"path": " "})),
("sketch.insert_dxf", json!({"path": 5})),
("sketch.insert_dxf", json!({"path": null})),
("sketch.insert_dxf", json!({"path": "x".repeat(4096)})),
("sketch.insert_dxf", json!({"text": too_big})),
("sketch.insert_svg", json!({})),
("sketch.insert_svg", json!({"path": " "})),
("sketch.insert_svg", json!({"text": null})),
("sketch.export_dxf", json!({"sketch": true})),
("sketch.export_dxf", json!({"sketch": [1]})),
("sketch.export_dxf", json!({"sketch": -1})),
("sketch.export_dxf", json!({"sketch": 1.5})),
];
for (id, text) in [("sketch.insert_dxf", DXF), ("sketch.insert_svg", SVG)] {
for extra in [
json!({"width": 0}),
json!({"width": -2}),
json!({"width": null}),
json!({"width": "wide"}),
json!({"width": [1]}),
json!({"height": 0}),
json!({"height": null}),
json!({"height": true}),
json!({"angle": "x"}),
json!({"angle": null}),
json!({"angle": {}}),
json!({"align": "left"}),
json!({"align": "Center"}),
json!({"align": ""}),
json!({"sketch": true}),
json!({"sketch": null, "width": 0}),
json!({"sketch": [1]}),
json!({"sketch": 1.5}),
] {
let mut p = json!({"text": text});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
cases.push((id, p));
}
}
assert_strict_keeps_todays_errors(&cases);
}
/// The same refusals for a sketch that is named but doesn't exist, a width the drawing can't
/// give (a drawing with no width to size is refused by the handler alone), and the export's
/// options, which the handler reads only once a sketch is being edited.
#[test]
fn strict_keeps_the_handlers_own_refusals() {
// A vertical line has no width to size.
let vertical = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n0\n21\n9\n0\nENDSEC\n0\nEOF\n";
for (id, p) in [
("sketch.insert_dxf", json!({"text": DXF, "sketch": "Nope"})),
("sketch.insert_dxf", json!({"text": DXF, "sketch": 9999})),
("sketch.insert_dxf", json!({"text": vertical, "width": 10})),
("sketch.insert_dxf", json!({"text": "0\nEOF\n"})),
("sketch.export_dxf", json!({"sketch": "Nope"})),
("sketch.export_dxf", json!({"sketch": 9999})),
("sketch.export_dxf", json!({"units": 5})),
("sketch.export_dxf", json!({"units": "Yard"})),
("sketch.export_dxf", json!({"layer": 1})),
("sketch.export_dxf", json!({"layers": "all"})),
("sketch.export_dxf", json!({"layers": true})),
("sketch.export_dxf", json!({"join": "yes"})),
] {
let error = |level: ParamCheck| {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute(id, &p).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{id} {p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
}
}
/// The types read each key as the handler does: keys it never reads in a call are not read by the
/// contract either, and the ones it reads fail with its words.
#[test]
fn the_types_read_like_the_handler() {
let reads = |p: Value| serde_json::from_value::<InsertDxfParams>(p).is_ok();
let issues = |p: Value| crate::schema::issues("sketch.insert_svg", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
// `path` is not read when `text` is given; `height` not when `width` is; `scale` not when
// either is.
assert!(reads(json!({"text": "x", "path": 5})) && reads(json!({"text": "x", "path": ""})));
assert!(reads(json!({"width": 5, "height": "x", "scale": "y"})) && reads(json!({"width": 5, "height": null})));
assert!(reads(json!({"height": 5, "scale": "y"})) && reads(json!({"height": 5, "scale": null})));
assert!(reads(json!({"path": "a.dxf", "scale": 2, "tolerance": 0.01, "at": [1, 2], "angle": 90, "align": "center"})));
assert!(reads(json!({"text": "x", "at": {"x": 1, "y": 2}, "angle": 720.5, "width": 1e12, "scale": 0, "tolerance": -1})));
// They are read when the handler reads them.
// Stricter than the handler, which reads a `text` that isn't text as left out.
assert!(!reads(json!({"text": 5, "path": "a.dxf"})) && !reads(json!({"path": "a.dxf", "scale": "y"})));
assert!(!reads(json!({"path": ""})) && !reads(json!({"path": 5, "scale": 1})));
assert!(!reads(json!({"text": "x", "height": 0})) && !reads(json!({"text": "x", "height": "x", "scale": 2})));
assert!(!reads(json!({"text": "x", "width": null, "height": 5})) && !reads(json!({"text": "x", "scale": "y", "width": 0})));
// Only while no sketch is being edited does `plane` or `name` matter, which the check can't
// see: they take anything.
assert!(reads(json!({"text": "x", "plane": 5, "name": [1]})) && reads(json!({"text": "x", "plane": null, "name": null})));
assert_eq!(issues(json!({"text": "x", "width": 0})), ["`width` must be a positive number (mm)"]);
assert_eq!(issues(json!({"text": "x", "height": -1})), ["`height` must be a positive number (mm)"]);
assert_eq!(issues(json!({"text": "x", "angle": "1"})), ["`angle` must be a number (degrees)"]);
assert_eq!(issues(json!({"text": "x", "align": "left"})), ["`align` must be \"origin\" or \"center\""]);
assert_eq!(issues(json!({"path": " "})), ["give `path` or `text`"]);
assert_eq!(issues(json!({"text": "x", "sketch": true})), ["`sketch` must be an id or a name"]);
assert_eq!(issues(json!({"text": "x", "wdth": 5})), ["unknown key `wdth`; did you mean `width`?"]);
assert!(issues(json!({"text": "x", "width": 5, "height": "x", "path": 5})).is_empty());
let export = |p: Value| serde_json::from_value::<ExportDxfParams>(p).is_ok();
assert!(export(json!({})) && export(json!({"sketch": null, "path": null})) && export(json!({"sketch": "S", "path": ""})));
assert!(export(json!({"sketch": 3, "path": "a.dxf"})) && !export(json!({"sketch": true})) && !export(json!({"path": 5})));
}
/// What the schemas take: a drawing from `path` or `text`, and the keys' own shapes.
#[test]
fn the_schemas_take_what_the_handlers_read() {
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
let schema = schema.as_value();
for (p, takes) in cases {
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
}
};
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
takes(
schema,
&[
(json!({"text": "x"}), true),
(json!({"path": "a.svg", "at": [1, 2], "scale": 2, "angle": 45, "align": "center", "tolerance": 0.01}), true),
(json!({"text": "x", "width": 40, "plane": "XZ", "name": "Logo", "sketch": "Sketch1"}), true),
(json!({"text": "x", "plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "sketch": 4}), true),
(json!({}), false),
(json!({"width": 40}), false),
(json!({"text": "x", "width": 0}), false),
(json!({"text": "x", "height": -1}), false),
(json!({"text": "x", "angle": "x"}), false),
(json!({"text": "x", "align": "left"}), false),
(json!({"text": "x", "at": [1]}), false),
(json!({"text": "x", "wdth": 5}), false),
(json!({"path": "x".repeat(4096)}), false),
(json!({"text": "x", "path": " "}), true),
// Unread keys take anything, as in the handler; read ones keep their shapes.
(json!({"text": "x", "path": 5}), true),
(json!({"text": "x", "width": 5, "height": -1}), true),
(json!({"text": "x", "width": 5, "scale": "y"}), true),
(json!({"text": "x", "height": 5, "scale": "y"}), true),
(json!({"path": 5}), false),
(json!({"text": "x", "height": "x", "scale": 2}), false),
(json!({"text": "x", "scale": "y"}), false),
],
);
}
// A `path` read without `text` is not blank (`conforms` doesn't check patterns).
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
let read_path = &schema.as_value()["anyOf"][1]["properties"]["path"];
assert_eq!(read_path["pattern"], json!(crate::schema::types::not_blank_pattern()));
}
takes(
input_schema_for::<ExportDxfParams>(),
&[
(json!({}), true),
(json!({"sketch": "S", "path": "a.dxf"}), true),
(json!({"sketch": 2}), true),
(json!({"sketch": true}), false),
(json!({"file": "a.dxf"}), false),
],
);
}
fn matches(schema: schemars::Schema, r: &Value) {
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
}
/// Every form of the three commands replies as its output schema says, in Warn without warnings;
/// the replies carry the keys they always had; a misspelt key gets its warning and the reply still
/// matches, and strict refuses it.
#[test]
fn replies_match_the_output_schema() {
let dir = temp_dir("replies");
let svg_file = dir.join("plate.svg");
let dxf_file = dir.join("plate.dxf");
std::fs::write(&svg_file, SVG).unwrap();
std::fs::write(&dxf_file, DXF).unwrap();
let (svg_path, dxf_path) = (svg_file.to_string_lossy().into_owned(), dxf_file.to_string_lossy().into_owned());
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let run = |s: &mut Session, id: &str, p: Value| {
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
r
};
// The session adds `created` (the ids a sketch command made) to every `sketch.*` reply, outside
// the handlers; it is not part of what they build.
let keys = |r: &Value| {
r.as_object().map(|o| o.keys().filter(|k| *k != "created").cloned().collect::<std::collections::BTreeSet<_>>()).unwrap_or_default()
};
let insert_keys: std::collections::BTreeSet<String> = ["created_sketch", "curves", "info", "sketch", "texts"].map(String::from).into();
// With no sketch being edited: a new one is made, on the plane and with the name given.
let r = run(&mut s, "sketch.insert_dxf", json!({"text": DXF, "plane": "XZ", "name": "Plate", "at": [5, 5]}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!(keys(&r), insert_keys, "{r}");
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(0)), "{r}");
assert_eq!(r["created_sketch"], r["sketch"], "{r}");
// Exactly what the handler returned before it built its reply from the type.
let mut handler_reply = r.clone();
handler_reply.as_object_mut().unwrap().remove("created");
assert_eq!(
handler_reply,
json!({"sketch": r["sketch"], "created_sketch": r["sketch"], "curves": 4, "texts": 0, "info": {"solved": true, "dof": 8}})
);
assert!(r["info"]["solved"].is_boolean() && r["info"]["dof"].is_u64(), "{r}");
// With one: into it, and `plane` and `name` (of any kind) are not read.
let id = s.active_sketch.unwrap();
for p in [
json!({"text": DXF, "path": 5, "plane": 7, "name": [1]}),
json!({"text": DXF, "width": 60, "height": "x", "scale": "y", "angle": 90, "align": "center", "at": {"x": 1, "y": 2}}),
json!({"text": DXF, "height": 10, "scale": "y", "tolerance": 0.01}),
json!({"text": DXF, "scale": 2, "align": "origin", "sketch": id}),
json!({"text": DXF, "sketch": s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default()}),
json!({"path": dxf_path, "text": null, "sketch": null}),
] {
let r = run(&mut s, "sketch.insert_dxf", p);
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!(keys(&r), insert_keys);
assert_eq!((r["sketch"].as_u64(), r["created_sketch"].clone(), r["curves"].as_u64()), (Some(id), Value::Null, Some(4)), "{r}");
}
let r = run(&mut s, "sketch.insert_svg", json!({"text": SVG, "angle": 15}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(1)), "{r}");
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "width": 40, "align": "center", "at": [10, 10]}));
assert_eq!(r["texts"], 1, "{r}");
s.execute("sketch.finish", &json!({})).unwrap();
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "plane": "YZ"}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_ne!(r["created_sketch"], Value::Null, "{r}");
s.execute("sketch.finish", &json!({})).unwrap();
// Export: the text, or a file.
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id}));
matches(output_schema_for::<ExportDxfResult>(), &r);
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
assert!(r["dxf"].as_str().is_some_and(|t| t.contains("ENTITIES")), "{r}");
assert!(r["entities"].as_u64().is_some_and(|n| n >= 4), "{r}");
let out = dir.join("out.dxf");
let out_path = out.to_string_lossy().into_owned();
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id, "path": out_path}));
matches(output_schema_for::<ExportDxfResult>(), &r);
assert_eq!(r["path"], json!(out_path), "{r}");
assert_eq!(keys(&r), ["entities", "path"].map(String::from).into(), "{r}");
assert!(std::fs::read_to_string(&out).unwrap().contains("ENTITIES"));
// A blank `path` and a null `sketch` read as left out.
s.execute("sketch.edit", &json!({"sketch": id})).unwrap();
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": null, "path": " "}));
assert!(r.get("dxf").is_some(), "{r}");
matches(output_schema_for::<ExportDxfResult>(), &r);
// The units and layer options, in any case; null and a blank layer read as left out.
for p in [
json!({"units": "IN", "layer": "PART"}),
json!({"units": "design", "layers": "Roles"}),
json!({"join": true, "layer": " "}),
json!({"units": null, "layer": null, "layers": null, "join": null}),
] {
let r = run(&mut s, "sketch.export_dxf", p);
matches(output_schema_for::<ExportDxfResult>(), &r);
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
}
let _ = std::fs::remove_dir_all(&dir);
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
let p = json!({"text": DXF, "wdth": 5});
let r = s.execute("sketch.insert_dxf", &p).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("`wdth`")))), "{r}");
matches(output_schema_for::<DrawingInserted>(), &r);
let r = s.execute("sketch.export_dxf", &json!({"pth": "a.dxf"})).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
matches(output_schema_for::<ExportDxfResult>(), &r);
let r = s.execute("sketch.insert_svg", &json!({"text": SVG, "angel": 5})).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
s.param_check = ParamCheck::Strict;
let error = s.execute("sketch.insert_dxf", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`wdth`") && error.contains("did you mean `width`"), "{error}");
}
+3
View File
@@ -67,6 +67,9 @@ sketch.dimension_text
sketch.drag
sketch.drag_end
sketch.draw.dimensioned
sketch.export_dxf
sketch.insert_dxf
sketch.insert_svg
sketch.move_point
sketch.options
sketch.polygon.circumscribed
+70 -4
View File
@@ -19,6 +19,11 @@ pub enum Class {
Testkit,
/// Binaries and build tooling: exempt from the rules.
Exempt,
/// Crates other front ends use too, kept extractable while they live here: they may depend
/// on other shared crates and on external crates (UI toolkits included) only. solidcore and
/// solidcore-record are git dependencies, not workspace members, so they count as external.
/// Any crate may depend on a shared crate; `cargo xtask shared` builds each on its own.
Shared,
}
impl Class {
@@ -27,7 +32,8 @@ impl Class {
Class::Layer(l) => Some(l),
Class::Standalone => Some(0),
Class::Testkit => Some(6),
Class::Exempt => None,
// Shared crates sit outside the layer order; `check` handles them before it asks.
Class::Exempt | Class::Shared => None,
}
}
}
@@ -60,6 +66,8 @@ pub const TABLE: &[(&str, Class)] = &[
("gpu", Class::Layer(5)),
("mcp", Class::Layer(5)),
("testkit", Class::Testkit),
// the widget handle and rect registry UI components publish into and scenarios/the control channel read (#12936)
("ui-registry", Class::Shared),
// apps and tooling
("solidcraft", Class::Exempt),
("cli", Class::Exempt),
@@ -131,6 +139,7 @@ pub enum Violation {
StandaloneHasWorkspaceDep { krate: String, dep: String },
TestkitAsNormalDep { krate: String },
UiBelowL5 { krate: String, dep: String, layer: u8 },
SharedDependsOnApp { krate: String, dep: String },
}
impl std::fmt::Display for Violation {
@@ -151,12 +160,20 @@ impl std::fmt::Display for Violation {
Violation::UiBelowL5 { krate, dep, layer } => {
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L5+")
}
Violation::SharedDependsOnApp { krate, dep } => {
write!(f, "{krate}: shared crate must depend only on other shared crates, solidcore and external crates, not workspace crate {dep}")
}
}
}
}
/// Check all rules. Returns violations sorted for stable output.
pub fn check(crates: &[Crate]) -> Vec<Violation> {
check_with(crates, classify)
}
/// `check` against any classification (the tests use one with crates TABLE does not have).
fn check_with(crates: &[Crate], classify: impl Fn(&str) -> Option<Class>) -> Vec<Violation> {
let mut out = Vec::new();
for c in crates {
let Some(class) = classify(&c.name) else {
@@ -177,9 +194,19 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
continue;
}
if class == Class::Shared {
// Every kind counts, dev included: its tests must build after extraction,
// so not even testkit.
match classify(&d.name) {
// Unregistered deps are reported on their own entry.
None | Some(Class::Shared) => {}
Some(_) => out.push(Violation::SharedDependsOnApp { krate: c.name.clone(), dep: d.name.clone() }),
}
continue;
}
match classify(&d.name) {
// Unregistered deps are reported on their own entry.
None => {}
// Unregistered deps are reported on their own entry; any crate may use a shared one.
None | Some(Class::Shared) => {}
Some(Class::Testkit) if d.kind != DepKind::Dev => {
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
}
@@ -197,7 +224,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
}
}
}
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
} else if class != Class::Shared && layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
out.push(Violation::UiBelowL5 { krate: c.name.clone(), dep: d.name.clone(), layer });
}
}
@@ -237,6 +264,7 @@ pub fn describe(class: Option<Class>) -> String {
Some(Class::Standalone) => "L0 standalone".into(),
Some(Class::Testkit) => "testkit".into(),
Some(Class::Exempt) => "exempt".into(),
Some(Class::Shared) => "shared".into(),
None => "UNREGISTERED".into(),
}
}
@@ -317,6 +345,44 @@ mod tests {
}
}
#[test]
fn shared_crate_must_not_depend_on_layered_crates() {
let v = check(&[c("solidcraft-ui-registry", &[("solidcraft-engine", Normal, true)])]);
assert!(matches!(&v[..], [Violation::SharedDependsOnApp { krate, dep }] if krate == "solidcraft-ui-registry" && dep == "solidcraft-engine"));
assert!(v[0].to_string().contains("shared crate must depend only on"));
let v = check(&[c("solidcraft-ui-registry", &[("solidcraft-geom", Build, true)])]);
assert!(matches!(v[..], [Violation::SharedDependsOnApp { .. }]));
}
#[test]
fn any_crate_may_use_a_shared_crate() {
for user in ["solidcraft-geom", "solidcraft-engine", "solidcraft-ui-egui", "solidcraft-testkit"] {
assert!(check(&[c(user, &[("solidcraft-ui-registry", Normal, true)])]).is_empty(), "{user}");
}
}
#[test]
fn shared_crate_may_use_shared_and_external_crates() {
let classes = |pkg: &str| if short_name(pkg) == "ui-text" { Some(Class::Shared) } else { classify(pkg) };
for kind in [Normal, Dev, Build] {
let g = [c("solidcraft-ui-registry", &[("solidcraft-ui-text", kind, true), ("serde", Normal, false), ("solidcore", Normal, false)])];
assert!(check_with(&g, classes).is_empty(), "{kind:?}: {:?}", check_with(&g, classes));
}
}
#[test]
fn shared_crate_may_use_ui_crates() {
for dep in ["egui", "eframe", "winit", "wgpu"] {
assert!(check(&[c("solidcraft-ui-registry", &[(dep, Normal, false)])]).is_empty(), "{dep}");
}
}
#[test]
fn shared_crate_must_not_use_testkit_even_in_tests() {
let v = check(&[c("solidcraft-ui-registry", &[("solidcraft-testkit", Dev, true)])]);
assert!(matches!(v[..], [Violation::SharedDependsOnApp { .. }]));
}
#[test]
fn metadata_parsing() {
let meta: Value = serde_json::from_str(
+15 -5
View File
@@ -21,6 +21,7 @@ mod mesh_decisions;
mod params;
mod pending;
mod render_assets;
mod shared;
mod skills;
mod test;
mod test_nextest;
@@ -37,6 +38,10 @@ usage: cargo xtask <command>
commands:
ui-containers enforce the universal container for all UI hosts
layers enforce the crate dependency layering (plan/architecture.md §3)
shared check that each shared crate (layers' Shared class) has workspace lints, a licence,
a README and no includes from outside its directory, then build and test it in a
scratch workspace holding only it and the shared crates it uses
(target/xtask-shared): it would build after extraction
assets check that every non-code asset file is attributed in ATTRIBUTION.md
app-icons regenerate shared app-icon exports for Linux, FreeBSD and Windows
icons validate UI icon standards, discovery and review-tool behavior (Python 3.11+)
@@ -129,8 +134,8 @@ commands:
web-offline-test [--site DIR]
check in headless Chrome that the web app works offline and updates to a new
build (a stand-in app, or the web build in DIR such as dist/web)
ci fmt --check, assets, icons, licences --check, lists, skills, layers, ui-containers,
mesh-decisions, clippy -D warnings, then the tests
ci fmt --check, assets, icons, licences --check, lists, skills, layers, shared,
ui-containers, mesh-decisions, clippy -D warnings, then the tests
with the wasm check alongside, then web-offline-test when Chrome is installed
(stops at the first failing step)
";
@@ -140,6 +145,7 @@ fn main() -> ExitCode {
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
let result = match args.first().map(String::as_str) {
Some("layers") => cmd_layers(),
Some("shared") => shared::run(&root()),
Some("ui-containers") => ui_containers::run(&root()),
Some("assets") => assets::run(&root()),
Some("icons") => icons::run(&root()),
@@ -202,7 +208,7 @@ pub fn cargo() -> Command {
c
}
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
pub(crate) fn run(mut cmd: Command, what: &str) -> Result<(), String> {
eprintln!("$ {what}");
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
@@ -425,8 +431,11 @@ fn cmd_wasm() -> Result<(), String> {
/// scripts and proc macros built for clippy and the tests are reused.
fn wasm_check() -> Result<(Command, usize), String> {
let crates = layers::from_metadata(&metadata()?)?;
let mut set: Vec<String> =
crates.iter().filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))).map(|c| c.name.clone()).collect();
let mut set: Vec<String> = crates
.iter()
.filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_) | layers::Class::Shared)))
.map(|c| c.name.clone())
.collect();
set.push("solidcraft-web".into());
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "--profile", "ci"]);
@@ -448,6 +457,7 @@ fn cmd_ci() -> Result<(), String> {
lists::run(&root(), false)?;
skills::run(&root())?;
cmd_layers()?;
shared::run(&root())?;
ui_containers::run(&root())?;
mesh_decisions::run(&root())?;
let mut c = cargo();
+415
View File
@@ -0,0 +1,415 @@
//! `cargo xtask shared`: prove each shared crate (`layers::Class::Shared`) would build after
//! extraction from this repository.
//!
//! For every shared crate it first checks the rules that keep a crate movable: its manifest uses
//! the workspace lints (`[lints] workspace = true`) and states a licence, a README.md sits beside
//! it, and no `include_str!`/`include_bytes!` reaches outside its own directory. Then it copies
//! the crate and the shared crates it depends on (transitively) into a scratch workspace under
//! `target/xtask-shared/<crate>/`, whose root manifest keeps this workspace's package fields,
//! lints, profiles, patches (made absolute) and external dependencies but none of its path
//! dependencies except the copied shared crates, and runs `cargo test --workspace` there with the
//! repository's Cargo.lock. A dependency on any other workspace crate is then simply missing, so
//! the build fails, which is the point.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use proc_macro2::{TokenStream, TokenTree};
use crate::layers::{self, Class};
/// Scratch workspaces, one per shared crate, under the target directory.
const SCRATCH: &str = "target/xtask-shared";
/// One target directory for every scratch workspace, so their builds share artefacts.
const SCRATCH_TARGET: &str = "target/xtask-shared-target";
pub fn run(root: &Path) -> Result<(), String> {
let meta = crate::metadata()?;
let crates = layers::from_metadata(&meta)?;
let dirs = manifest_dirs(&meta)?;
let shared: BTreeSet<&str> = crates.iter().filter(|c| layers::classify(&c.name) == Some(Class::Shared)).map(|c| c.name.as_str()).collect();
if shared.is_empty() {
println!("shared: no shared crates (layers::Class::Shared) to check");
return Ok(());
}
let dir_of = |name: &str| dirs.get(name).cloned().ok_or_else(|| format!("{name}: no manifest_path in cargo metadata"));
let mut problems = Vec::new();
for &name in &shared {
problems.extend(static_rules(name, &dir_of(name)?)?);
}
if !problems.is_empty() {
println!("{} shared-crate rule violation(s):", problems.len());
for p in &problems {
println!(" - {p}");
}
return Err(format!("{} shared-crate rule violation(s)", problems.len()));
}
let root_manifest = read_toml(&root.join("Cargo.toml"))?;
let shared_dirs: BTreeMap<PathBuf, &str> = shared.iter().map(|&n| Ok((canonical(&dir_of(n)?)?, n))).collect::<Result<_, String>>()?;
for &name in &shared {
let closure = shared_closure(name, &crates);
let scratch = root.join(SCRATCH).join(layers::short_name(name));
if scratch.exists() {
std::fs::remove_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?;
}
std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?;
let mut members = Vec::new();
for member in &closure {
let dest = layers::short_name(member).to_string();
copy_dir(&dir_of(member.as_str())?, &scratch.join(&dest))?;
members.push(dest);
}
let manifest = scratch_manifest(root, &root_manifest, &members, &shared_dirs)?;
let text = toml::to_string(&manifest).map_err(|e| format!("{name}: scratch Cargo.toml: {e}"))?;
write(&scratch.join("Cargo.toml"), &text)?;
let lock = root.join("Cargo.lock");
if lock.is_file() {
std::fs::copy(&lock, scratch.join("Cargo.lock")).map_err(|e| format!("{}: {e}", lock.display()))?;
}
let mut c = crate::cargo();
c.current_dir(&scratch).env("CARGO_TARGET_DIR", root.join(SCRATCH_TARGET)).args(["test", "--workspace"]);
crate::run(c, &format!("cargo test --workspace (shared crate {name} on its own: {})", members.join(", ")))
.map_err(|e| format!("shared crate {name} does not build and test on its own: {e}"))?;
}
println!("shared: {} shared crate(s) build and test on their own", shared.len());
Ok(())
}
/// Package name to the directory holding its Cargo.toml.
fn manifest_dirs(meta: &serde_json::Value) -> Result<BTreeMap<String, PathBuf>, String> {
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
let mut out = BTreeMap::new();
for p in pkgs {
let (Some(name), Some(manifest)) = (p["name"].as_str(), p["manifest_path"].as_str()) else { continue };
if let Some(dir) = Path::new(manifest).parent() {
out.insert(name.to_string(), dir.to_path_buf());
}
}
Ok(out)
}
/// The crate and every shared crate it reaches through workspace dependencies of any kind.
fn shared_closure(name: &str, crates: &[layers::Crate]) -> BTreeSet<String> {
let mut seen = BTreeSet::new();
let mut todo = vec![name.to_string()];
while let Some(n) = todo.pop() {
if !seen.insert(n.clone()) {
continue;
}
let Some(c) = crates.iter().find(|c| c.name == n) else { continue };
todo.extend(c.deps.iter().filter(|d| d.workspace && layers::classify(&d.name) == Some(Class::Shared)).map(|d| d.name.clone()));
}
seen
}
/// The scratch workspace's root manifest (see the module comment).
fn scratch_manifest(root: &Path, src: &toml::Table, members: &[String], shared_dirs: &BTreeMap<PathBuf, &str>) -> Result<toml::Table, String> {
let empty = toml::Table::new();
let ws = src.get("workspace").and_then(toml::Value::as_table).unwrap_or(&empty);
let mut workspace = toml::Table::new();
workspace.insert("resolver".into(), toml::Value::String("3".into()));
workspace.insert("members".into(), toml::Value::Array(members.iter().cloned().map(toml::Value::String).collect()));
for key in ["package", "lints"] {
if let Some(v) = ws.get(key) {
workspace.insert(key.into(), v.clone());
}
}
let mut deps = toml::Table::new();
for (key, value) in ws.get("dependencies").and_then(toml::Value::as_table).unwrap_or(&empty) {
let Some(path) = path_of(value) else {
deps.insert(key.clone(), value.clone());
continue;
};
// Path dependencies stay only when they are shared crates, pointed at their copies.
let Ok(dir) = canonical(&root.join(path)) else { continue };
// Only the copied ones: a shared crate outside this closure has no directory here.
if let Some(pkg) = shared_dirs.get(&dir).filter(|pkg| members.iter().any(|m| m == layers::short_name(pkg))) {
let mut entry = value.as_table().cloned().unwrap_or_default();
entry.insert("path".into(), toml::Value::String(layers::short_name(pkg).to_string()));
deps.insert(key.clone(), toml::Value::Table(entry));
}
}
workspace.insert("dependencies".into(), toml::Value::Table(deps));
let mut out = toml::Table::new();
out.insert("workspace".into(), toml::Value::Table(workspace));
if let Some(profile) = src.get("profile") {
out.insert("profile".into(), profile.clone());
}
if let Some(patches) = src.get("patch").and_then(toml::Value::as_table) {
let mut patch = toml::Table::new();
for (registry, entries) in patches {
let mut table = toml::Table::new();
for (key, value) in entries.as_table().unwrap_or(&empty) {
let mut value = value.clone();
if let (Some(path), Some(entry)) = (path_of(&value).map(str::to_string), value.as_table_mut()) {
let abs = root.join(&path);
let abs = abs.to_str().ok_or_else(|| format!("patch {registry}.{key}: path {} is not UTF-8", abs.display()))?;
entry.insert("path".into(), toml::Value::String(abs.to_string()));
}
table.insert(key.clone(), value);
}
patch.insert(registry.clone(), toml::Value::Table(table));
}
out.insert("patch".into(), toml::Value::Table(patch));
}
Ok(out)
}
fn path_of(value: &toml::Value) -> Option<&str> {
value.as_table()?.get("path")?.as_str()
}
/// The manifest, README and include rules for one shared crate.
fn static_rules(name: &str, dir: &Path) -> Result<Vec<String>, String> {
let mut out = Vec::new();
let manifest = read_toml(&dir.join("Cargo.toml"))?;
let lints_from_workspace = manifest.get("lints").and_then(|l| l.get("workspace")).and_then(toml::Value::as_bool) == Some(true);
if !lints_from_workspace {
out.push(format!("{name}: Cargo.toml must use the workspace lints (`[lints] workspace = true`)"));
}
let licence = manifest.get("package").and_then(|p| p.get("license"));
let has_licence = match licence {
Some(toml::Value::String(s)) => !s.trim().is_empty(),
Some(v) => v.get("workspace").and_then(toml::Value::as_bool) == Some(true),
None => false,
};
if !has_licence {
out.push(format!("{name}: Cargo.toml must state a licence (`license = \"...\"` or `license.workspace = true`)"));
}
if !dir.join("README.md").is_file() {
out.push(format!("{name}: needs a README.md beside its Cargo.toml stating its public API"));
}
let crate_dir = canonical(dir)?;
let mut files = Vec::new();
rust_files(dir, &mut files)?;
for file in files {
let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
let tokens: TokenStream = text.parse().map_err(|e| format!("{}: does not tokenise: {e}", file.display()))?;
let base = canonical(file.parent().unwrap_or(dir))?;
let shown = file.strip_prefix(dir).unwrap_or(&file).display().to_string();
for (mac, target) in includes(tokens, &base, &crate_dir) {
let inside = match &target {
Included::Relative(p) => normalise(p).starts_with(&crate_dir),
Included::Absolute => false,
};
if !inside {
out.push(format!("{name}: {shown}: `{mac}!` reads outside the crate's own directory; move the file into the crate"));
}
}
}
Ok(out)
}
enum Included {
/// The resolved path (not yet normalised).
Relative(PathBuf),
/// An absolute literal path, which never travels with the crate.
Absolute,
}
/// Every `include_str!`/`include_bytes!` whose path is a string literal, or
/// `concat!(env!("CARGO_MANIFEST_DIR"), "...")`, with the file it names. Paths built any other way
/// cannot be resolved here and are left alone.
fn includes(tokens: TokenStream, base: &Path, crate_dir: &Path) -> Vec<(String, Included)> {
let mut out = Vec::new();
let trees: Vec<TokenTree> = tokens.into_iter().collect();
for (i, tree) in trees.iter().enumerate() {
match tree {
TokenTree::Ident(id) if *id == "include_str" || *id == "include_bytes" => {
let (Some(TokenTree::Punct(bang)), Some(TokenTree::Group(args))) = (trees.get(i + 1), trees.get(i + 2)) else { continue };
if bang.as_char() != '!' {
continue;
}
if let Some(target) = include_target(args.stream(), base, crate_dir) {
out.push((id.to_string(), target));
}
}
TokenTree::Group(g) => out.extend(includes(g.stream(), base, crate_dir)),
_ => {}
}
}
out
}
fn include_target(args: TokenStream, base: &Path, crate_dir: &Path) -> Option<Included> {
let trees: Vec<TokenTree> = args.into_iter().collect();
match &trees[..] {
[TokenTree::Literal(lit)] => {
let path = string_literal(lit)?;
if Path::new(&path).is_absolute() { Some(Included::Absolute) } else { Some(Included::Relative(base.join(path))) }
}
[TokenTree::Ident(concat), TokenTree::Punct(bang), TokenTree::Group(inner)] if *concat == "concat" && bang.as_char() == '!' => {
let parts: Vec<TokenTree> = inner.stream().into_iter().collect();
let [TokenTree::Ident(env), TokenTree::Punct(b), TokenTree::Group(var), rest @ ..] = &parts[..] else { return None };
let var: Vec<TokenTree> = var.stream().into_iter().collect();
let from_manifest_dir = matches!(&var[..], [TokenTree::Literal(l)] if string_literal(l).as_deref() == Some("CARGO_MANIFEST_DIR"));
if *env != "env" || b.as_char() != '!' || !from_manifest_dir {
return None;
}
let mut tail = String::new();
for t in rest {
match t {
TokenTree::Punct(p) if p.as_char() == ',' => {}
TokenTree::Literal(l) => tail.push_str(&string_literal(l)?),
_ => return None,
}
}
Some(Included::Relative(crate_dir.join(tail.trim_start_matches(['/', '\\']))))
}
_ => None,
}
}
fn string_literal(lit: &proc_macro2::Literal) -> Option<String> {
syn::parse2::<syn::LitStr>(TokenStream::from(TokenTree::Literal(lit.clone()))).ok().map(|s| s.value())
}
/// Resolve `.` and `..` without touching the file system (the file may not exist).
fn normalise(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
/// Every .rs file under `dir`, skipping build output.
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
for entry in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
let path = entry.path();
let kind = entry.file_type().map_err(|e| format!("{}: {e}", path.display()))?;
if kind.is_dir() {
if entry.file_name() != "target" {
rust_files(&path, out)?;
}
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
out.sort();
Ok(())
}
/// Copy `from` into `to`, skipping any `target` directory.
fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
std::fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
for entry in std::fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))? {
let entry = entry.map_err(|e| format!("{}: {e}", from.display()))?;
let src = entry.path();
let dest = to.join(entry.file_name());
if src.is_dir() {
if entry.file_name() != "target" {
copy_dir(&src, &dest)?;
}
} else {
std::fs::copy(&src, &dest).map_err(|e| format!("{} -> {}: {e}", src.display(), dest.display()))?;
}
}
Ok(())
}
fn read_toml(path: &Path) -> Result<toml::Table, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
}
fn write(path: &Path, text: &str) -> Result<(), String> {
std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))
}
fn canonical(path: &Path) -> Result<PathBuf, String> {
std::fs::canonicalize(path).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn found(src: &str, base: &str, crate_dir: &str) -> Vec<(String, bool)> {
let crate_dir = Path::new(crate_dir);
includes(src.parse().unwrap(), Path::new(base), crate_dir)
.into_iter()
.map(|(m, t)| {
let inside = match t {
Included::Relative(p) => normalise(&p).starts_with(crate_dir),
Included::Absolute => false,
};
(m, inside)
})
.collect()
}
#[test]
fn includes_inside_the_crate_pass() {
let src = r#"const A: &str = include_str!("../assets/a.txt"); fn f() { let b = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/b.bin")); }"#;
assert_eq!(found(src, "/w/crates/x/src", "/w/crates/x"), [("include_str".to_string(), true), ("include_bytes".to_string(), true)]);
}
#[test]
fn includes_outside_the_crate_flagged() {
let src = r#"
const A: &str = include_str!("../../../assets/a.txt");
const B: &[u8] = include_bytes!("/etc/b.bin");
const C: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../other/c.txt"));
const D: &[&str] = &[include_str!(r"../../d.txt")];
"#;
let v = found(src, "/w/crates/x/src", "/w/crates/x");
assert_eq!(v.len(), 4);
assert!(v.iter().all(|(_, inside)| !inside), "{v:?}");
}
#[test]
fn unresolvable_include_paths_are_left_alone() {
assert!(found(r#"const A: &str = include_str!(env!("SOMEWHERE"));"#, "/w/x/src", "/w/x").is_empty());
}
#[test]
fn scratch_manifest_keeps_external_and_shared_dependencies_only() {
let root = std::env::temp_dir().join(format!("xtask-shared-test-{}", std::process::id()));
let shared_dir = root.join("crates/ui-registry");
let other_dir = root.join("crates/engine");
std::fs::create_dir_all(&shared_dir).unwrap();
std::fs::create_dir_all(&other_dir).unwrap();
let src: toml::Table = toml::from_str(
r#"
[workspace]
members = ["crates/*"]
[workspace.package]
edition = "2024"
[workspace.dependencies]
serde = "1"
solidcraft-ui-registry = { path = "crates/ui-registry" }
solidcraft-engine = { path = "crates/engine" }
[workspace.lints.rust]
unsafe_code = "forbid"
[profile.ci]
inherits = "dev"
[patch.crates-io]
winit = { path = "vendor/winit" }
"#,
)
.unwrap();
let shared_dirs = BTreeMap::from([(std::fs::canonicalize(&shared_dir).unwrap(), "solidcraft-ui-registry")]);
let m = scratch_manifest(&root, &src, &["ui-registry".into()], &shared_dirs).unwrap();
let _ = std::fs::remove_dir_all(&root);
let ws = m["workspace"].as_table().unwrap();
assert_eq!(ws["resolver"].as_str(), Some("3"));
let deps = ws["dependencies"].as_table().unwrap();
assert!(deps.contains_key("serde") && !deps.contains_key("solidcraft-engine"));
assert_eq!(path_of(&deps["solidcraft-ui-registry"]), Some("ui-registry"));
assert!(ws.contains_key("package") && ws.contains_key("lints") && m.contains_key("profile"));
let winit = path_of(&m["patch"]["crates-io"]["winit"]).unwrap();
assert!(Path::new(winit).is_absolute() && winit.ends_with("vendor/winit"));
}
}