Compare commits

..

1 Commits

Author SHA1 Message Date
PeppaWutz c18aaf7cc3 Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508) (#12601)
* Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508)

sketch.insert_dxf, sketch.insert_svg and sketch.export_dxf now publish typed
input and output schemas. Each parameter is read the way its handler reads
it today and refused with the handler's own message, and the replies are
built from the same types the output schemas describe, so the JSON they
return does not change. Keys the handler reads only in some cases (a path
next to text, a height next to a width, a scale next to either) are read by
the check in the same cases only. Their lines leave unconverted.txt.

Part of #2829.

* Insert DXF and SVG: the schema refuses a blank or too-long path when it is read

* Insert DXF and SVG: the schema checks height, scale and path only when they are read

The published schema constrained `height` next to a `width`, `scale` next to
either, and `path` next to a `text`, which the handler never reads. Their shapes
now apply only in the cases the handler reads them, matching `leave_unread`.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 02:38:51 +00:00
8 changed files with 804 additions and 513 deletions
+33 -10
View File
@@ -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)]
+1
View File
@@ -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;
+448
View File
@@ -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}");
}
-3
View File
@@ -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
View File
@@ -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
View File
@@ -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();
-415
View File
@@ -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"));
}
}