M4.10: neutral file, preference and MCP wording; vendor-name CI gate

Edit -> "PDF Presets…"; the clipboard preferences read "Legacy vector
clipboard (no transparency)" / "Legacy vector clipboard" (keys copyAicb and
aicbMode unchanged); the second keyboard shortcut set is "Classic Defaults",
and the old set name is still accepted from saved UI preferences, imported
sets and shortcuts.preset. The MCP server instructions, the apply_effect and
create_graph tool descriptions, the text.createInPath params doc,
docs/mcp.md, the ui-egui crate description and the PDF compatibility docs
no longer name another vendor.

New `cargo xtask brands` (run by `cargo xtask ci`) fails on vendor product
or company names in Rust string literals (labels, params docs, menus,
panels, MCP definitions; comments and test code skipped), Cargo.toml
descriptions and text under docs/ and packaging/; a required legacy alias
is marked `brand-ok`. xtask shares one git file listing between the asset
and brand checks. docs/development.md describes the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 11:47:28 +07:00
parent dd0bb0eefb
commit 5d9b97c3bd
15 changed files with 482 additions and 27 deletions

View File

@ -269,12 +269,12 @@ pub const PREF_SPECS: &[PrefSpec] = &[
// Clipboard Handling
p!("copyAsSvg", "Clipboard Handling", "On Copy", "Include SVG Code", bool),
p!("copyAsPdf", "Clipboard Handling", "On Copy", "PDF", bool),
p!("copyAicb", "Clipboard Handling", "On Copy", "AICB (no transparency support)", bool),
p!("copyAicb", "Clipboard Handling", "On Copy", "Legacy vector clipboard (no transparency)", bool),
p!(
"aicbMode",
"Clipboard Handling",
"On Copy",
"AICB",
"Legacy vector clipboard",
choice(&[("preservePaths", "Preserve Paths"), ("preserveAppearance", "Preserve Appearance and Overprints")])
),
p!(

View File

@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped, like Illustrator) → {id}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),

View File

@ -18,7 +18,7 @@ const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "VectorCraft is a vector illustration app (an Illustrator clone). Coordinates are points in \
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are points in \
document space (y down, origin at the first artboard's top-left; a new document is 612×792). Draw with draw_shape / \
draw_path, change colours with set_paint, look with screenshot and inspect_document. Every menu action is a command: \
find it with list_commands and run it with run_command. New objects become the selection, and most commands act on the \

View File

