mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 04:52:10 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c18aaf7cc3 |
@@ -6,29 +6,48 @@ 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::{CurveKind, LinkKind, LinkSource, Sketch};
|
||||
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::sketch::{edit, ids_of};
|
||||
use super::sketch::{Left, edit_solved, 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", insert_dxf)
|
||||
CommandSpec::new("sketch.insert_dxf", "Insert DXF", crate::checked!("sketch.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", insert_svg)
|
||||
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.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", export_dxf).icon("sketch_dxf")
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", crate::checked!("sketch.export_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)"),
|
||||
];
|
||||
|
||||
@@ -251,10 +270,13 @@ 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());
|
||||
}
|
||||
let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
// `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 (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
|
||||
Ok((ids_of(sk, &cs), texts))
|
||||
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
|
||||
})?;
|
||||
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;
|
||||
@@ -279,7 +301,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info}))
|
||||
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -522,9 +544,10 @@ 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}")))?;
|
||||
return Ok(json!({"path": path, "entities": entity_count}));
|
||||
let exported = DxfExported::Written { path: path.to_string(), entities: entity_count };
|
||||
return reply(cmd, ExportDxfResult { exported, param_warnings: Vec::new() });
|
||||
}
|
||||
Ok(json!({"dxf": text, "entities": entity_count}))
|
||||
reply(cmd, ExportDxfResult { exported: DxfExported::Text { dxf: text, entities: entity_count }, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -110,6 +110,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,313 @@
|
||||
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}");
|
||||
}
|
||||
@@ -67,9 +67,6 @@ 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
|
||||
|
||||
+4
-70
@@ -19,11 +19,6 @@ 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 {
|
||||
@@ -32,8 +27,7 @@ impl Class {
|
||||
Class::Layer(l) => Some(l),
|
||||
Class::Standalone => Some(0),
|
||||
Class::Testkit => Some(6),
|
||||
// Shared crates sit outside the layer order; `check` handles them before it asks.
|
||||
Class::Exempt | Class::Shared => None,
|
||||
Class::Exempt => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,8 +60,6 @@ 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),
|
||||
@@ -139,7 +131,6 @@ 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 {
|
||||
@@ -160,20 +151,12 @@ 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 {
|
||||
@@ -194,19 +177,9 @@ fn check_with(crates: &[Crate], classify: impl Fn(&str) -> Option<Class>) -> Vec
|
||||
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; any crate may use a shared one.
|
||||
None | Some(Class::Shared) => {}
|
||||
// Unregistered deps are reported on their own entry.
|
||||
None => {}
|
||||
Some(Class::Testkit) if d.kind != DepKind::Dev => {
|
||||
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
||||
}
|
||||
@@ -224,7 +197,7 @@ fn check_with(crates: &[Crate], classify: impl Fn(&str) -> Option<Class>) -> Vec
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if class != Class::Shared && layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
out.push(Violation::UiBelowL5 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
||||
}
|
||||
}
|
||||
@@ -264,7 +237,6 @@ 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(),
|
||||
}
|
||||
}
|
||||
@@ -345,44 +317,6 @@ 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(
|
||||
|
||||
+5
-15
@@ -21,7 +21,6 @@ mod mesh_decisions;
|
||||
mod params;
|
||||
mod pending;
|
||||
mod render_assets;
|
||||
mod shared;
|
||||
mod skills;
|
||||
mod test;
|
||||
mod test_nextest;
|
||||
@@ -38,10 +37,6 @@ 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+)
|
||||
@@ -134,8 +129,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, shared,
|
||||
ui-containers, mesh-decisions, clippy -D warnings, then the tests
|
||||
ci fmt --check, assets, icons, licences --check, lists, skills, layers, 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)
|
||||
";
|
||||
@@ -145,7 +140,6 @@ 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()),
|
||||
@@ -208,7 +202,7 @@ pub fn cargo() -> Command {
|
||||
c
|
||||
}
|
||||
|
||||
pub(crate) fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
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}")) }
|
||||
@@ -431,11 +425,8 @@ 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(_) | layers::Class::Shared)))
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
let mut set: Vec<String> =
|
||||
crates.iter().filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))).map(|c| c.name.clone()).collect();
|
||||
set.push("solidcraft-web".into());
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "--profile", "ci"]);
|
||||
@@ -457,7 +448,6 @@ 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();
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
//! `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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user