xtask (ci, assets, layers, wasm, parity, version, ico), PowerPoint parity catalog, ROADMAP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
55a5752d25
commit
0c0f55e47c
|
|
@ -2,3 +2,6 @@
|
|||
# (build.rs warns and continues without it). CI and releases set CRAFT_FONTS_DIR explicitly.
|
||||
[env]
|
||||
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
|
||||
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
|
|
|
|||
|
|
@ -3044,6 +3044,7 @@ dependencies = [
|
|||
name = "slidecraft-pptx"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"serde_json",
|
||||
|
|
@ -4363,6 +4364,13 @@ version = "0.8.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "apps/*"]
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/slidecraft", "apps/slidecraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
# SlideCraft roadmap
|
||||
|
||||
SlideCraft aims at full PowerPoint parity — and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
(macOS, Windows, Linux, BSD and the web).
|
||||
|
||||
## Status (2026-10-07)
|
||||
|
||||
**Working today**
|
||||
- Document model: presentations, slides, masters and 11 standard layouts with placeholder inheritance
|
||||
(slide → layout → master → theme), sections, notes, comments, custom shows, header/footer fields,
|
||||
embedded media, unlimited undo built on shared slide snapshots.
|
||||
- Themes: 8 original themes with colour and font schemes, custom colours/fonts, backgrounds,
|
||||
slide size presets.
|
||||
- Shapes: ~150 preset geometries with adjustment handles, text boxes, pictures (with crop and
|
||||
adjustments), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more),
|
||||
basic SmartArt, groups, connectors, ink, action buttons, WordArt.
|
||||
- Text: in-place editing with caret, selection, keyboard and mouse; every Home-tab font and paragraph
|
||||
control; bullets and numbering; levels; autofit; columns; vertical text; fields; hyperlinks.
|
||||
- Editing: select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; z-order;
|
||||
group/ungroup; align/distribute; Format Painter; Selection pane; clipboard incl. images.
|
||||
- Transitions (fade, push, wipe, split, cover, uncover, zoom, morph…) and animations (entrance,
|
||||
emphasis, exit, motion paths, triggers, by-paragraph builds) on a shared timeline engine.
|
||||
- Slide show: full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen, presenter
|
||||
window, rehearse timings, custom shows, Set Up Show; Reading View.
|
||||
- Views: Normal (thumbnails with sections, slide, notes), Outline, Slide Sorter, Notes Page,
|
||||
Slide Master; zoom; grayscale.
|
||||
- Review: comments, accessibility checker, spelling.
|
||||
- UI: PowerPoint-style ribbon with contextual tabs, ~240 original icons, status bar, panes, command
|
||||
palette, light/dark.
|
||||
- Automation: ~200 commands, every one reachable from `slidecraft-cli`, the app's JSON control
|
||||
channel and the MCP server (headless or connected to the running app).
|
||||
|
||||
**In progress:** PPTX import/export · web (WASM) build · release packaging for all platforms.
|
||||
|
||||
**Next (in order):** PDF export · audio/video playback · Format Shape pane depth · connectors with
|
||||
glue, freeform and edit points · merge shapes · Animation Pane polish · native macOS menu bar ·
|
||||
print · Notes/Handout masters · equations · SVG pictures.
|
||||
|
||||
Milestone details live in `plan/execution-plan.md` (M0–M14, local planning notes).
|
||||
|
||||
## How far from full parity (estimate, 2026-10-07)
|
||||
|
||||
**Breadth: ~73% weighted** (P0 core 87%, P1 67%, P2 33%) over the 187 features of the PowerPoint
|
||||
catalogue, scored row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes
|
||||
it). Many features scored done still lack some of PowerPoint's options, dialogs or pixel fidelity, so
|
||||
**overall parity including depth is about 55%**.
|
||||
|
||||
**Remaining work: about 230 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly
|
||||
80–110 hours with four agents in parallel on separate crates:
|
||||
|
||||
| Work | Estimate |
|
||||
|---|---|
|
||||
| Open P0 (15 partial, 2 missing: PPTX fidelity, PDF export, media playback, Format Shape pane, presenter view…) | 30 h |
|
||||
| Open P1 (23 partial, 10 missing: connectors/glue, freeform, merge shapes, print, multi-monitor, SVG…) | 45 h |
|
||||
| Open P2 (11 partial, 28 missing: equations, video export, 3-D, remove background, thesaurus…) | 40 h |
|
||||
| Depth and pixel fidelity of every ribbon group, dialog and pane against PowerPoint | 75 h |
|
||||
| Performance (incremental rendering, GPU raster) and PPTX corpus hardening | 30 h |
|
||||
| Platform verification and release hardening (signing, Flatpak, BSD, web) | 10 h |
|
||||
|
||||
Basis: the M0 arc (model, renderer, text engine, engine with ~200 commands, ribbon UI, show engine,
|
||||
CLI and MCP) took roughly 25 wall-clock agent hours; open catalogue rows average 1–2 hours each.
|
||||
|
||||
## Agents: CLI and MCP
|
||||
|
||||
Every command is reachable from `slidecraft-cli` (`run`, `describe`, `commands`, `app` for the running
|
||||
window, `render`, `convert`), from MCP (`slidecraft-cli mcp`, optionally `--connect PORT`), and from the
|
||||
app's JSON control channel (`slidecraft --control PORT`).
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
# Feature parity with PowerPoint
|
||||
|
||||
The summary is generated by `cargo xtask parity` from the table at the bottom of this file (edit a
|
||||
row, re-run). Rows come from the black-box feature catalogue of PowerPoint for Mac (menus, ribbon
|
||||
tabs, dialogs and panes observed in the running app — no Microsoft code, documentation assets or
|
||||
images were copied).
|
||||
|
||||
Each row is scored **D** (done), **P** (partial: works but lacks options, UI or fidelity) or **M**
|
||||
(missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out of scope: Microsoft
|
||||
365 cloud services (co-authoring through OneDrive, Copilot, stock images, Designer's online service,
|
||||
Live Presentations), add-ins and VBA macros.
|
||||
|
||||
<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 73%** over 187 features.
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 55 | 15 | 2 | 87% |
|
||||
| P1 | 65 | 32 | 23 | 10 | 67% |
|
||||
| P2 | 50 | 11 | 11 | 28 | 33% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell | 71% |
|
||||
| Files | 54% |
|
||||
| Slides | 95% |
|
||||
| Views | 90% |
|
||||
| Masters & themes | 80% |
|
||||
| Shapes | 60% |
|
||||
| Format | 75% |
|
||||
| Arrange | 100% |
|
||||
| Text | 83% |
|
||||
| Tables | 89% |
|
||||
| Charts | 65% |
|
||||
| SmartArt | 33% |
|
||||
| Pictures | 58% |
|
||||
| Media | 27% |
|
||||
| Transitions | 83% |
|
||||
| Animations | 74% |
|
||||
| Slide show | 66% |
|
||||
| Review | 60% |
|
||||
| Draw | 33% |
|
||||
| Undo & clipboard | 88% |
|
||||
| Automation (✱) | 100% |
|
||||
| Platforms (✱) | 25% |
|
||||
|
||||
Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Open PowerPoint .pptx, Save as PowerPoint .pptx, Autosave and crash recovery, Export to PDF, Format background (solid, gradient, picture, pattern), Connectors (straight, elbow, curved), Gradient fill (linear, radial, rectangular, path), Format Shape pane, Edit chart data, Crop (and crop to shape, aspect ratio), Insert audio (any common format), Audio/video playback in the show, Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
|
||||
|
||||
## Rows
|
||||
|
||||
| Area | Feature | Priority | Status |
|
||||
|---|---|---|---|
|
||||
| Application shell | Start screen (new, templates/themes, recent) | P0 | P |
|
||||
| Application shell | Ribbon with Home/Insert/Draw/Design/Transitions/Animations/Slide Show/Review/View tabs | P0 | D |
|
||||
| Application shell | Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design) | P0 | P |
|
||||
| Application shell | Quick Access toolbar / title bar commands | P1 | P |
|
||||
| Application shell | Native menu bar (macOS) with every command | P1 | M |
|
||||
| Application shell | Command palette / Tell me search | P1 | D |
|
||||
| Application shell | Keyboard shortcuts for every common command | P0 | D |
|
||||
| Application shell | Status bar (slide counter, notes, comments, views, zoom) | P0 | D |
|
||||
| Application shell | Light and dark appearance | P1 | D |
|
||||
| Application shell | Multiple open presentations | P1 | P |
|
||||
| Application shell | Preferences (autosave, units, defaults) | P2 | P |
|
||||
| Files | New blank presentation | P0 | D |
|
||||
| Files | Native file format (open/save) | P0 | D |
|
||||
| Files | Open PowerPoint .pptx | P0 | P |
|
||||
| Files | Save as PowerPoint .pptx | P0 | P |
|
||||
| Files | Open/save .potx/.ppsx/.pptm variants | P2 | M |
|
||||
| Files | Legacy .ppt (binary) import | P2 | M |
|
||||
| Files | Save as template | P1 | D |
|
||||
| Files | Autosave and crash recovery | P0 | P |
|
||||
| Files | Document properties | P2 | D |
|
||||
| Files | Export to PDF | P0 | M |
|
||||
| Files | Export slides as PNG/JPEG | P0 | D |
|
||||
| Files | Export outline / RTF | P2 | P |
|
||||
| Files | Export video (MP4) / animated GIF | P2 | M |
|
||||
| Files | Print (slides, handouts, notes, outline) | P1 | M |
|
||||
| Files | Password protection | P2 | M |
|
||||
| Files | Reuse slides from another file | P1 | M |
|
||||
| Files | Drag and drop files onto the window | P1 | D |
|
||||
| Slides | New slide by layout | P0 | D |
|
||||
| Slides | Duplicate, delete, hide slides | P0 | D |
|
||||
| Slides | Reorder slides (thumbnails, sorter) | P0 | D |
|
||||
| Slides | Sections (add, rename, remove, move, collapse) | P1 | D |
|
||||
| Slides | Slide layouts (11 standard) | P0 | D |
|
||||
| Slides | Reset slide | P1 | D |
|
||||
| Slides | Slides from outline | P2 | D |
|
||||
| Slides | Copy/paste slides between decks | P1 | P |
|
||||
| Views | Normal view (thumbnails, slide, notes) | P0 | D |
|
||||
| Views | Outline view | P1 | D |
|
||||
| Views | Slide Sorter | P0 | D |
|
||||
| Views | Notes Page | P1 | D |
|
||||
| Views | Reading View | P1 | D |
|
||||
| Views | Zoom, fit to window | P0 | D |
|
||||
| Views | Rulers, gridlines, guides | P1 | P |
|
||||
| Views | Smart guides | P1 | D |
|
||||
| Views | Grayscale / black and white view | P2 | D |
|
||||
| Views | New window / arrange windows | P2 | M |
|
||||
| Masters & themes | Slide Master view (edit master and layouts) | P0 | D |
|
||||
| Masters & themes | Insert/rename/delete layouts, insert placeholders | P1 | D |
|
||||
| Masters & themes | Handout and Notes masters | P2 | M |
|
||||
| Masters & themes | Built-in themes (original designs) | P0 | D |
|
||||
| Masters & themes | Theme variants | P1 | P |
|
||||
| Masters & themes | Colour schemes (built-in and custom) | P0 | D |
|
||||
| Masters & themes | Font schemes (built-in and custom) | P0 | D |
|
||||
| Masters & themes | Effect schemes / background styles | P2 | P |
|
||||
| Masters & themes | Format background (solid, gradient, picture, pattern) | P0 | P |
|
||||
| Masters & themes | Hide background graphics | P1 | D |
|
||||
| Masters & themes | Slide size (16:9, 4:3, presets, custom) | P0 | D |
|
||||
| Masters & themes | Header & footer (date, slide number, footer) | P1 | D |
|
||||
| Masters & themes | Browse themes from .pptx/.thmx | P2 | M |
|
||||
| Masters & themes | Designer layout suggestions (offline) | P2 | M |
|
||||
| Shapes | Preset shapes (lines, rectangles, basic, arrows, equation, flowchart, stars, callouts) | P0 | D |
|
||||
| Shapes | Action buttons | P2 | D |
|
||||
| Shapes | Adjustment handles | P0 | D |
|
||||
| Shapes | Connectors (straight, elbow, curved) | P0 | P |
|
||||
| Shapes | Connector glue to connection sites | P1 | M |
|
||||
| Shapes | Freeform, curve and scribble tools | P1 | M |
|
||||
| Shapes | Edit points | P1 | M |
|
||||
| Shapes | Merge shapes (union, combine, fragment, intersect, subtract) | P1 | M |
|
||||
| Shapes | Change shape | P1 | D |
|
||||
| Shapes | Text boxes | P0 | D |
|
||||
| Shapes | WordArt | P1 | P |
|
||||
| Shapes | Text warp transforms | P2 | M |
|
||||
| Shapes | Shape quick styles | P1 | D |
|
||||
| Shapes | Set as default shape | P2 | D |
|
||||
| Format | Solid fill and transparency | P0 | D |
|
||||
| Format | Gradient fill (linear, radial, rectangular, path) | P0 | P |
|
||||
| Format | Picture and texture fill | P1 | P |
|
||||
| Format | Pattern fill | P2 | P |
|
||||
| Format | Line colour, width, dash, compound, cap, join | P0 | D |
|
||||
| Format | Arrowheads | P0 | D |
|
||||
| Format | Shadow (outer, inner, presets) | P1 | D |
|
||||
| Format | Reflection, glow, soft edges | P2 | P |
|
||||
| Format | Bevel and 3-D rotation | P2 | M |
|
||||
| Format | Format Painter | P1 | D |
|
||||
| Format | Format Shape pane | P0 | P |
|
||||
| Arrange | Size and position (exact), lock aspect ratio | P0 | D |
|
||||
| Arrange | Rotate and flip | P0 | D |
|
||||
| Arrange | Bring forward / send backward / to front / to back | P0 | D |
|
||||
| Arrange | Align and distribute | P0 | D |
|
||||
| Arrange | Group, ungroup, regroup | P0 | D |
|
||||
| Arrange | Selection pane (names, visibility, order) | P1 | D |
|
||||
| Arrange | Nudge, duplicate with repeat offset | P1 | D |
|
||||
| Arrange | Lock shapes | P2 | D |
|
||||
| Arrange | Alt text | P1 | D |
|
||||
| Text | In-place text editing (caret, selection, mouse, keyboard) | P0 | D |
|
||||
| Text | Font, size, bold, italic, underline, strikethrough | P0 | D |
|
||||
| Text | Font colour, highlight | P0 | D |
|
||||
| Text | Superscript, subscript, caps, character spacing | P1 | D |
|
||||
| Text | Change case, grow/shrink font, clear formatting | P1 | D |
|
||||
| Text | Text outline and text effects | P2 | P |
|
||||
| Text | Paragraph alignment, indents, spacing, line spacing | P0 | D |
|
||||
| Text | Bullets and numbering | P0 | D |
|
||||
| Text | Picture bullets, custom bullet symbols | P2 | P |
|
||||
| Text | List levels (Tab / Shift+Tab) | P0 | D |
|
||||
| Text | Tab stops | P2 | P |
|
||||
| Text | Columns | P2 | D |
|
||||
| Text | Vertical anchor, margins, wrap | P0 | D |
|
||||
| Text | Autofit (shrink on overflow, resize shape) | P0 | D |
|
||||
| Text | Text direction (rotated, stacked) | P1 | D |
|
||||
| Text | Find and replace, replace fonts | P1 | P |
|
||||
| Text | Spelling check | P1 | P |
|
||||
| Text | Thesaurus | P2 | M |
|
||||
| Text | AutoCorrect and smart quotes | P2 | M |
|
||||
| Text | Symbols and emoji | P1 | D |
|
||||
| Text | Equations | P2 | M |
|
||||
| Text | Hyperlinks (URL, slide, email) | P1 | D |
|
||||
| Text | Date/time and slide number fields | P1 | D |
|
||||
| Text | Right-to-left and complex scripts | P1 | P |
|
||||
| Text | East Asian text (CJK fonts, line breaking) | P1 | P |
|
||||
| Tables | Insert table (grid picker, dialog) | P0 | D |
|
||||
| Tables | Draw table | P2 | M |
|
||||
| Tables | Table styles and style options | P0 | D |
|
||||
| Tables | Insert/delete rows and columns | P0 | D |
|
||||
| Tables | Merge and split cells | P1 | D |
|
||||
| Tables | Distribute rows/columns, sizes | P1 | D |
|
||||
| Tables | Cell fill, borders, margins, alignment | P1 | P |
|
||||
| Tables | Keyboard navigation between cells | P0 | D |
|
||||
| Charts | Insert chart (column, bar, line, area, pie, doughnut, scatter) | P0 | D |
|
||||
| Charts | Other chart types (radar, stock, combo, waterfall, funnel, treemap, sunburst, histogram, box) | P2 | P |
|
||||
| Charts | Edit chart data | P0 | P |
|
||||
| Charts | Chart elements (title, legend, labels, axes, gridlines) | P1 | P |
|
||||
| Charts | Chart styles and colours | P2 | P |
|
||||
| SmartArt | Insert SmartArt (lists, processes, cycles, hierarchy) | P1 | P |
|
||||
| SmartArt | Text pane and layout switching | P2 | M |
|
||||
| Pictures | Insert picture (PNG, JPEG, GIF, WebP, BMP, TIFF) | P0 | D |
|
||||
| Pictures | SVG pictures | P1 | M |
|
||||
| Pictures | Crop (and crop to shape, aspect ratio) | P0 | P |
|
||||
| Pictures | Corrections, colour, transparency | P1 | P |
|
||||
| Pictures | Artistic effects | P2 | M |
|
||||
| Pictures | Remove background | P2 | M |
|
||||
| Pictures | Picture styles and borders | P1 | P |
|
||||
| Pictures | Change / reset picture | P1 | D |
|
||||
| Pictures | Compress pictures | P2 | M |
|
||||
| Pictures | Paste image from clipboard | P0 | D |
|
||||
| Media | Insert audio (any common format) | P0 | P |
|
||||
| Media | Insert video | P1 | P |
|
||||
| Media | Audio/video playback in the show | P0 | M |
|
||||
| Media | Trim, fade in/out, volume, loop, play across slides | P2 | P |
|
||||
| Media | Record audio / screen recording | P2 | M |
|
||||
| Media | Icons, 3-D models, stock content | P2 | M |
|
||||
| Transitions | Transition gallery (fade, push, wipe, split, cover, uncover, zoom, morph…) | P0 | D |
|
||||
| Transitions | Effect options and duration | P0 | D |
|
||||
| Transitions | Advance on click / after time, apply to all | P0 | D |
|
||||
| Transitions | Transition sounds | P2 | M |
|
||||
| Transitions | Morph | P1 | P |
|
||||
| Animations | Entrance, emphasis, exit effects | P0 | D |
|
||||
| Animations | Motion paths | P1 | P |
|
||||
| Animations | Effect options, start (on click/with/after), duration, delay | P0 | D |
|
||||
| Animations | Animation pane (reorder, timing) | P0 | P |
|
||||
| Animations | By-paragraph text builds | P1 | D |
|
||||
| Animations | Triggers | P2 | D |
|
||||
| Animations | Animation Painter | P2 | M |
|
||||
| Animations | Preview in editor | P1 | P |
|
||||
| Slide show | Full-screen show from start / current | P0 | D |
|
||||
| Slide show | Navigation (keys, click, go to slide, blank screen) | P0 | D |
|
||||
| Slide show | Presenter view (notes, next slide, timer) | P0 | P |
|
||||
| Slide show | Pen and highlighter ink during the show | P1 | P |
|
||||
| Slide show | Laser pointer | P2 | M |
|
||||
| Slide show | Rehearse timings | P2 | D |
|
||||
| Slide show | Custom shows | P2 | D |
|
||||
| Slide show | Set up show (loop, kiosk, without animation) | P1 | D |
|
||||
| Slide show | Record slide show (narration) | P2 | M |
|
||||
| Slide show | Multi-monitor presenting | P1 | M |
|
||||
| Review | Comments (add, reply, resolve, delete) | P1 | D |
|
||||
| Review | Accessibility checker | P2 | D |
|
||||
| Review | Compare presentations | P2 | M |
|
||||
| Review | Track changes / version history | P2 | M |
|
||||
| Draw | Ink pens, highlighter, eraser | P1 | P |
|
||||
| Draw | Ink to shape / text | P2 | M |
|
||||
| Undo & clipboard | Unlimited undo/redo | P0 | D |
|
||||
| Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D |
|
||||
| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | P |
|
||||
| Automation (✱) | Every command scriptable (CLI, control channel, MCP) | P0 | D |
|
||||
| Automation (✱) | Headless rendering and conversion | P0 | D |
|
||||
| Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
|
||||
| Platforms (✱) | Web (WASM) build | P1 | M |
|
||||
| Platforms (✱) | BSD build | P2 | M |
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "xtask"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
//! `cargo xtask assets`: every non-code asset must be attributed in `ATTRIBUTION.md`.
|
||||
//!
|
||||
//! Scans all git-tracked (and untracked, not ignored) files with an asset extension, plus
|
||||
//! everything under `assets/`, `docs/images/` and `examples/`, and fails if a path is not listed
|
||||
//! as `` `path` `` in ATTRIBUTION.md. See the asset policy in AGENTS.md.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const ASSET_EXT: &[&str] = &[
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"bmp",
|
||||
"tif",
|
||||
"tiff",
|
||||
"ico",
|
||||
"icns",
|
||||
"svg",
|
||||
"pdf",
|
||||
"ai",
|
||||
"eps",
|
||||
"psd",
|
||||
"icc",
|
||||
"icm",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"ase",
|
||||
"indd",
|
||||
"idml",
|
||||
"aco",
|
||||
"abr",
|
||||
"slidecraft",
|
||||
"pptx",
|
||||
"potx",
|
||||
"ppsx",
|
||||
"thmx",
|
||||
"mp4",
|
||||
"mov",
|
||||
"webm",
|
||||
"wav",
|
||||
"mp3",
|
||||
"m4a",
|
||||
"ogg",
|
||||
"flac",
|
||||
];
|
||||
const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "docs/brand/", "examples/"];
|
||||
|
||||
pub fn is_asset(path: &str) -> bool {
|
||||
let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
/// Paths that need an ATTRIBUTION.md entry but lack one.
|
||||
pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
|
||||
let patterns: Vec<&str> = assets_md.split('`').skip(1).step_by(2).collect();
|
||||
files.iter().filter(|f| is_asset(f) && !patterns.iter().any(|p| glob(p, f))).cloned().collect()
|
||||
}
|
||||
|
||||
/// `*` matches any run of characters except `/`; everything else literally.
|
||||
fn glob(pat: &str, s: &str) -> bool {
|
||||
match pat.split_once('*') {
|
||||
None => pat == s,
|
||||
Some((head, tail)) => {
|
||||
let Some(rest) = s.strip_prefix(head) else { return false };
|
||||
(0..=rest.len()).take_while(|&i| i == 0 || !rest[..i].contains('/')).any(|i| rest.is_char_boundary(i) && glob(tail, &rest[i..]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let out = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
|
||||
.output()
|
||||
.map_err(|e| format!("git ls-files: {e}"))?;
|
||||
let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect();
|
||||
let md = std::fs::read_to_string(root.join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?;
|
||||
let miss = missing(&files, &md);
|
||||
if miss.is_empty() {
|
||||
println!("assets: all {} asset files attributed in ATTRIBUTION.md", files.iter().filter(|f| is_asset(f)).count());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{} asset file(s) lack an ATTRIBUTION.md entry (author, source, licence):\n {}", miss.len(), miss.join("\n ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_unattributed_assets() {
|
||||
let files = vec!["assets/icons/a.svg".to_string(), "crates/x/src/lib.rs".into(), "docs/images/b.png".into(), "tests/fixture.png".into()];
|
||||
let md = "| `assets/icons/a.svg` | me | here | MIT |";
|
||||
assert_eq!(missing(&files, md), vec!["docs/images/b.png".to_string(), "tests/fixture.png".into()]);
|
||||
assert!(!is_asset("crates/x/src/lib.rs"));
|
||||
assert!(is_asset("assets/fonts/OFL.txt"));
|
||||
let md = "`assets/app-icon/hicolor/*/apps/x.png`";
|
||||
assert!(missing(&["assets/app-icon/hicolor/16x16/apps/x.png".into()], md).is_empty());
|
||||
assert_eq!(missing(&["assets/app-icon/hicolor/a/b/apps/x.png".into()], md).len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
//! `cargo xtask ico <out.ico> <in.png>…`: pack PNG images into a Windows `.ico`.
|
||||
//!
|
||||
//! Vista and later read PNG-compressed icon entries, so each PNG is stored as is. The size comes
|
||||
//! from the PNG's IHDR chunk; 256 px is written as 0, per the ICO format.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Width and height from a PNG's IHDR chunk.
|
||||
pub fn png_size(png: &[u8]) -> Result<(u32, u32), String> {
|
||||
const SIG: &[u8] = b"\x89PNG\r\n\x1a\n";
|
||||
if png.len() < 24 || &png[..8] != SIG || &png[12..16] != b"IHDR" {
|
||||
return Err("not a PNG file".into());
|
||||
}
|
||||
let w = u32::from_be_bytes([png[16], png[17], png[18], png[19]]);
|
||||
let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]);
|
||||
Ok((w, h))
|
||||
}
|
||||
|
||||
/// An ICO file holding `pngs`, which must be square and at most 256 px.
|
||||
pub fn pack(pngs: &[Vec<u8>]) -> Result<Vec<u8>, String> {
|
||||
if pngs.is_empty() || pngs.len() > u16::MAX as usize {
|
||||
return Err("need at least one PNG".into());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // reserved
|
||||
out.extend_from_slice(&1u16.to_le_bytes()); // type: icon
|
||||
out.extend_from_slice(&(pngs.len() as u16).to_le_bytes());
|
||||
let mut offset = 6 + 16 * pngs.len();
|
||||
for png in pngs {
|
||||
let (w, h) = png_size(png)?;
|
||||
if w != h || w == 0 || w > 256 {
|
||||
return Err(format!("icon images must be square and at most 256 px, got {w}x{h}"));
|
||||
}
|
||||
let dim = if w == 256 { 0 } else { w as u8 };
|
||||
out.extend_from_slice(&[dim, dim, 0, 0]); // width, height, palette size, reserved
|
||||
out.extend_from_slice(&1u16.to_le_bytes()); // colour planes
|
||||
out.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
|
||||
out.extend_from_slice(&(png.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(offset as u32).to_le_bytes());
|
||||
offset += png.len();
|
||||
}
|
||||
for png in pngs {
|
||||
out.extend_from_slice(png);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn run(args: &[&str]) -> Result<(), String> {
|
||||
let [out, inputs @ ..] = args else {
|
||||
return Err("usage: cargo xtask ico <out.ico> <in.png>…".into());
|
||||
};
|
||||
let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))).collect::<Result<Vec<_>, _>>()?;
|
||||
let ico = pack(&pngs)?;
|
||||
std::fs::write(Path::new(out), &ico).map_err(|e| format!("write {out}: {e}"))?;
|
||||
println!("{out}: {} images, {} bytes", pngs.len(), ico.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_png(size: u32) -> Vec<u8> {
|
||||
let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
v.extend_from_slice(&size.to_be_bytes());
|
||||
v.extend_from_slice(&size.to_be_bytes());
|
||||
v.extend_from_slice(&[8, 6, 0, 0, 0, 1, 2, 3, 4]);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_directory_and_payloads() {
|
||||
let a = fake_png(16);
|
||||
let b = fake_png(256);
|
||||
let ico = pack(&[a.clone(), b.clone()]).unwrap();
|
||||
assert_eq!(&ico[..6], &[0, 0, 1, 0, 2, 0]);
|
||||
// Entry 0: 16 px at offset 6 + 32.
|
||||
assert_eq!(&ico[6..8], &[16, 16]);
|
||||
assert_eq!(u32::from_le_bytes(ico[14..18].try_into().unwrap()), a.len() as u32);
|
||||
assert_eq!(u32::from_le_bytes(ico[18..22].try_into().unwrap()), 38);
|
||||
// Entry 1: 256 px stored as 0, right after the first payload.
|
||||
assert_eq!(&ico[22..24], &[0, 0]);
|
||||
assert_eq!(u32::from_le_bytes(ico[34..38].try_into().unwrap()), 38 + a.len() as u32);
|
||||
assert_eq!(&ico[38..38 + a.len()], &a[..]);
|
||||
assert_eq!(&ico[38 + a.len()..], &b[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
assert!(pack(&[]).is_err());
|
||||
assert!(pack(&[fake_png(512)]).is_err());
|
||||
assert!(pack(&[b"GIF89a".to_vec()]).is_err());
|
||||
assert_eq!(png_size(&fake_png(48)).unwrap(), (48, 48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_icon_is_valid() {
|
||||
let path = crate::root().join("assets/app-icon/slidecraft.ico");
|
||||
let Ok(ico) = std::fs::read(&path) else { return };
|
||||
assert_eq!(&ico[..4], &[0, 0, 1, 0]);
|
||||
let n = u16::from_le_bytes([ico[4], ico[5]]) as usize;
|
||||
assert!(n >= 4, "expected several sizes in {}", path.display());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
//! Dependency layering rules (plan/architecture.md §3).
|
||||
//!
|
||||
//! The rule engine works on a small, metadata-independent model so it can be
|
||||
//! unit-tested; `from_metadata` builds that model from `cargo metadata`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where a workspace crate sits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Class {
|
||||
/// Regular layered crate.
|
||||
Layer(u8),
|
||||
/// Layer 0, and additionally may depend on no workspace crate at all.
|
||||
Standalone,
|
||||
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
|
||||
/// purposes); other crates may use it only as a dev-dependency.
|
||||
Testkit,
|
||||
/// Binaries and build tooling: exempt from the rules.
|
||||
Exempt,
|
||||
}
|
||||
|
||||
impl Class {
|
||||
fn layer(self) -> Option<u8> {
|
||||
match self {
|
||||
Class::Layer(l) => Some(l),
|
||||
Class::Standalone => Some(0),
|
||||
Class::Testkit => Some(6),
|
||||
Class::Exempt => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layering table. Names are package names without the `slidecraft-`
|
||||
/// prefix.
|
||||
pub const TABLE: &[(&str, Class)] = &[
|
||||
("geom", Class::Layer(0)),
|
||||
("color", Class::Layer(0)),
|
||||
("model", Class::Layer(1)),
|
||||
("fonts", Class::Layer(1)),
|
||||
("text", Class::Layer(2)),
|
||||
("anim", Class::Layer(2)),
|
||||
("format", Class::Layer(2)),
|
||||
("render", Class::Layer(3)),
|
||||
("pptx", Class::Layer(3)),
|
||||
("engine", Class::Layer(4)),
|
||||
("ui-egui", Class::Layer(6)),
|
||||
("mcp", Class::Layer(6)),
|
||||
("testkit", Class::Testkit),
|
||||
// L7 apps and tooling
|
||||
("slidecraft", Class::Exempt),
|
||||
("cli", Class::Exempt),
|
||||
("web", Class::Exempt),
|
||||
("xtask", Class::Exempt),
|
||||
];
|
||||
|
||||
/// Upward dev-dependencies allowed for integration tests: the PPTX round-trip tests drive the
|
||||
/// engine (build a deck with commands, export, re-import).
|
||||
pub const DEV_EXCEPTIONS: &[(&str, &str)] = &[("pptx", "engine")];
|
||||
|
||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
let (from, to) = (short_name(from), short_name(to));
|
||||
INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) {
|
||||
(Some(f), Some(t)) => t < f,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
|
||||
/// First layer allowed to use UI crates.
|
||||
pub const UI_MIN_LAYER: u8 = 6;
|
||||
|
||||
pub fn short_name(pkg: &str) -> &str {
|
||||
pkg.strip_prefix("slidecraft-").unwrap_or(pkg)
|
||||
}
|
||||
|
||||
pub fn classify(pkg: &str) -> Option<Class> {
|
||||
let s = short_name(pkg);
|
||||
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
fn is_ui_crate(name: &str) -> bool {
|
||||
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(prefix) => name.starts_with(prefix),
|
||||
None => name == *p,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DepKind {
|
||||
Normal,
|
||||
Dev,
|
||||
Build,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dep {
|
||||
pub name: String,
|
||||
pub kind: DepKind,
|
||||
/// `true` if the dependency is a workspace member.
|
||||
pub workspace: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Crate {
|
||||
pub name: String,
|
||||
pub deps: Vec<Dep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Violation {
|
||||
Unregistered { krate: String },
|
||||
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
|
||||
StandaloneHasWorkspaceDep { krate: String, dep: String },
|
||||
TestkitAsNormalDep { krate: String },
|
||||
UiBelowL6 { krate: String, dep: String, layer: u8 },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Violation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Violation::Unregistered { krate } => {
|
||||
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)")
|
||||
}
|
||||
Violation::Upward { krate, dep, from, to, kind } => {
|
||||
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
|
||||
}
|
||||
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
|
||||
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
|
||||
}
|
||||
Violation::TestkitAsNormalDep { krate } => {
|
||||
write!(f, "{krate}: slidecraft-testkit may only be a dev-dependency")
|
||||
}
|
||||
Violation::UiBelowL6 { krate, dep, layer } => {
|
||||
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all rules. Returns violations sorted for stable output.
|
||||
pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
let mut out = Vec::new();
|
||||
for c in crates {
|
||||
let Some(class) = classify(&c.name) else {
|
||||
out.push(Violation::Unregistered { krate: c.name.clone() });
|
||||
continue;
|
||||
};
|
||||
if class == Class::Exempt {
|
||||
continue;
|
||||
}
|
||||
let layer = class.layer().unwrap_or(0);
|
||||
for d in &c.deps {
|
||||
// Self dev-dependencies (e.g. to enable features in tests) are fine.
|
||||
if d.name == c.name {
|
||||
continue;
|
||||
}
|
||||
if d.workspace {
|
||||
if class == Class::Standalone {
|
||||
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
||||
continue;
|
||||
}
|
||||
match classify(&d.name) {
|
||||
// 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() });
|
||||
}
|
||||
}
|
||||
Some(dc) => {
|
||||
let to = dc.layer().unwrap_or(u8::MAX);
|
||||
let dev_ok = d.kind == DepKind::Dev && DEV_EXCEPTIONS.contains(&(short_name(&c.name), short_name(&d.name)));
|
||||
if to >= layer && !dev_ok && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
|
||||
out.push(Violation::Upward {
|
||||
krate: c.name.clone(),
|
||||
dep: d.name.clone(),
|
||||
from: layer,
|
||||
to: if to == u8::MAX { 7 } else { to },
|
||||
kind: d.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
out.push(Violation::UiBelowL6 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|v| v.to_string());
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
||||
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
||||
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
||||
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
let mut out = Vec::new();
|
||||
for p in pkgs {
|
||||
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
|
||||
let mut deps = Vec::new();
|
||||
for d in p["dependencies"].as_array().into_iter().flatten() {
|
||||
let dname = d["name"].as_str().unwrap_or_default().to_owned();
|
||||
let kind = match d["kind"].as_str() {
|
||||
Some("dev") => DepKind::Dev,
|
||||
Some("build") => DepKind::Build,
|
||||
_ => DepKind::Normal,
|
||||
};
|
||||
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
|
||||
deps.push(Dep { name: dname, kind, workspace });
|
||||
}
|
||||
out.push(Crate { name, deps });
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn describe(class: Option<Class>) -> String {
|
||||
match class {
|
||||
Some(Class::Layer(l)) => format!("L{l}"),
|
||||
Some(Class::Standalone) => "L0 standalone".into(),
|
||||
Some(Class::Testkit) => "testkit".into(),
|
||||
Some(Class::Exempt) => "exempt".into(),
|
||||
None => "UNREGISTERED".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
|
||||
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
|
||||
}
|
||||
use DepKind::*;
|
||||
|
||||
#[test]
|
||||
fn clean_downward_graph_passes() {
|
||||
let g = [
|
||||
c("slidecraft-geom", &[("kurbo", Normal, false)]),
|
||||
c("slidecraft-model", &[("slidecraft-geom", Normal, true)]),
|
||||
c("slidecraft-engine", &[("slidecraft-model", Normal, true), ("slidecraft-testkit", Dev, true)]),
|
||||
c("slidecraft-ui-egui", &[("slidecraft-engine", Normal, true), ("egui", Normal, false)]),
|
||||
c("slidecraft-cli", &[("slidecraft-ui-egui", Normal, true)]),
|
||||
];
|
||||
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dependency_flagged() {
|
||||
let v = check(&[c("slidecraft-model", &[("slidecraft-engine", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 4, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sideways_dependency_flagged() {
|
||||
let v = check(&[c("slidecraft-text", &[("slidecraft-anim", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l0_foundations_are_independent() {
|
||||
let v = check(&[c("slidecraft-geom", &[("slidecraft-color", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_dev_dependency_ignored() {
|
||||
assert!(check(&[c("slidecraft-model", &[("slidecraft-model", Dev, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dev_dependency_flagged() {
|
||||
let v = check(&[c("slidecraft-geom", &[("slidecraft-model", Dev, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_crates_below_l6_flagged() {
|
||||
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
||||
let v = check(&[c("slidecraft-engine", &[(dep, Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 4, .. }]), "{dep}");
|
||||
}
|
||||
assert!(check(&[c("slidecraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
|
||||
assert!(check(&[c("slidecraft-mcp", &[("winit", Normal, false)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_crate_is_error() {
|
||||
let v = check(&[c("slidecraft-mystery", &[])]);
|
||||
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "slidecraft-mystery"));
|
||||
assert!(v[0].to_string().contains("register"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testkit_only_as_dev_dependency() {
|
||||
let v = check(&[c("slidecraft-render", &[("slidecraft-testkit", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
|
||||
assert!(check(&[c("slidecraft-render", &[("slidecraft-testkit", Dev, true)])]).is_empty());
|
||||
// testkit itself may use anything up to L5 but not L6 crates.
|
||||
assert!(check(&[c("slidecraft-testkit", &[("slidecraft-engine", Normal, true)])]).is_empty());
|
||||
assert!(!check(&[c("slidecraft-testkit", &[("slidecraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_and_xtask_exempt() {
|
||||
for app in ["slidecraft", "slidecraft-cli", "slidecraft-web", "xtask"] {
|
||||
assert!(check(&[c(app, &[("egui", Normal, false), ("slidecraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_parsing() {
|
||||
let meta: Value = serde_json::from_str(
|
||||
r#"{"packages":[
|
||||
{"name":"slidecraft-model","dependencies":[
|
||||
{"name":"slidecraft-geom","kind":null,"path":"/x/crates/geom"},
|
||||
{"name":"serde","kind":null},
|
||||
{"name":"proptest","kind":"dev"}]},
|
||||
{"name":"slidecraft-geom","dependencies":[]}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let g = from_metadata(&meta).unwrap();
|
||||
assert_eq!(g.len(), 2);
|
||||
let doc = g.iter().find(|c| c.name == "slidecraft-model").unwrap();
|
||||
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
|
||||
assert_eq!(doc.deps[2].kind, Dev);
|
||||
assert!(check(&g).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
//! Workspace tooling: `cargo xtask <command>`.
|
||||
//!
|
||||
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
|
||||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod assets;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod parity;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity recompute the PowerPoint feature-parity summary in docs/parity.md
|
||||
ico <out.ico> <in.png>...
|
||||
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("parity") => parity::run(&root()),
|
||||
Some("ico") => ico::run(&rest),
|
||||
Some("version") => version::run(&root(), &rest),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace root (parent of the xtask crate).
|
||||
pub fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
|
||||
}
|
||||
|
||||
pub fn cargo() -> Command {
|
||||
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
||||
c.current_dir(root());
|
||||
c
|
||||
}
|
||||
|
||||
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}")) }
|
||||
}
|
||||
|
||||
pub fn metadata() -> Result<serde_json::Value, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
||||
}
|
||||
|
||||
fn cmd_layers() -> Result<(), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
println!("Dependency layering (plan/architecture.md §3)\n");
|
||||
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
||||
for c in &crates {
|
||||
let ws: Vec<String> = c
|
||||
.deps
|
||||
.iter()
|
||||
.filter(|d| d.workspace)
|
||||
.map(|d| {
|
||||
let k = match d.kind {
|
||||
layers::DepKind::Normal => "",
|
||||
layers::DepKind::Dev => " (dev)",
|
||||
layers::DepKind::Build => " (build)",
|
||||
};
|
||||
format!("{}{k}", layers::short_name(&d.name))
|
||||
})
|
||||
.collect();
|
||||
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
|
||||
}
|
||||
let violations = layers::check(&crates);
|
||||
println!();
|
||||
if violations.is_empty() {
|
||||
println!("OK: {} crates, no layering violations.", crates.len());
|
||||
Ok(())
|
||||
} else {
|
||||
println!("{} violation(s):", violations.len());
|
||||
for v in &violations {
|
||||
println!(" - {v}");
|
||||
}
|
||||
Err(format!("{} layering violation(s)", violations.len()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace packages that must build for wasm32: all L0–L5 crates plus
|
||||
/// the egui shell.
|
||||
fn wasm_set() -> Result<Vec<String>, String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
Ok(crates
|
||||
.into_iter()
|
||||
.filter(|c| match layers::classify(&c.name) {
|
||||
Some(layers::Class::Layer(l)) => l <= 5 || layers::short_name(&c.name) == "ui-egui",
|
||||
Some(layers::Class::Standalone) => true,
|
||||
_ => false,
|
||||
})
|
||||
.map(|c| c.name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn cmd_wasm() -> Result<(), String> {
|
||||
let set = wasm_set()?;
|
||||
let mut results = Vec::new();
|
||||
for pkg in &set {
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
|
||||
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
|
||||
results.push((pkg.clone(), ok));
|
||||
}
|
||||
println!("\nwasm32-unknown-unknown check:");
|
||||
for (p, ok) in &results {
|
||||
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
|
||||
}
|
||||
let failed = results.iter().filter(|r| !r.1).count();
|
||||
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
|
||||
}
|
||||
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
|
||||
let steps: Vec<Step> = vec![
|
||||
(
|
||||
"fmt",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["fmt", "--all", "--", "--check"]);
|
||||
run(c, "cargo fmt --all -- --check")
|
||||
}),
|
||||
),
|
||||
(
|
||||
"clippy",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
|
||||
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
|
||||
}),
|
||||
),
|
||||
(
|
||||
"test",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["test", "--workspace"]);
|
||||
run(c, "cargo test --workspace")
|
||||
}),
|
||||
),
|
||||
("assets", Box::new(|| assets::run(&root()))),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
("wasm", Box::new(cmd_wasm)),
|
||||
];
|
||||
let mut done = Vec::new();
|
||||
for (name, f) in &steps {
|
||||
eprintln!("\n=== ci: {name} ===");
|
||||
if let Err(e) = f() {
|
||||
println!("\nCI summary:");
|
||||
for d in &done {
|
||||
println!(" ok {d}");
|
||||
}
|
||||
println!(" FAIL {name}: {e}");
|
||||
for (n, _) in steps.iter().skip(done.len() + 1) {
|
||||
println!(" skip {n}");
|
||||
}
|
||||
return Err(format!("ci failed at `{name}`"));
|
||||
}
|
||||
done.push(*name);
|
||||
}
|
||||
println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", "));
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity.md from its row
|
||||
//! table (each PowerPoint feature scored D / P / M, weighted by priority).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
const START: &str = "<!-- SUMMARY -->";
|
||||
const ROWS: &str = "\n## Rows";
|
||||
|
||||
/// (area, feature, priority 0–2, value 0 / 0.5 / 1) for each table row.
|
||||
pub fn rows(text: &str) -> Vec<(String, String, u8, f64)> {
|
||||
let mut out = Vec::new();
|
||||
for l in text.lines() {
|
||||
let cells: Vec<&str> = l.trim().trim_matches('|').split('|').map(str::trim).collect();
|
||||
if cells.len() != 4 {
|
||||
continue;
|
||||
}
|
||||
let pri = match cells[2] {
|
||||
"P0" => 0,
|
||||
"P1" => 1,
|
||||
"P2" => 2,
|
||||
_ => continue,
|
||||
};
|
||||
let v = match cells[3] {
|
||||
"D" => 1.0,
|
||||
"P" => 0.5,
|
||||
"M" => 0.0,
|
||||
_ => continue,
|
||||
};
|
||||
out.push((cells[0].to_string(), cells[1].to_string(), pri, v));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn weight(p: u8) -> f64 {
|
||||
[3.0, 2.0, 1.0][p as usize]
|
||||
}
|
||||
|
||||
pub fn summary(rows: &[(String, String, u8, f64)]) -> String {
|
||||
let pct = |rs: &[&(String, String, u8, f64)]| {
|
||||
let tot: f64 = rs.iter().map(|r| weight(r.2)).sum();
|
||||
if tot == 0.0 { 0.0 } else { 100.0 * rs.iter().map(|r| weight(r.2) * r.3).sum::<f64>() / tot }
|
||||
};
|
||||
let all: Vec<_> = rows.iter().collect();
|
||||
let mut s = format!(
|
||||
"**Weighted breadth parity: {:.0}%** over {} features.\n\n| Priority | Features | Done | Partial | Missing | Parity |\n|---|---|---|---|---|---|\n",
|
||||
pct(&all),
|
||||
rows.len()
|
||||
);
|
||||
for p in 0..3u8 {
|
||||
let rr: Vec<_> = rows.iter().filter(|r| r.2 == p).collect();
|
||||
let n = |v: f64| rr.iter().filter(|r| r.3 == v).count();
|
||||
let unweighted = if rr.is_empty() { 0.0 } else { 100.0 * rr.iter().map(|r| r.3).sum::<f64>() / rr.len() as f64 };
|
||||
s += &format!("| P{p} | {} | {} | {} | {} | {unweighted:.0}% |\n", rr.len(), n(1.0), n(0.5), n(0.0));
|
||||
}
|
||||
s += "\n| Area | Parity |\n|---|---|\n";
|
||||
let mut areas: Vec<&str> = Vec::new();
|
||||
for r in rows {
|
||||
if !areas.contains(&r.0.as_str()) {
|
||||
areas.push(&r.0);
|
||||
}
|
||||
}
|
||||
for a in areas {
|
||||
let rr: Vec<_> = rows.iter().filter(|r| r.0 == a).collect();
|
||||
s += &format!("| {a} | {:.0}% |\n", pct(&rr));
|
||||
}
|
||||
let open: Vec<&str> = rows.iter().filter(|r| r.2 == 0 && r.3 < 1.0).map(|r| r.1.as_str()).collect();
|
||||
s += &format!("\nOpen P0 items: {}.\n", open.join(", "));
|
||||
s
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let path = root.join("docs/parity.md");
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let (Some(a), Some(b)) = (text.find(START), text.find(ROWS)) else {
|
||||
return Err("docs/parity.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into());
|
||||
};
|
||||
let rows = rows(&text[b..]);
|
||||
let s = summary(&rows);
|
||||
let new = format!("{}{START}\n{s}{}", &text[..a], &text[b..]);
|
||||
std::fs::write(&path, new).map_err(|e| e.to_string())?;
|
||||
print!("{s}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scores_rows() {
|
||||
let t = "| Area | Feature | Priority | Status |\n|---|---|---|---|\n| A | x | P0 | D |\n| A | y | P2 | M |\n| B | z | P1 | P |\n";
|
||||
let r = rows(t);
|
||||
assert_eq!(r.len(), 3);
|
||||
let s = summary(&r);
|
||||
// (3·1 + 1·0 + 2·0.5) / 6 = 67%
|
||||
assert!(s.contains("**Weighted breadth parity: 67%**"), "{s}");
|
||||
assert!(s.contains("Open P0 items: ."));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
//! `cargo xtask stats`: tests and lines of Rust per crate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Counts {
|
||||
files: usize,
|
||||
lines: usize,
|
||||
tests: usize,
|
||||
proptests: usize,
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, c: &mut Counts) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if p.is_dir() {
|
||||
// Nested fuzz workspaces and build output are not part of the crate.
|
||||
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
||||
walk(&p, c);
|
||||
}
|
||||
} else if name.ends_with(".rs")
|
||||
&& let Ok(s) = std::fs::read_to_string(&p)
|
||||
{
|
||||
c.files += 1;
|
||||
c.lines += s.lines().count();
|
||||
let (t, pt) = count_tests(&s);
|
||||
c.tests += t;
|
||||
c.proptests += pt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
|
||||
/// property tests, and macro-generated tests are only counted once — use
|
||||
/// `--exact` for harness-accurate numbers).
|
||||
fn count_tests(src: &str) -> (usize, usize) {
|
||||
let mut tests = 0;
|
||||
let mut props = 0;
|
||||
for line in src.lines() {
|
||||
let t = line.trim_start();
|
||||
if t.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
tests += t.matches("#[test]").count();
|
||||
if t.starts_with("proptest!") {
|
||||
props += 1;
|
||||
}
|
||||
}
|
||||
(tests, props)
|
||||
}
|
||||
|
||||
fn exact_tests(pkg: &str) -> Option<usize> {
|
||||
let out = crate::cargo().args(["test", "-q", "-p", pkg, "--", "--list"]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count())
|
||||
}
|
||||
|
||||
pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||
let meta = crate::metadata()?;
|
||||
let mut rows = Vec::new();
|
||||
for p in meta["packages"].as_array().into_iter().flatten() {
|
||||
let name = p["name"].as_str().unwrap_or("?").to_owned();
|
||||
let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) else {
|
||||
continue;
|
||||
};
|
||||
let mut c = Counts::default();
|
||||
walk(&dir, &mut c);
|
||||
let ex = if exact { exact_tests(&name) } else { None };
|
||||
rows.push((name, c, ex));
|
||||
}
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let exact_col = if exact { format!(" {:>8}", "harness") } else { String::new() };
|
||||
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", "crate", "files", "lines", "#[test]", "proptest!");
|
||||
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||
let mut total = Counts::default();
|
||||
let mut total_exact = 0;
|
||||
for (name, c, ex) in &rows {
|
||||
let ex_s = match (exact, ex) {
|
||||
(false, _) => String::new(),
|
||||
(true, Some(n)) => {
|
||||
total_exact += n;
|
||||
format!(" {n:>8}")
|
||||
}
|
||||
(true, None) => format!(" {:>8}", "error"),
|
||||
};
|
||||
println!("{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", c.files, c.lines, c.tests, c.proptests);
|
||||
total.files += c.files;
|
||||
total.lines += c.lines;
|
||||
total.tests += c.tests;
|
||||
total.proptests += c.proptests;
|
||||
}
|
||||
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||
let ex_s = if exact { format!(" {total_exact:>8}") } else { String::new() };
|
||||
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", "TOTAL", total.files, total.lines, total.tests, total.proptests);
|
||||
if !exact {
|
||||
println!("\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::count_tests;
|
||||
|
||||
#[test]
|
||||
fn counts_attributes_and_proptest_blocks() {
|
||||
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
||||
assert_eq!(count_tests(src), (2, 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
//! `cargo xtask version [set X.Y.Z[-pre]]`: the workspace version, the single source of truth.
|
||||
//!
|
||||
//! The version lives in `[workspace.package] version` in the root `Cargo.toml`; every crate
|
||||
//! inherits it with `version.workspace = true`, and the packaging scripts read it from here.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The `version = "…"` value inside `[workspace.package]`.
|
||||
pub fn read(manifest: &str) -> Result<String, String> {
|
||||
let (_, line) = find_line(manifest)?;
|
||||
let v = line.split_once('=').map(|(_, v)| v.trim().trim_matches('"').to_string()).unwrap_or_default();
|
||||
if v.is_empty() {
|
||||
return Err("empty [workspace.package] version".into());
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// `manifest` with the workspace version replaced; everything else is byte-for-byte unchanged.
|
||||
pub fn replace(manifest: &str, new: &str) -> Result<String, String> {
|
||||
validate(new)?;
|
||||
let (idx, _) = find_line(manifest)?;
|
||||
let mut out = String::with_capacity(manifest.len() + 8);
|
||||
for (i, line) in manifest.split_inclusive('\n').enumerate() {
|
||||
if i == idx {
|
||||
let eol = if line.ends_with("\r\n") {
|
||||
"\r\n"
|
||||
} else if line.ends_with('\n') {
|
||||
"\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
out.push_str(&format!("version = \"{new}\"{eol}"));
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Semver without build metadata: `MAJOR.MINOR.PATCH` plus an optional `-pre.release` tag.
|
||||
pub fn validate(v: &str) -> Result<(), String> {
|
||||
let bad = || Err(format!("`{v}` is not a version like 1.2.3 or 1.2.3-rc.1"));
|
||||
let (core, pre) = match v.split_once('-') {
|
||||
Some((c, p)) => (c, Some(p)),
|
||||
None => (v, None),
|
||||
};
|
||||
let nums: Vec<&str> = core.split('.').collect();
|
||||
if nums.len() != 3 || nums.iter().any(|n| n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) || (n.len() > 1 && n.starts_with('0'))) {
|
||||
return bad();
|
||||
}
|
||||
if let Some(p) = pre
|
||||
&& (p.is_empty() || p.split('.').any(|id| id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')))
|
||||
{
|
||||
return bad();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index and text of the `version` line in `[workspace.package]`.
|
||||
fn find_line(manifest: &str) -> Result<(usize, &str), String> {
|
||||
let mut in_section = false;
|
||||
for (i, line) in manifest.lines().enumerate() {
|
||||
let t = line.trim();
|
||||
if t.starts_with('[') {
|
||||
in_section = t == "[workspace.package]";
|
||||
continue;
|
||||
}
|
||||
if in_section
|
||||
&& let Some(rest) = t.strip_prefix("version")
|
||||
&& rest.trim_start().starts_with('=')
|
||||
{
|
||||
return Ok((i, line));
|
||||
}
|
||||
}
|
||||
Err("no `version = \"…\"` in [workspace.package] of the root Cargo.toml".into())
|
||||
}
|
||||
|
||||
pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let path = root.join("Cargo.toml");
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
match args {
|
||||
[] => {
|
||||
println!("{}", read(&text)?);
|
||||
Ok(())
|
||||
}
|
||||
["set", new] => {
|
||||
let old = read(&text)?;
|
||||
let updated = replace(&text, new)?;
|
||||
// Write atomically: a half-written root manifest breaks every build in the workspace.
|
||||
let tmp = root.join("target").join("Cargo.toml.xtask-version");
|
||||
std::fs::create_dir_all(root.join("target")).map_err(|e| format!("create target/: {e}"))?;
|
||||
std::fs::write(&tmp, updated).map_err(|e| format!("write {}: {e}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| format!("replace {}: {e}", path.display()))?;
|
||||
// Refresh the workspace members' entries in Cargo.lock (no dependency upgrades).
|
||||
let mut c = crate::cargo();
|
||||
c.args(["update", "--workspace", "--offline"]);
|
||||
if crate::run(c, "cargo update --workspace --offline").is_err() {
|
||||
let mut c = crate::cargo();
|
||||
c.args(["update", "--workspace"]);
|
||||
crate::run(c, "cargo update --workspace")?;
|
||||
}
|
||||
println!("version: {old} -> {new}");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("usage: cargo xtask version [set X.Y.Z[-pre]]".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MANIFEST: &str = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[workspace.dependencies]\nfoo = { version = \"1\" }\n";
|
||||
|
||||
#[test]
|
||||
fn reads_the_workspace_version() {
|
||||
assert_eq!(read(MANIFEST).unwrap(), "0.1.0");
|
||||
assert!(read("[package]\nversion = \"1.0.0\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_only_the_workspace_version() {
|
||||
let out = replace(MANIFEST, "1.2.3-rc.1").unwrap();
|
||||
assert_eq!(read(&out).unwrap(), "1.2.3-rc.1");
|
||||
assert_eq!(out, MANIFEST.replace("version = \"0.1.0\"", "version = \"1.2.3-rc.1\""));
|
||||
assert!(out.contains("foo = { version = \"1\" }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_crlf_line_endings() {
|
||||
let crlf = MANIFEST.replace('\n', "\r\n");
|
||||
let out = replace(&crlf, "2.0.0").unwrap();
|
||||
assert_eq!(out, crlf.replace("\"0.1.0\"", "\"2.0.0\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_versions() {
|
||||
for ok in ["0.1.0", "1.20.300", "1.0.0-rc.1", "1.0.0-alpha", "1.0.0-x-y.2"] {
|
||||
assert!(validate(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in ["1.0", "1.0.0.0", "v1.0.0", "01.0.0", "1.0.0-", "1.0.0-a..b", "1.0.0+meta", "1.a.0", ""] {
|
||||
assert!(validate(bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(replace(MANIFEST, "nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_real_manifest_has_a_valid_version() {
|
||||
let text = std::fs::read_to_string(crate::root().join("Cargo.toml")).unwrap();
|
||||
validate(&read(&text).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue