mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 21:56:53 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ad62d04fa |
@@ -248,10 +248,11 @@ impl Discovery {
|
|||||||
/// (`~$…`) are skipped.
|
/// (`~$…`) are skipped.
|
||||||
fn collect_inputs(inputs: &[String]) -> Discovery {
|
fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||||
let mut out = Discovery::default();
|
let mut out = Discovery::default();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
for i in inputs {
|
for i in inputs {
|
||||||
let p = PathBuf::from(i);
|
let p = PathBuf::from(i);
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
if !walk(&p, 0, &mut out) {
|
if !walk(&p, 0, &mut out, &mut seen) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if !out.add_file(p) {
|
} else if !out.add_file(p) {
|
||||||
@@ -268,7 +269,20 @@ fn collect_inputs(inputs: &[String]) -> Discovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns false only when the global file budget stops traversal.
|
/// Returns false only when the global file budget stops traversal.
|
||||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet<u64>) -> bool {
|
||||||
|
// A directory is visited once by identity (dev+inode on unix): several links to the same
|
||||||
|
// directory are aliases, not exponential branches of the scan. Other platforms keep the
|
||||||
|
// depth cap only.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::MetadataExt as _;
|
||||||
|
if let Ok(m) = std::fs::metadata(dir)
|
||||||
|
&& let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))
|
||||||
|
&& !seen.insert(id)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
if depth > MAX_DEPTH {
|
if depth > MAX_DEPTH {
|
||||||
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
|
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
|
||||||
return true;
|
return true;
|
||||||
@@ -301,7 +315,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
if !walk(&p, depth + 1, out) {
|
if !walk(&p, depth + 1, out, seen) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||||
@@ -981,6 +995,12 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
|||||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
||||||
let names = zip_names(&mut zip);
|
let names = zip_names(&mut zip);
|
||||||
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
||||||
|
// Several relationships name the same part; each fresh check decompressed it again, so
|
||||||
|
// repeated references to one highly compressible part multiplied the work. The verdict is
|
||||||
|
// remembered per (part, named content) and the decompression the package can cause is
|
||||||
|
// budgeted as a whole (finding: repeated decompression).
|
||||||
|
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
|
||||||
|
let mut budget: u64 = 512 * 1024 * 1024;
|
||||||
let mut kinds = BTreeMap::new();
|
let mut kinds = BTreeMap::new();
|
||||||
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
||||||
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
||||||
@@ -994,7 +1014,7 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
|||||||
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||||
if !external
|
if !external
|
||||||
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
|
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
|
||||||
&& !holds_something(&mut zip, part, kind)
|
&& !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1013,15 +1033,41 @@ fn rels_source_dir(key: &str) -> Option<String> {
|
|||||||
|
|
||||||
/// Whether a part has content: an element under its root, or for comments (whose authors list
|
/// Whether a part has content: an element under its root, or for comments (whose authors list
|
||||||
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
|
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
|
||||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool {
|
/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this
|
||||||
use quick_xml::events::Event;
|
/// package's remaining checks may cause, and once it is spent the part counts as having content.
|
||||||
let Ok(f) = zip.by_name(name) else { return true };
|
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool {
|
||||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART)));
|
let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||||
let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() {
|
|
||||||
"comments" => Some(b"comment"),
|
"comments" => Some(b"comment"),
|
||||||
"vmldrawing" => Some(b"shape"),
|
"vmldrawing" => Some(b"shape"),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let marker = match wanted {
|
||||||
|
Some(b"comment") => "comment",
|
||||||
|
Some(_) => "shape",
|
||||||
|
None => "any",
|
||||||
|
};
|
||||||
|
if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) {
|
||||||
|
return *v;
|
||||||
|
}
|
||||||
|
let v = holds_something_uncached(zip, name, marker, budget);
|
||||||
|
verdicts.insert((name.to_ascii_lowercase(), marker), v);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool {
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
let Ok(f) = zip.by_name(name) else { return true };
|
||||||
|
if *budget == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let take = f.size().min(MAX_XML_PART).min(*budget);
|
||||||
|
*budget -= take;
|
||||||
|
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take)));
|
||||||
|
let wanted: Option<&'static [u8]> = match marker {
|
||||||
|
"comment" => Some(b"comment"),
|
||||||
|
"shape" => Some(b"shape"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut in_root = false;
|
let mut in_root = false;
|
||||||
loop {
|
loop {
|
||||||
@@ -1321,7 +1367,8 @@ mod tests {
|
|||||||
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
||||||
std::fs::write(&path, "not a directory").unwrap();
|
std::fs::write(&path, "not a directory").unwrap();
|
||||||
let mut discovery = Discovery::default();
|
let mut discovery = Discovery::default();
|
||||||
assert!(walk(&path, 0, &mut discovery));
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
assert!(walk(&path, 0, &mut discovery, &mut seen));
|
||||||
assert!(discovery.files.is_empty());
|
assert!(discovery.files.is_empty());
|
||||||
assert_eq!(discovery.failures.len(), 1);
|
assert_eq!(discovery.failures.len(), 1);
|
||||||
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
||||||
|
|||||||
@@ -214,7 +214,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
|||||||
if let Some(w) = web_sys::window()
|
if let Some(w) = web_sys::window()
|
||||||
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
||||||
{
|
{
|
||||||
let _ = w.open_with_url_and_target(&url, "_blank");
|
// With `noopener` the new browsing context can't hold a reference back to the
|
||||||
|
// editor window and navigate it while the user reads the linked page.
|
||||||
|
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
inbox: Some(inbox),
|
inbox: Some(inbox),
|
||||||
|
|||||||
@@ -160,21 +160,28 @@ fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<P
|
|||||||
segs.push((*a, *b));
|
segs.push((*a, *b));
|
||||||
}
|
}
|
||||||
if dash {
|
if dash {
|
||||||
|
// The per-segment dash cap bounds each input line, but a caller-supplied vector of many
|
||||||
|
// long dashed lines multiplies: together they would eagerly allocate megabytes of
|
||||||
|
// geometry even when nothing of it reaches the canvas, so the total is capped too and
|
||||||
|
// the line is drawn solid once the budget is gone.
|
||||||
let (on, off) = (width * 4.0, width * 3.0);
|
let (on, off) = (width * 4.0, width * 3.0);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
let mut budget = 65_536usize;
|
||||||
for (a, b) in segs {
|
for (a, b) in segs {
|
||||||
|
if budget == 0 {
|
||||||
|
return vec![vec![pts.to_vec()]];
|
||||||
|
}
|
||||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||||
let len = (dx * dx + dy * dy).sqrt();
|
let len = (dx * dx + dy * dy).sqrt();
|
||||||
if !(len.is_finite() && len > 0.0) {
|
if !(len.is_finite() && len > 0.0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut t = 0.0;
|
let mut t = 0.0;
|
||||||
let mut n = 0;
|
while t < len && budget > 0 {
|
||||||
while t < len && n < 10_000 {
|
|
||||||
let e = (t + on).min(len);
|
let e = (t + on).min(len);
|
||||||
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
|
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
|
||||||
t += on + off;
|
t += on + off;
|
||||||
n += 1;
|
budget -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
segs = out;
|
segs = out;
|
||||||
@@ -262,3 +269,23 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
cv.buf
|
cv.buf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dashed_geometry_stays_within_the_dash_budget() {
|
||||||
|
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
|
||||||
|
// shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like
|
||||||
|
// number of quads before any of it reached a 4-pixel canvas (finding: dash expansion).
|
||||||
|
let prims: Vec<Prim> =
|
||||||
|
(0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect();
|
||||||
|
let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]);
|
||||||
|
assert_eq!(img.len(), 2 * 2 * 4);
|
||||||
|
// And the geometry itself is bounded even for one huge dashed polyline.
|
||||||
|
assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536);
|
||||||
|
let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }];
|
||||||
|
assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ impl Cache {
|
|||||||
}
|
}
|
||||||
None => (None, u32::MAX),
|
None => (None, u32::MAX),
|
||||||
};
|
};
|
||||||
|
// Eviction only frees entries used before this frame, so a workbook covered in same-frame
|
||||||
|
// pictures can leave the cache unable to make room. Then draw the texture without
|
||||||
|
// retaining it: the handle is dropped at the end of the frame and the GPU memory goes
|
||||||
|
// with it, instead of the budget being outflanked (finding: cache admission).
|
||||||
|
if self.entries.len() >= MAX_CACHE_ITEMS || self.bytes.saturating_add(bytes) > MAX_CACHE_BYTES {
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
self.bytes = self.bytes.saturating_add(bytes);
|
self.bytes = self.bytes.saturating_add(bytes);
|
||||||
self.entries.insert(key, Entry { texture: texture.clone(), bytes, side, used: frame });
|
self.entries.insert(key, Entry { texture: texture.clone(), bytes, side, used: frame });
|
||||||
texture
|
texture
|
||||||
@@ -212,13 +219,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
||||||
assert!(cache.entries.len() < data.len());
|
assert!(cache.entries.len() < data.len());
|
||||||
// Many on screen at once, over the budget: every one keeps its texture, frame after frame.
|
// Many on screen at once, over the budget: each still gets a texture this frame, but the
|
||||||
|
// cache cannot grow past its budget whatever the workbook shows. Eight of the 16 MB
|
||||||
|
// textures fit the 128 MB budget, and those stay pinned; the rest re-upload each frame
|
||||||
|
// instead of being retained over it (finding: cache admission).
|
||||||
|
// Two frames pass so the rehearsal's entries are old enough to be evictable.
|
||||||
|
frame(&ctx);
|
||||||
|
frame(&ctx);
|
||||||
let shown: Vec<_> = data.iter().enumerate().take(12).map(|(i, d)| cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id()).collect();
|
let shown: Vec<_> = data.iter().enumerate().take(12).map(|(i, d)| cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id()).collect();
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
frame(&ctx);
|
frame(&ctx);
|
||||||
for (i, d) in data.iter().enumerate().take(12) {
|
for (i, d) in data.iter().enumerate().take(12) {
|
||||||
assert_eq!(cache.texture(&ctx, key(1, i as u32, d), d, 2048).map(|t| t.id()), Some(shown[i]));
|
let id = cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id();
|
||||||
|
if i < 8 {
|
||||||
|
assert_eq!(id, shown[i], "a picture that fits keeps its texture");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
||||||
|
assert!(cache.entries.len() <= 8, "{} entries kept", cache.entries.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
BASE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||||||
KEYCHAIN="$TMP/gridcraft-signing.keychain-db"
|
# A private directory created exclusively, not predictable names in a possibly shared temp
|
||||||
CERT="$TMP/gridcraft-signing.p12"
|
# root: another local user's symlink at `gridcraft-signing.p12` would otherwise select the
|
||||||
|
# write target and capture the decoded certificate.
|
||||||
|
WORK="$(mktemp -d "$BASE/gridcraft-signing.XXXXXXXX")"
|
||||||
|
KEYCHAIN="$WORK/gridcraft-signing.keychain-db"
|
||||||
|
CERT="$WORK/gridcraft-signing.p12"
|
||||||
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
|
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
|
||||||
|
|
||||||
trap 'rm -f "$CERT"' EXIT
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
|
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
|
||||||
security create-keychain -p "$KC_PASS" "$KEYCHAIN"
|
security create-keychain -p "$KC_PASS" "$KEYCHAIN"
|
||||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||||
|
|||||||
+24
-3
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::MetadataExt as _;
|
||||||
|
|
||||||
#[derive(Default, Clone, Copy)]
|
#[derive(Default, Clone, Copy)]
|
||||||
struct Counts {
|
struct Counts {
|
||||||
files: usize,
|
files: usize,
|
||||||
@@ -10,7 +13,24 @@ struct Counts {
|
|||||||
proptests: usize,
|
proptests: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn walk(dir: &Path, c: &mut Counts) {
|
fn walk(dir: &Path, c: &mut Counts, seen: &mut std::collections::HashSet<u64>, budget: &mut usize) {
|
||||||
|
if *budget == 0 {
|
||||||
|
// Hard stop for pathological trees: two directory symlinks pointing at each other
|
||||||
|
// would otherwise be walked exponentially.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Directory identities (dev+inode on unix) make a symlink to a visited directory a
|
||||||
|
// no-op, not another exponential branch of the scan.
|
||||||
|
#[cfg(unix)]
|
||||||
|
let id = std::fs::metadata(dir).ok().and_then(|m| m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino())));
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let id: Option<u64> = None;
|
||||||
|
if let Some(id) = id
|
||||||
|
&& !seen.insert(id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*budget -= 1;
|
||||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -18,10 +38,11 @@ fn walk(dir: &Path, c: &mut Counts) {
|
|||||||
let p = e.path();
|
let p = e.path();
|
||||||
let name = e.file_name();
|
let name = e.file_name();
|
||||||
let name = name.to_string_lossy();
|
let name = name.to_string_lossy();
|
||||||
|
// A symlinked directory is descended only if its identity is new (checked above).
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
// Nested fuzz workspaces and build output are not part of the crate.
|
// Nested fuzz workspaces and build output are not part of the crate.
|
||||||
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
||||||
walk(&p, c);
|
walk(&p, c, seen, budget);
|
||||||
}
|
}
|
||||||
} else if name.ends_with(".rs")
|
} else if name.ends_with(".rs")
|
||||||
&& let Ok(s) = std::fs::read_to_string(&p)
|
&& let Ok(s) = std::fs::read_to_string(&p)
|
||||||
@@ -71,7 +92,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut c = Counts::default();
|
let mut c = Counts::default();
|
||||||
walk(&dir, &mut c);
|
walk(&dir, &mut c, &mut std::collections::HashSet::new(), &mut 10_000);
|
||||||
let ex = if exact { exact_tests(&name) } else { None };
|
let ex = if exact { exact_tests(&name) } else { None };
|
||||||
rows.push((name, c, ex));
|
rows.push((name, c, ex));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user