@ -302,7 +302,7 @@ pub fn tool_definitions() -> Vec<Value> {
obj(
json!({
"effect": {"type": "string", "description": "Effect id (see the catalogue)"},
"params": {"type": "object", "description": "Effect parameters; missing keys take Illustrator's dialog defaults"},
"params": {"type": "object", "description": "Effect parameters; missing keys take the dialog defaults"},
"ids": {"type": "array", "items": {"type": "integer"}},
}),
&[],
@ -347,7 +347,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"create_graph",
"Create graph",
"Create a graph (Illustrator graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.",
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.",
obj(
json!({
"type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]},

View File

@ -21,13 +21,13 @@ use vectorcraft_doc::Document;
/// PDF standard / version to target.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Compatibility {
/// Acrobat 5 (PDF 1.4).
/// PDF 1.4.
Pdf14,
/// Acrobat 6 (PDF 1.5).
/// PDF 1.5.
Pdf15,
/// Acrobat 7 (PDF 1.6).
/// PDF 1.6.
Pdf16,
/// Acrobat 8+ (PDF 1.7). The default.
/// PDF 1.7. The default.
#[default]
Pdf17,
/// PDF 2.0.

View File

@ -3,7 +3,7 @@ name = "vectorcraft-ui-egui"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "VectorCraft egui frontend (Illustrator-style UI); swappable — nothing below depends on it"
description = "VectorCraft egui frontend (menus, panels, tools and canvas); swappable — nothing below depends on it"
[lints]
workspace = true

View File

@ -98,7 +98,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"),
("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"),
("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"),
("shortcuts.preset", "Keyboard Shortcut Set", "", "{name: \"VectorCraft Defaults\" | \"Illustrator Defaults\"}"),
(
"shortcuts.preset",
"Keyboard Shortcut Set",
"",
"{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)",
),
("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"),
("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"),
("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"),
@ -750,7 +755,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
Sep,
todo("Transparency Flattener Presets…"),
todo("Print Presets…"),
todo("Adobe PDF Presets…"),
todo("PDF Presets…"),
cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})),
Sep,
c("Color Settings…", "edit.colorSettings"),

View File

@ -17,8 +17,20 @@ use crate::state::{Dialog, UiState};
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, menus, widgets};
pub const PRESETS: &[&str] = &["VectorCraft Defaults", "Illustrator Defaults"];
pub const PRESETS: &[&str] = &["VectorCraft Defaults", "Classic Defaults"];
pub const CUSTOM: &str = "Custom";
/// Set names earlier versions saved: (old name, current name).
const LEGACY_PRESETS: &[(&str, &str)] = &[("Illustrator Defaults", "Classic Defaults")]; // brand-ok: legacy preference value
/// The current name of a shortcut set: names earlier versions saved map to today's.
pub fn set_name(name: &str) -> &str {
LEGACY_PRESETS.iter().find(|(old, _)| *old == name).map_or(name, |(_, new)| new)
}
/// Serde reader for a saved set name (UI preferences written by earlier versions).
pub fn deserialize_set_name<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
<String as serde::Deserialize>::deserialize(d).map(|s| set_name(&s).to_string())
}
/// Bumped whenever overrides or the workspace list change (the native menu rebuilds on it).
pub static GENERATION: AtomicU64 = AtomicU64::new(0);
@ -281,7 +293,7 @@ pub fn reset_one(overrides: &mut BTreeMap<String, String>, key: &str) {
/// Overrides of a preset (both presets are the registry defaults for now).
pub fn preset(name: &str) -> Option<BTreeMap<String, String>> {
PRESETS.contains(&name).then(BTreeMap::new)
PRESETS.contains(&set_name(name)).then(BTreeMap::new)
}
/// Export format: `{"format": "vectorcraft-shortcuts", "set": name, "overrides": {...}}`.
@ -299,7 +311,7 @@ pub fn import_json(v: &Value) -> Result<(String, BTreeMap<String, String>), Stri
}
out.insert(k.clone(), normalize(s).unwrap_or_default());
}
Ok((v.get("set").and_then(Value::as_str).unwrap_or(CUSTOM).to_string(), out))
Ok((v.get("set").and_then(Value::as_str).map_or(CUSTOM, set_name).to_string(), out))
}
// ---------- UI commands ----------
@ -370,7 +382,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
sync(&app.ui);
Ok(Value::Null)
}
"shortcuts.preset" => match s("name").as_deref().and_then(|n| preset(n).map(|p| (n.to_string(), p))) {
"shortcuts.preset" => match s("name").as_deref().map(set_name).and_then(|n| preset(n).map(|p| (n.to_string(), p))) {
Some((n, p)) => {
app.ui.shortcut_overrides = p;
app.ui.shortcut_set = n;

View File

@ -229,7 +229,7 @@ pub struct UiState {
#[serde(default)]
pub shortcut_overrides: std::collections::BTreeMap<String, String>,
/// Name of the shortcut set (preset or "Custom").
#[serde(default = "default_shortcut_set")]
#[serde(default = "default_shortcut_set", deserialize_with = "crate::shortcut_editor::deserialize_set_name")]
pub shortcut_set: String,
/// Current workspace (Window → Workspace).
#[serde(default = "default_workspace")]

View File

@ -67,3 +67,41 @@ fn swatches_menu_offers_save_swatch_library() {
assert!(text.contains("Save Swatch Library…"), "{text}");
assert!(!text.contains("ASE"), "{text}");
}
#[test]
fn edit_menu_lists_pdf_presets() {
let app = app();
assert!(menus::menu_entries(&app).iter().any(|e| e.path == ["Edit"] && e.label == "PDF Presets…" && !e.enabled));
}
#[test]
fn clipboard_preferences_name_the_legacy_format_neutrally() {
let label = |key: &str| vectorcraft_engine::cmd::prefscmds::PREF_SPECS.iter().find(|p| p.key == key).unwrap().label;
assert_eq!(label("copyAicb"), "Legacy vector clipboard (no transparency)");
assert_eq!(label("aicbMode"), "Legacy vector clipboard");
}
#[test]
fn shortcut_set_old_name_is_accepted() {
use crate::shortcut_editor::{PRESETS, import_json};
const OLD: &str = "Illustrator Defaults"; // brand-ok: legacy preference value under test
assert_eq!(PRESETS, ["VectorCraft Defaults", "Classic Defaults"]);
// UI preferences saved by an earlier version load (and save again) under the current name.
let ui: crate::state::UiState = serde_json::from_value(json!({ "shortcut_set": OLD })).unwrap();
assert_eq!(ui.shortcut_set, "Classic Defaults");
let saved = serde_json::to_value(&ui).unwrap();
assert_eq!(saved["shortcut_set"], "Classic Defaults");
let back: crate::state::UiState = serde_json::from_value(saved).unwrap();
assert_eq!(back.shortcut_set, "Classic Defaults");
let default: crate::state::UiState = serde_json::from_value(json!({})).unwrap();
assert_eq!(default.shortcut_set, PRESETS[0]);
// The command and imported sets take the old name too.
let mut app = app();
app.run("shortcuts.preset", json!({ "name": OLD })).unwrap();
assert_eq!(app.ui.shortcut_set, "Classic Defaults");
app.run("shortcuts.preset", json!({ "name": "VectorCraft Defaults" })).unwrap();
assert_eq!(app.ui.shortcut_set, "VectorCraft Defaults");
assert!(app.run("shortcuts.preset", json!({ "name": "Nope" })).is_err());
let (set, _) = import_json(&json!({ "set": OLD, "overrides": {} })).unwrap();
assert_eq!(set, "Classic Defaults");
}

View File

@ -23,3 +23,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox. (The app's synchronous drop path is compiled out on wasm32.)
- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`.
- Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter).
## Vendor names gate
`cargo xtask brands` (part of `cargo xtask ci`) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), `Cargo.toml` descriptions and every text file under `docs/` and `packaging/`. Comments and test code are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, carries a `brand-ok` comment.

View File

@ -64,7 +64,7 @@ or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | Nine graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | |

View File

@ -5,7 +5,6 @@
//! as `` `path` `` in ASSETS.md. See the asset policy in AGENTS.md.
use std::path::Path;
use std::process::Command;
const ASSET_EXT: &[&str] = &[
"png",
@ -51,12 +50,7 @@ pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
}
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 files = crate::repo_files(root)?;
let md = std::fs::read_to_string(root.join("ASSETS.md")).map_err(|e| format!("ASSETS.md: {e}"))?;
let miss = missing(&files, &md);
if miss.is_empty() {

385
xtask/src/brands.rs Normal file
View File

@ -0,0 +1,385 @@
//! `cargo xtask brands`: no other vendor's product or company names in what users and agents read.
//!
//! VectorCraft names its own features; where a comparison is needed, text says "the reference
//! app". Scanned:
//! * string literals in Rust sources (command labels and params docs, `UI_COMMANDS`, menus, panels,
//! MCP tool definitions…). Comments and test code (`tests*.rs`, `*_tests.rs`, `tests/` and
//! `benches/` folders, items under `#[cfg(test)]`) are skipped;
//! * the `description` of every `Cargo.toml`;
//! * every text file under `docs/` and `packaging/`.
//!
//! A line that has to keep an old name (an alias that old files or preferences still use) says
//! `brand-ok` in a comment on that line.
use std::path::Path;
/// Vendor product and company names, lowercase, matched case-insensitively as whole words.
const DENY: &[&str] = &[
"adobe",
"illustrator",
"photoshop",
"acrobat",
"indesign",
"lightroom",
"dreamweaver",
"creative cloud",
"typekit",
"kuler",
"behance",
"myriad",
"pantone",
"aicb",
];
/// Marks a line that keeps a legacy name on purpose.
const ALLOW: &str = "brand-ok";
/// This file lists the names it rejects.
const SELF: &str = "xtask/src/brands.rs";
/// A denied name found in a file.
#[derive(Debug, PartialEq)]
pub struct Hit {
pub path: String,
/// 1-based.
pub line: usize,
pub term: &'static str,
/// The literal or line it was found in.
pub context: String,
}
pub fn run(root: &Path) -> Result<(), String> {
let files = crate::repo_files(root)?;
let hits = scan(root, &files);
if hits.is_empty() {
println!("brands: no vendor names in user-visible text");
return Ok(());
}
let list: Vec<String> = hits.iter().map(|h| format!("{}:{}: \"{}\" in {}", h.path, h.line, h.term, short(&h.context))).collect();
Err(format!(
"{} vendor name(s) in user-visible text (say \"the reference app\" or name the feature; mark a required legacy alias `{ALLOW}`):\n {}",
hits.len(),
list.join("\n ")
))
}
/// Every hit in `files` (paths relative to `root`).
pub fn scan(root: &Path, files: &[String]) -> Vec<Hit> {
let mut hits = vec![];
for path in files {
let Some(kind) = kind_of(path) else { continue };
// Binary files (images under docs/) are not text and have nothing to read.
let Ok(text) = std::fs::read_to_string(root.join(path)) else { continue };
let found = match kind {
Kind::Rust => scan_rust(&text),
Kind::Manifest => scan_lines(&text, |l| l.trim_start().starts_with("description")),
Kind::Text => scan_lines(&text, |_| true),
};
hits.extend(found.into_iter().map(|(line, term, context)| Hit { path: path.clone(), line, term, context }));
}
hits
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
Rust,
Manifest,
Text,
}
fn kind_of(path: &str) -> Option<Kind> {
if path.starts_with("docs/") || path.starts_with("packaging/") {
Some(Kind::Text)
} else if path == "Cargo.toml" || path.ends_with("/Cargo.toml") {
Some(Kind::Manifest)
} else if path.ends_with(".rs") && ["crates/", "apps/", "xtask/"].iter().any(|d| path.starts_with(d)) && path != SELF && !is_test_path(path) {
Some(Kind::Rust)
} else {
None
}
}
fn is_test_path(path: &str) -> bool {
let mut parts = path.split('/').rev();
let file = parts.next().unwrap_or_default();
file == "tests.rs" || file.starts_with("tests_") || file.ends_with("_tests.rs") || parts.any(|d| d == "tests" || d == "benches")
}
/// (line, term, line text) for each denied name on the lines `keep` selects.
fn scan_lines(text: &str, keep: impl Fn(&str) -> bool) -> Vec<(usize, &'static str, String)> {
text.lines()
.enumerate()
.filter(|(_, l)| keep(l) && !l.contains(ALLOW))
.flat_map(|(i, l)| terms_in(l, DENY).into_iter().map(move |t| (i + 1, t, l.trim().to_string())))
.collect()
}
/// (first line, term, literal) for each denied name in a Rust string literal outside comments and
/// test code.
fn scan_rust(src: &str) -> Vec<(usize, &'static str, String)> {
let lines: Vec<&str> = src.lines().collect();
let mut out = vec![];
for lit in rust_literals(src) {
if lines.get(lit.first - 1..lit.last.min(lines.len())).is_some_and(|ls| ls.iter().any(|l| l.contains(ALLOW))) {
continue;
}
for t in terms_in(&lit.text, DENY) {
out.push((lit.first, t, lit.text.clone()));
}
}
out
}
/// The `terms` (lowercase) that occur in `text` as whole words, ignoring case.
fn terms_in(text: &str, terms: &[&'static str]) -> Vec<&'static str> {
let lower = text.to_ascii_lowercase();
let b = lower.as_bytes();
let word = |c: Option<&u8>| c.is_some_and(u8::is_ascii_alphanumeric);
terms
.iter()
.copied()
.filter(|t| lower.match_indices(t).any(|(i, _)| !word(i.checked_sub(1).and_then(|j| b.get(j))) && !word(b.get(i + t.len()))))
.collect()
}
fn short(s: &str) -> String {
let s = s.replace('\n', " ");
match s.char_indices().nth(100) {
Some((i, _)) => format!("{}…", &s[..i]),
None => s,
}
}
/// A Rust string literal: its first and last line (1-based) and its text (escapes as spaces).
#[derive(Debug)]
struct Lit {
first: usize,
last: usize,
text: String,
}
/// String literals of a Rust source, skipping comments and items under `#[cfg(test)]`.
fn rust_literals(src: &str) -> Vec<Lit> {
let b = src.as_bytes();
let mut out = vec![];
let (mut i, mut line) = (0usize, 1usize);
let mut depth = 0usize;
// `#[cfg(test)]` seen, its item not opened yet; brace depth the test item was opened at.
let (mut test_attr, mut test_depth) = (false, None::<usize>);
while i < b.len() {
match b[i] {
b'\n' => {
line += 1;
i += 1;
}
b'/' if b.get(i + 1) == Some(&b'/') => {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
}
b'/' if b.get(i + 1) == Some(&b'*') => {
let mut nest = 0usize;
while i < b.len() {
if b[i..].starts_with(b"/*") {
nest += 1;
i += 2;
} else if b[i..].starts_with(b"*/") {
nest -= 1;
i += 2;
if nest == 0 {
break;
}
} else {
line += usize::from(b[i] == b'\n');
i += 1;
}
}
}
b'"' => {
let first = line;
let (text, end) = quoted(b, i + 1, &mut line);
i = end;
if test_depth.is_none() {
out.push(Lit { first, last: line, text });
}
}
b'\'' => i = after_char_or_lifetime(src, i),
b'#' if b[i..].starts_with(b"#[cfg(test)]") => {
test_attr = true;
i += "#[cfg(test)]".len();
}
b'{' => {
if test_attr && test_depth.is_none() {
test_depth = Some(depth);
}
test_attr = false;
depth += 1;
i += 1;
}
b'}' => {
depth = depth.saturating_sub(1);
if test_depth == Some(depth) {
test_depth = None;
}
i += 1;
}
b';' => {
test_attr = false;
i += 1;
}
c if c.is_ascii_alphabetic() || c == b'_' => {
let start = i;
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
i += 1;
}
let hashes = b[i..].iter().take_while(|&&h| h == b'#').count();
// Raw strings (r"…", r#"…"#, br…, cr…); b"…" and c"…" are read as plain strings next.
if matches!(&src[start..i], "r" | "br" | "cr") && b.get(i + hashes) == Some(&b'"') {
let first = line;
let body = i + hashes + 1;
let close: Vec<u8> = std::iter::once(b'"').chain(std::iter::repeat_n(b'#', hashes)).collect();
let end = (body..b.len()).find(|&j| b[j..].starts_with(&close)).unwrap_or(b.len());
let text = String::from_utf8_lossy(&b[body..end]).into_owned();
line += text.matches('\n').count();
i = (end + close.len()).min(b.len());
if test_depth.is_none() {
out.push(Lit { first, last: line, text });
}
}
}
_ => i += 1,
}
}
out
}
/// Read a `"…"` literal whose body starts at `i`: (text with escapes as spaces, index after the
/// closing quote).
fn quoted(b: &[u8], mut i: usize, line: &mut usize) -> (String, usize) {
let mut text = Vec::new();
while i < b.len() && b[i] != b'"' {
if b[i] == b'\\' {
match b.get(i + 1) {
Some(&c @ (b'"' | b'\\' | b'\'')) => text.push(c),
Some(b'\n') => {
*line += 1;
text.push(b' ');
}
_ => text.push(b' '),
}
i += 2;
} else {
*line += usize::from(b[i] == b'\n');
text.push(b[i]);
i += 1;
}
}
(String::from_utf8_lossy(&text).into_owned(), (i + 1).min(b.len()))
}
/// Skip a char literal (`'x'`, `'\n'`, `'\u{…}'`) at `i`, or just the quote of a lifetime/label.
fn after_char_or_lifetime(src: &str, i: usize) -> usize {
let b = src.as_bytes();
if b.get(i + 1) == Some(&b'\\') {
let close = (i + 3..b.len()).find(|&j| b[j] == b'\'').unwrap_or(b.len());
return (close + 1).min(b.len());
}
match src[i + 1..].chars().next() {
Some(c) if b.get(i + 1 + c.len_utf8()) == Some(&b'\'') => i + 2 + c.len_utf8(),
_ => i + 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rust_hits(src: &str) -> Vec<(usize, &'static str)> {
scan_rust(src).into_iter().map(|(l, t, _)| (l, t)).collect()
}
#[test]
fn rust_literals_skip_comments_identifiers_tests_and_marked_lines() {
let src = r##"// Illustrator in a comment
/// Adobe in a doc comment
/* block Adobe /* nested */ Photoshop */
fn f() { let a = "Made for Illustrator users"; let k = "copyAicb"; let q = '"'; let l: &'static str = r#"raw "Acrobat" text"#; }
const OLD: &str = "Illustrator Defaults"; // brand-ok: legacy alias
fn g() -> &'static str { "line\nAICB" }
#[cfg(test)]
mod tests {
fn t() { let x = "Adobe"; if true { let y = "Photoshop"; } }
}
#[cfg(test)]
mod other;
fn h() { let s = b"InDesign"; let m = "multi
line Pantone"; }
"##;
assert_eq!(rust_hits(src), [(4, "illustrator"), (4, "acrobat"), (6, "aicb"), (13, "indesign"), (13, "pantone")]);
let lits = rust_literals(src);
let multi = lits.iter().find(|l| l.text.contains("Pantone")).unwrap();
assert_eq!((multi.first, multi.last), (13, 14));
}
#[test]
fn whole_words_only() {
assert_eq!(terms_in("Adobe's app", DENY), ["adobe"]);
assert_eq!(terms_in("an ILLUSTRATOR-style UI", DENY), ["illustrator"]);
assert!(terms_in("illustrators illustration copyAicb aicbMode", DENY).is_empty());
}
#[test]
fn docs_packaging_and_manifests() {
let lines = |text: &str, keep: fn(&str) -> bool| -> Vec<(usize, &'static str)> {
scan_lines(text, keep).into_iter().map(|(l, t, _)| (l, t)).collect()
};
assert_eq!(lines("Keywords=vector;illustrator;\n", |_| true), [(1, "illustrator")]);
assert_eq!(lines("Like Adobe apps\nnot here: Adobe # brand-ok\n", |_| true), [(1, "adobe")]);
let manifest = "name = \"adobe-thing\"\ndescription = \"Illustrator-style UI\"\n";
assert_eq!(lines(manifest, |l| l.trim_start().starts_with("description")), [(2, "illustrator")]);
}
#[test]
fn scope() {
assert_eq!(kind_of("crates/engine/src/cmd/file.rs"), Some(Kind::Rust));
assert_eq!(kind_of("crates/engine/src/tests_file.rs"), None);
assert_eq!(kind_of("crates/tools/src/text_tests.rs"), None);
assert_eq!(kind_of("crates/color/src/cms/tests.rs"), None);
assert_eq!(kind_of("crates/engine/tests/command_sweep.rs"), None);
assert_eq!(kind_of(SELF), None);
assert_eq!(kind_of("crates/ui-egui/Cargo.toml"), Some(Kind::Manifest));
assert_eq!(kind_of("docs/mcp.md"), Some(Kind::Text));
assert_eq!(kind_of("packaging/linux/nfpm.yaml"), Some(Kind::Text));
for skipped in ["README.md", "ROADMAP.md", "CLAUDE.md", "AGENTS.md", ".github/workflows/release.yml", "ASSETS.md"] {
assert_eq!(kind_of(skipped), None, "{skipped}");
}
}
#[test]
fn injected_strings_fail_the_gate() {
let dir = std::env::temp_dir().join(format!("vc-brands-{}", std::process::id()));
let file = |p: &str, text: &str| {
let path = dir.join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
};
file("crates/x/src/lib.rs", "pub const LABEL: &str = \"Export for Illustrator\"; // a comment: Adobe\n");
file("docs/x.md", "Works like Photoshop.\n");
file("packaging/x.desktop", "Keywords=vector;illustrator;\n");
file("README.md", "Adobe trademark notice\n");
let files: Vec<String> = ["crates/x/src/lib.rs", "docs/x.md", "packaging/x.desktop", "README.md"].map(String::from).to_vec();
let hits = scan(&dir, &files);
let _ = std::fs::remove_dir_all(&dir);
let found: Vec<(&str, usize, &str)> = hits.iter().map(|h| (h.path.as_str(), h.line, h.term)).collect();
assert_eq!(found, [("crates/x/src/lib.rs", 1, "illustrator"), ("docs/x.md", 1, "photoshop"), ("packaging/x.desktop", 1, "illustrator")]);
}
#[test]
fn the_tree_is_clean() {
let root = crate::root();
// Outside a git checkout there is no file list to check.
let Ok(files) = crate::repo_files(&root) else { return };
let hits = scan(&root, &files);
assert!(hits.is_empty(), "{hits:#?}");
}
}

View File

@ -4,6 +4,7 @@
//! invoked through `std::process::Command`.
mod assets;
mod brands;
mod bundle;
mod ico;
mod layers;
@ -18,9 +19,10 @@ usage: cargo xtask <command>
commands:
assets check that every icon/image/font/asset is attributed in ASSETS.md
brands check that user-visible text (labels, params docs, MCP, docs, packaging) names no other vendor's products
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)
ci fmt --check, clippy -D warnings, test, assets, brands, layers, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
bundle build dist/VectorCraft.app (macOS) with assets/app-icon/vectorcraft.icns
@ -34,6 +36,7 @@ fn main() -> ExitCode {
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
let result = match args.first().map(String::as_str) {
Some("assets") => assets::run(&root()),
Some("brands") => brands::run(&root()),
Some("layers") => cmd_layers(),
Some("wasm") => cmd_wasm(),
Some("ci") => cmd_ci(),
@ -62,6 +65,19 @@ pub fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
}
/// Tracked and untracked (not ignored) files that exist, relative to `root`.
pub fn repo_files(root: &Path) -> Result<Vec<String>, 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}"))?;
if !out.status.success() {
return Err(format!("git ls-files failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
}
Ok(String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect())
}
pub fn cargo() -> Command {
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
c.current_dir(root());
@ -176,6 +192,7 @@ fn cmd_ci() -> Result<(), String> {
}),
),
("assets", Box::new(|| assets::run(&root()))),
("brands", Box::new(|| brands::run(&root()))),
("layers", Box::new(cmd_layers)),
("wasm", Box::new(cmd_wasm)),
];