buildwithnexus 0.11.1 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/Cargo.lock +1 -1
- package/harness/Cargo.toml +1 -1
- package/harness/src/agent.rs +33 -15
- package/harness/src/provider.rs +203 -11
- package/harness/src/tools.rs +681 -23
- package/harness/src/tui.rs +94 -25
- package/package.json +1 -1
package/harness/src/tools.rs
CHANGED
|
@@ -318,11 +318,14 @@ pub fn defs_for_context(include_subagent: bool, context_tokens: usize) -> Vec<To
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
// The compact surface advertises one canonical tool per capability — read,
|
|
321
|
-
// write, edit, shell, glob, grep, list, artifact publishing, plus the
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
321
|
+
// write, edit, shell, glob, grep, list, artifact publishing, plus the finish
|
|
322
|
+
// control tool and python_tool for local extensions. The `question` tool is
|
|
323
|
+
// deliberately omitted: small models use it to stall (endlessly re-asking a
|
|
324
|
+
// clarifying question instead of building), so on the compact surface they must
|
|
325
|
+
// act on sensible defaults. Pure aliases (`read`/`write`/`edit`/`bash`,
|
|
326
|
+
// `publish_artifact`) still dispatch in `run` if a model insists, but
|
|
327
|
+
// advertising duplicates wastes prompt tokens and confuses weak models. Keep
|
|
328
|
+
// this list at 12 defs or fewer.
|
|
326
329
|
fn compact_tool(name: &str) -> bool {
|
|
327
330
|
matches!(
|
|
328
331
|
name,
|
|
@@ -334,7 +337,6 @@ fn compact_tool(name: &str) -> bool {
|
|
|
334
337
|
| "grep_files"
|
|
335
338
|
| "list_dir"
|
|
336
339
|
| "finish"
|
|
337
|
-
| "question"
|
|
338
340
|
| "Artifact"
|
|
339
341
|
| "python_tool"
|
|
340
342
|
)
|
|
@@ -1182,6 +1184,57 @@ fn first_local_script_src(contents: &str) -> Option<String> {
|
|
|
1182
1184
|
None
|
|
1183
1185
|
}
|
|
1184
1186
|
|
|
1187
|
+
// True if `val` is a local relative path (not an absolute URL or data: URI) —
|
|
1188
|
+
// such a reference won't exist next to a published single-file artifact.
|
|
1189
|
+
fn is_local_asset(val: &str) -> bool {
|
|
1190
|
+
let v = val.trim().to_ascii_lowercase();
|
|
1191
|
+
!v.is_empty()
|
|
1192
|
+
&& !v.starts_with("http://")
|
|
1193
|
+
&& !v.starts_with("https://")
|
|
1194
|
+
&& !v.starts_with("//")
|
|
1195
|
+
&& !v.starts_with("data:")
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Extract a (possibly quoted) attribute value from the text right after `attr=`.
|
|
1199
|
+
fn attr_after(after: &str) -> String {
|
|
1200
|
+
let v = if let Some(rest) = after.strip_prefix('"') {
|
|
1201
|
+
rest.split('"').next().unwrap_or("")
|
|
1202
|
+
} else if let Some(rest) = after.strip_prefix('\'') {
|
|
1203
|
+
rest.split('\'').next().unwrap_or("")
|
|
1204
|
+
} else {
|
|
1205
|
+
after
|
|
1206
|
+
.split(|c: char| c.is_whitespace() || c == '>')
|
|
1207
|
+
.next()
|
|
1208
|
+
.unwrap_or("")
|
|
1209
|
+
};
|
|
1210
|
+
v.trim().to_string()
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// A `<link rel="stylesheet" href="local.css">` pointing at a local file that
|
|
1214
|
+
// won't exist next to the published artifact. Returns the local href, else None.
|
|
1215
|
+
fn first_local_stylesheet_href(contents: &str) -> Option<String> {
|
|
1216
|
+
let lower = contents.to_ascii_lowercase();
|
|
1217
|
+
let mut at = 0;
|
|
1218
|
+
while let Some(rel) = lower[at..].find("<link") {
|
|
1219
|
+
let tag_start = at + rel;
|
|
1220
|
+
let tag_end = lower[tag_start..]
|
|
1221
|
+
.find('>')
|
|
1222
|
+
.map(|e| tag_start + e)
|
|
1223
|
+
.unwrap_or(lower.len());
|
|
1224
|
+
let tag_lower = &lower[tag_start..tag_end];
|
|
1225
|
+
if tag_lower.contains("stylesheet") {
|
|
1226
|
+
if let Some(hp) = tag_lower.find("href=") {
|
|
1227
|
+
let val = attr_after(&contents[tag_start + hp + "href=".len()..tag_end]);
|
|
1228
|
+
if is_local_asset(&val) {
|
|
1229
|
+
return Some(val);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
at = tag_end.max(tag_start + "<link".len());
|
|
1234
|
+
}
|
|
1235
|
+
None
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1185
1238
|
// Rejection reasons for artifact contents. Every message quotes the offending
|
|
1186
1239
|
// snippet and the exact rule, plus what to change — cheap models can't fix
|
|
1187
1240
|
// what they can't see.
|
|
@@ -1196,7 +1249,12 @@ fn artifact_quality_error(contents: &str, kind: &str) -> Option<String> {
|
|
|
1196
1249
|
let trimmed_len = contents.trim().len();
|
|
1197
1250
|
if trimmed_len < 300 {
|
|
1198
1251
|
return Some(format!(
|
|
1199
|
-
"HTML artifact is too small to be a complete runnable app: {trimmed_len} chars, but the minimum is 300.
|
|
1252
|
+
"HTML artifact is too small to be a complete runnable app: {trimmed_len} chars, but the minimum is 300. It must be ONE self-contained file: do NOT link external files (no <link rel=stylesheet> or <script src=…>); put ALL CSS inside a <style> block and ALL JavaScript — the full working logic — inside a <script> block. Resend the complete document in `contents`."
|
|
1253
|
+
));
|
|
1254
|
+
}
|
|
1255
|
+
if let Some(href) = first_local_stylesheet_href(contents) {
|
|
1256
|
+
return Some(format!(
|
|
1257
|
+
"HTML artifact links a local stylesheet via <link rel=\"stylesheet\" href=\"{href}\">, which will not exist next to the published file. Move those styles into an inline <style>…</style> block so the artifact is self-contained."
|
|
1200
1258
|
));
|
|
1201
1259
|
}
|
|
1202
1260
|
if let Some(src) = first_local_script_src(contents) {
|
|
@@ -1490,14 +1548,8 @@ fn strip_html(html: &str) -> String {
|
|
|
1490
1548
|
i += 1;
|
|
1491
1549
|
}
|
|
1492
1550
|
|
|
1493
|
-
// Collapse whitespace and decode
|
|
1494
|
-
let decoded = out
|
|
1495
|
-
.replace("&", "&")
|
|
1496
|
-
.replace("<", "<")
|
|
1497
|
-
.replace(">", ">")
|
|
1498
|
-
.replace(""", "\"")
|
|
1499
|
-
.replace(" ", " ")
|
|
1500
|
-
.replace("'", "'");
|
|
1551
|
+
// Collapse whitespace and decode HTML entities (named + numeric).
|
|
1552
|
+
let decoded = decode_html_entities(&out);
|
|
1501
1553
|
|
|
1502
1554
|
let mut result = String::new();
|
|
1503
1555
|
let mut prev_ws = true;
|
|
@@ -1515,6 +1567,205 @@ fn strip_html(html: &str) -> String {
|
|
|
1515
1567
|
result.trim().to_string()
|
|
1516
1568
|
}
|
|
1517
1569
|
|
|
1570
|
+
// One parsed web-search result.
|
|
1571
|
+
struct SearchHit {
|
|
1572
|
+
title: String,
|
|
1573
|
+
url: String,
|
|
1574
|
+
snippet: String,
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// Percent-decode a URL-encoded string (also treats `+` as space). Invalid
|
|
1578
|
+
// escapes are left as-is. Bytes are reassembled as UTF-8 lossily.
|
|
1579
|
+
fn percent_decode(s: &str) -> String {
|
|
1580
|
+
let bytes = s.as_bytes();
|
|
1581
|
+
let mut out = Vec::with_capacity(bytes.len());
|
|
1582
|
+
let mut i = 0;
|
|
1583
|
+
while i < bytes.len() {
|
|
1584
|
+
match bytes[i] {
|
|
1585
|
+
b'%' if i + 2 < bytes.len() => {
|
|
1586
|
+
if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
|
1587
|
+
out.push(b);
|
|
1588
|
+
i += 3;
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
out.push(b'%');
|
|
1592
|
+
i += 1;
|
|
1593
|
+
}
|
|
1594
|
+
b'+' => {
|
|
1595
|
+
out.push(b' ');
|
|
1596
|
+
i += 1;
|
|
1597
|
+
}
|
|
1598
|
+
b => {
|
|
1599
|
+
out.push(b);
|
|
1600
|
+
i += 1;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
String::from_utf8_lossy(&out).into_owned()
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Resolve a single entity body (the text between `&` and `;`) to its character:
|
|
1608
|
+
// the common named entities plus decimal (`#8217`) and hex (`#x2019`) numeric
|
|
1609
|
+
// character references. Returns None for anything unrecognized.
|
|
1610
|
+
fn entity_char(ent: &str) -> Option<char> {
|
|
1611
|
+
match ent {
|
|
1612
|
+
"amp" => Some('&'),
|
|
1613
|
+
"lt" => Some('<'),
|
|
1614
|
+
"gt" => Some('>'),
|
|
1615
|
+
"quot" => Some('"'),
|
|
1616
|
+
"apos" => Some('\''),
|
|
1617
|
+
"nbsp" => Some(' '),
|
|
1618
|
+
"mdash" => Some('—'),
|
|
1619
|
+
"ndash" => Some('–'),
|
|
1620
|
+
"hellip" => Some('…'),
|
|
1621
|
+
"rsquo" => Some('’'),
|
|
1622
|
+
"lsquo" => Some('‘'),
|
|
1623
|
+
"rdquo" => Some('”'),
|
|
1624
|
+
"ldquo" => Some('“'),
|
|
1625
|
+
_ => {
|
|
1626
|
+
let num = ent.strip_prefix('#')?;
|
|
1627
|
+
let code = match num.strip_prefix('x').or_else(|| num.strip_prefix('X')) {
|
|
1628
|
+
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
|
|
1629
|
+
None => num.parse::<u32>().ok()?,
|
|
1630
|
+
};
|
|
1631
|
+
char::from_u32(code)
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Decode HTML entities in a single left-to-right pass — named entities and
|
|
1637
|
+
// decimal/hex numeric character references. Single-pass so `&lt;` decodes to
|
|
1638
|
+
// the literal `<` (not `<`), and an unrecognized `&…;` is left untouched.
|
|
1639
|
+
fn decode_html_entities(s: &str) -> String {
|
|
1640
|
+
if !s.contains('&') {
|
|
1641
|
+
return s.to_string();
|
|
1642
|
+
}
|
|
1643
|
+
let mut out = String::with_capacity(s.len());
|
|
1644
|
+
let mut rest = s;
|
|
1645
|
+
while let Some(amp) = rest.find('&') {
|
|
1646
|
+
out.push_str(&rest[..amp]);
|
|
1647
|
+
let tail = &rest[amp..];
|
|
1648
|
+
// `&` and `;` are ASCII, so `semi` is a valid char boundary; the ≤12
|
|
1649
|
+
// bound keeps a lone `&` in prose from swallowing a distant `;`.
|
|
1650
|
+
if let Some(semi) = tail.find(';') {
|
|
1651
|
+
if semi <= 12 {
|
|
1652
|
+
if let Some(ch) = entity_char(&tail[1..semi]) {
|
|
1653
|
+
out.push(ch);
|
|
1654
|
+
rest = &tail[semi + 1..];
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
out.push('&');
|
|
1660
|
+
rest = &tail[1..];
|
|
1661
|
+
}
|
|
1662
|
+
out.push_str(rest);
|
|
1663
|
+
out
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Read attribute `attr` from the tag that opens at byte index `tag_start`,
|
|
1667
|
+
// scanning only within that tag (up to its closing `>`).
|
|
1668
|
+
fn tag_attr(html: &str, tag_start: usize, attr: &str) -> Option<String> {
|
|
1669
|
+
let end = html[tag_start..].find('>').map(|e| tag_start + e)?;
|
|
1670
|
+
let seg = &html[tag_start..end];
|
|
1671
|
+
let key = format!("{attr}=");
|
|
1672
|
+
let after = &seg[seg.find(&key)? + key.len()..];
|
|
1673
|
+
let quote = after.chars().next()?;
|
|
1674
|
+
if quote == '"' || quote == '\'' {
|
|
1675
|
+
let rest = &after[1..];
|
|
1676
|
+
rest.find(quote).map(|c| rest[..c].to_string())
|
|
1677
|
+
} else {
|
|
1678
|
+
let endv = after.find(char::is_whitespace).unwrap_or(after.len());
|
|
1679
|
+
Some(after[..endv].to_string())
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// DuckDuckGo Lite wraps result links in a redirect: `//duckduckgo.com/l/?uddg=
|
|
1684
|
+
// <percent-encoded-target>&rut=…`. Recover the real destination; fall back to
|
|
1685
|
+
// normalizing a protocol-relative href.
|
|
1686
|
+
fn ddg_real_url(href: &str) -> String {
|
|
1687
|
+
let href = href.replace("&", "&");
|
|
1688
|
+
if let Some(p) = href.find("uddg=") {
|
|
1689
|
+
let rest = &href[p + "uddg=".len()..];
|
|
1690
|
+
let end = rest.find('&').unwrap_or(rest.len());
|
|
1691
|
+
return percent_decode(&rest[..end]);
|
|
1692
|
+
}
|
|
1693
|
+
if let Some(stripped) = href.strip_prefix("//") {
|
|
1694
|
+
return format!("https://{stripped}");
|
|
1695
|
+
}
|
|
1696
|
+
href.to_string()
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// Parse DuckDuckGo Lite result rows into structured hits. Returns empty if the
|
|
1700
|
+
// markup doesn't match (the caller then falls back to a stripped-text blob).
|
|
1701
|
+
fn parse_ddg_lite(html: &str) -> Vec<SearchHit> {
|
|
1702
|
+
let mut titles: Vec<(String, String)> = Vec::new();
|
|
1703
|
+
let mut i = 0;
|
|
1704
|
+
while let Some(rel) = html[i..].find("<a ") {
|
|
1705
|
+
let tag_start = i + rel;
|
|
1706
|
+
let Some(gt) = html[tag_start..].find('>').map(|e| tag_start + e) else {
|
|
1707
|
+
break;
|
|
1708
|
+
};
|
|
1709
|
+
if html[tag_start..gt].contains("result-link") {
|
|
1710
|
+
let url = ddg_real_url(&tag_attr(html, tag_start, "href").unwrap_or_default());
|
|
1711
|
+
let after = &html[gt + 1..];
|
|
1712
|
+
if let Some(close) = after.to_lowercase().find("</a") {
|
|
1713
|
+
let title = decode_html_entities(&strip_html(&after[..close]))
|
|
1714
|
+
.trim()
|
|
1715
|
+
.to_string();
|
|
1716
|
+
if !title.is_empty() {
|
|
1717
|
+
titles.push((title, url));
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
i = gt + 1;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
let mut snippets: Vec<String> = Vec::new();
|
|
1725
|
+
let mut j = 0;
|
|
1726
|
+
while let Some(rel) = html[j..].find("result-snippet") {
|
|
1727
|
+
let pos = j + rel;
|
|
1728
|
+
if let Some(gt) = html[pos..].find('>') {
|
|
1729
|
+
let start = pos + gt + 1;
|
|
1730
|
+
if let Some(lt) = html[start..].find('<') {
|
|
1731
|
+
snippets.push(
|
|
1732
|
+
decode_html_entities(&strip_html(&html[start..start + lt]))
|
|
1733
|
+
.trim()
|
|
1734
|
+
.to_string(),
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
j = pos + "result-snippet".len();
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
titles
|
|
1742
|
+
.into_iter()
|
|
1743
|
+
.enumerate()
|
|
1744
|
+
.map(|(k, (title, url))| SearchHit {
|
|
1745
|
+
title,
|
|
1746
|
+
url,
|
|
1747
|
+
snippet: snippets.get(k).cloned().unwrap_or_default(),
|
|
1748
|
+
})
|
|
1749
|
+
.collect()
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// Render parsed hits as compact, numbered `title / url / snippet` blocks that a
|
|
1753
|
+
// small model can read, capped at `max` results.
|
|
1754
|
+
fn format_search_hits(hits: &[SearchHit], max: usize) -> String {
|
|
1755
|
+
let mut out = String::new();
|
|
1756
|
+
for (n, h) in hits.iter().take(max).enumerate() {
|
|
1757
|
+
out.push_str(&format!("{}. {}\n", n + 1, h.title));
|
|
1758
|
+
if !h.url.is_empty() {
|
|
1759
|
+
out.push_str(&format!(" {}\n", h.url));
|
|
1760
|
+
}
|
|
1761
|
+
if !h.snippet.is_empty() {
|
|
1762
|
+
out.push_str(&format!(" {}\n", h.snippet));
|
|
1763
|
+
}
|
|
1764
|
+
out.push('\n');
|
|
1765
|
+
}
|
|
1766
|
+
out.trim_end().to_string()
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1518
1769
|
// Commands so destructive they require confirmation in every mode.
|
|
1519
1770
|
pub fn catastrophic(cmd: &str) -> bool {
|
|
1520
1771
|
let lower = cmd.to_lowercase();
|
|
@@ -1861,13 +2112,170 @@ fn xml_escape(s: &str) -> String {
|
|
|
1861
2112
|
}
|
|
1862
2113
|
|
|
1863
2114
|
fn docx_paragraph(text: &str, style: Option<&str>) -> String {
|
|
1864
|
-
let
|
|
2115
|
+
let ppr = style
|
|
1865
2116
|
.map(|s| format!("<w:pPr><w:pStyle w:val=\"{s}\"/></w:pPr>"))
|
|
1866
2117
|
.unwrap_or_default();
|
|
1867
|
-
format!(
|
|
1868
|
-
|
|
2118
|
+
format!("<w:p>{ppr}{}</w:p>", docx_runs(text))
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// Emit one Word run for `text` with the active inline formatting. Empty text
|
|
2122
|
+
// produces nothing (an all-empty paragraph is still valid OOXML).
|
|
2123
|
+
fn docx_push_run(out: &mut String, text: &str, bold: bool, italic: bool, code: bool) {
|
|
2124
|
+
if text.is_empty() {
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
let mut rpr = String::new();
|
|
2128
|
+
if bold {
|
|
2129
|
+
rpr.push_str("<w:b/>");
|
|
2130
|
+
}
|
|
2131
|
+
if italic {
|
|
2132
|
+
rpr.push_str("<w:i/>");
|
|
2133
|
+
}
|
|
2134
|
+
if code {
|
|
2135
|
+
rpr.push_str("<w:rFonts w:ascii=\"Consolas\" w:hAnsi=\"Consolas\" w:cs=\"Consolas\"/>");
|
|
2136
|
+
}
|
|
2137
|
+
if !rpr.is_empty() {
|
|
2138
|
+
rpr = format!("<w:rPr>{rpr}</w:rPr>");
|
|
2139
|
+
}
|
|
2140
|
+
out.push_str("<w:r>");
|
|
2141
|
+
out.push_str(&rpr);
|
|
2142
|
+
out.push_str(&format!(
|
|
2143
|
+
"<w:t xml:space=\"preserve\">{}</w:t></w:r>",
|
|
1869
2144
|
xml_escape(text)
|
|
1870
|
-
)
|
|
2145
|
+
));
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// Light-markdown inline formatting → Word runs: `**bold**`, `*italic*`, and
|
|
2149
|
+
// `` `code` `` (verbatim). Emphasis markers only toggle when they flank
|
|
2150
|
+
// non-space text, so arithmetic like `5 * 3` and glob patterns stay literal;
|
|
2151
|
+
// underscores are deliberately left alone so `snake_case` isn't italicized.
|
|
2152
|
+
fn docx_runs(text: &str) -> String {
|
|
2153
|
+
let chars: Vec<char> = text.chars().collect();
|
|
2154
|
+
let n = chars.len();
|
|
2155
|
+
let mut out = String::new();
|
|
2156
|
+
let mut buf = String::new();
|
|
2157
|
+
let (mut bold, mut italic, mut code) = (false, false, false);
|
|
2158
|
+
let mut i = 0;
|
|
2159
|
+
while i < n {
|
|
2160
|
+
let c = chars[i];
|
|
2161
|
+
// Inside a code span everything is literal until the closing backtick.
|
|
2162
|
+
if code {
|
|
2163
|
+
if c == '`' {
|
|
2164
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2165
|
+
buf.clear();
|
|
2166
|
+
code = false;
|
|
2167
|
+
} else {
|
|
2168
|
+
buf.push(c);
|
|
2169
|
+
}
|
|
2170
|
+
i += 1;
|
|
2171
|
+
continue;
|
|
2172
|
+
}
|
|
2173
|
+
match c {
|
|
2174
|
+
'`' => {
|
|
2175
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2176
|
+
buf.clear();
|
|
2177
|
+
code = true;
|
|
2178
|
+
i += 1;
|
|
2179
|
+
}
|
|
2180
|
+
'*' if i + 1 < n && chars[i + 1] == '*' => {
|
|
2181
|
+
// Opening `**` must precede non-space; closing must follow it.
|
|
2182
|
+
let flanks = if bold {
|
|
2183
|
+
i > 0 && !chars[i - 1].is_whitespace()
|
|
2184
|
+
} else {
|
|
2185
|
+
i + 2 < n && !chars[i + 2].is_whitespace()
|
|
2186
|
+
};
|
|
2187
|
+
if flanks {
|
|
2188
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2189
|
+
buf.clear();
|
|
2190
|
+
bold = !bold;
|
|
2191
|
+
} else {
|
|
2192
|
+
buf.push('*');
|
|
2193
|
+
buf.push('*');
|
|
2194
|
+
}
|
|
2195
|
+
i += 2;
|
|
2196
|
+
}
|
|
2197
|
+
'*' => {
|
|
2198
|
+
let flanks = if italic {
|
|
2199
|
+
i > 0 && !chars[i - 1].is_whitespace()
|
|
2200
|
+
} else {
|
|
2201
|
+
i + 1 < n && !chars[i + 1].is_whitespace()
|
|
2202
|
+
};
|
|
2203
|
+
if flanks {
|
|
2204
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2205
|
+
buf.clear();
|
|
2206
|
+
italic = !italic;
|
|
2207
|
+
} else {
|
|
2208
|
+
buf.push('*');
|
|
2209
|
+
}
|
|
2210
|
+
i += 1;
|
|
2211
|
+
}
|
|
2212
|
+
_ => {
|
|
2213
|
+
buf.push(c);
|
|
2214
|
+
i += 1;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2219
|
+
out
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// Split a markdown table line into trimmed cells, dropping the outer pipes.
|
|
2223
|
+
fn parse_table_cells(line: &str) -> Vec<String> {
|
|
2224
|
+
let t = line.trim();
|
|
2225
|
+
let t = t.strip_prefix('|').unwrap_or(t);
|
|
2226
|
+
let t = t.strip_suffix('|').unwrap_or(t);
|
|
2227
|
+
t.split('|').map(|c| c.trim().to_string()).collect()
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// A markdown table row: trimmed, starts with `|`, and has at least two pipes.
|
|
2231
|
+
fn is_table_row(line: &str) -> bool {
|
|
2232
|
+
let t = line.trim();
|
|
2233
|
+
t.starts_with('|') && t.matches('|').count() >= 2
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// The `|---|:--:|` alignment row: every cell is only dashes/colons.
|
|
2237
|
+
fn is_table_separator(line: &str) -> bool {
|
|
2238
|
+
let cells = parse_table_cells(line);
|
|
2239
|
+
!cells.is_empty()
|
|
2240
|
+
&& cells
|
|
2241
|
+
.iter()
|
|
2242
|
+
.all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':'))
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// One table cell. Header cells are a single bold run; body cells get full
|
|
2246
|
+
// inline markdown formatting.
|
|
2247
|
+
fn docx_table_cell(text: &str, header: bool) -> String {
|
|
2248
|
+
let runs = if header {
|
|
2249
|
+
let mut s = String::new();
|
|
2250
|
+
docx_push_run(&mut s, text, true, false, false);
|
|
2251
|
+
s
|
|
2252
|
+
} else {
|
|
2253
|
+
docx_runs(text)
|
|
2254
|
+
};
|
|
2255
|
+
format!("<w:tc><w:tcPr><w:tcW w:w=\"0\" w:type=\"auto\"/></w:tcPr><w:p>{runs}</w:p></w:tc>")
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// A bordered Word table. The first row is treated as the (bold) header.
|
|
2259
|
+
fn docx_table(rows: &[Vec<String>]) -> String {
|
|
2260
|
+
let mut out = String::from(
|
|
2261
|
+
"<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>\
|
|
2262
|
+
<w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2263
|
+
<w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2264
|
+
<w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2265
|
+
<w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2266
|
+
<w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2267
|
+
<w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2268
|
+
</w:tblBorders></w:tblPr>",
|
|
2269
|
+
);
|
|
2270
|
+
for (r, row) in rows.iter().enumerate() {
|
|
2271
|
+
out.push_str("<w:tr>");
|
|
2272
|
+
for cell in row {
|
|
2273
|
+
out.push_str(&docx_table_cell(cell, r == 0));
|
|
2274
|
+
}
|
|
2275
|
+
out.push_str("</w:tr>");
|
|
2276
|
+
}
|
|
2277
|
+
out.push_str("</w:tbl>");
|
|
2278
|
+
out
|
|
1871
2279
|
}
|
|
1872
2280
|
|
|
1873
2281
|
fn docx_document(title: &str, body: &str) -> String {
|
|
@@ -1875,8 +2283,25 @@ fn docx_document(title: &str, body: &str) -> String {
|
|
|
1875
2283
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>"#,
|
|
1876
2284
|
);
|
|
1877
2285
|
out.push_str(&docx_paragraph(title, Some("Title")));
|
|
1878
|
-
|
|
1879
|
-
|
|
2286
|
+
let lines: Vec<&str> = body.lines().collect();
|
|
2287
|
+
let mut idx = 0;
|
|
2288
|
+
while idx < lines.len() {
|
|
2289
|
+
// A run of consecutive `|`-delimited rows becomes one Word table; the
|
|
2290
|
+
// alignment separator row is dropped and the first row is the header.
|
|
2291
|
+
if is_table_row(lines[idx]) {
|
|
2292
|
+
let mut rows: Vec<Vec<String>> = Vec::new();
|
|
2293
|
+
while idx < lines.len() && is_table_row(lines[idx]) {
|
|
2294
|
+
if !is_table_separator(lines[idx]) {
|
|
2295
|
+
rows.push(parse_table_cells(lines[idx]));
|
|
2296
|
+
}
|
|
2297
|
+
idx += 1;
|
|
2298
|
+
}
|
|
2299
|
+
if !rows.is_empty() {
|
|
2300
|
+
out.push_str(&docx_table(&rows));
|
|
2301
|
+
}
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
let trimmed = lines[idx].trim();
|
|
1880
2305
|
if trimmed.is_empty() {
|
|
1881
2306
|
out.push_str("<w:p/>");
|
|
1882
2307
|
} else if let Some(h) = trimmed.strip_prefix("### ") {
|
|
@@ -1890,6 +2315,7 @@ fn docx_document(title: &str, body: &str) -> String {
|
|
|
1890
2315
|
} else {
|
|
1891
2316
|
out.push_str(&docx_paragraph(trimmed, None));
|
|
1892
2317
|
}
|
|
2318
|
+
idx += 1;
|
|
1893
2319
|
}
|
|
1894
2320
|
out.push_str(r#"<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>"#);
|
|
1895
2321
|
out
|
|
@@ -2983,7 +3409,20 @@ pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
|
|
|
2983
3409
|
.set("User-Agent", "Mozilla/5.0 (compatible; buildwithnexus/1.0)"),
|
|
2984
3410
|
) {
|
|
2985
3411
|
Ok(resp) => match resp.into_string() {
|
|
2986
|
-
Ok(html) =>
|
|
3412
|
+
Ok(html) => {
|
|
3413
|
+
let hits = parse_ddg_lite(&html);
|
|
3414
|
+
if hits.is_empty() {
|
|
3415
|
+
// Markup didn't match — fall back to the raw text so
|
|
3416
|
+
// the model still gets something.
|
|
3417
|
+
ok(truncate(strip_html(&html), MAX_OUT))
|
|
3418
|
+
} else {
|
|
3419
|
+
let body = format_search_hits(&hits, 10);
|
|
3420
|
+
ok(truncate(
|
|
3421
|
+
format!("{} results for \"{query}\":\n\n{body}", hits.len()),
|
|
3422
|
+
MAX_OUT,
|
|
3423
|
+
))
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
2987
3426
|
Err(e) => err(format!("search response error: {e}")),
|
|
2988
3427
|
},
|
|
2989
3428
|
Err(e) => err(format!("web search failed: {e}")),
|
|
@@ -3802,6 +4241,178 @@ mod tests {
|
|
|
3802
4241
|
use super::*;
|
|
3803
4242
|
use std::path::PathBuf;
|
|
3804
4243
|
|
|
4244
|
+
// ── web search parsing ──────────────────────────────────────────────────
|
|
4245
|
+
const DDG_LITE_SAMPLE: &str = r#"<html><body><form>search</form><table>
|
|
4246
|
+
<tr><td>1. </td><td>
|
|
4247
|
+
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fstd%2F&rut=abc" class='result-link'>Rust std docs</a>
|
|
4248
|
+
</td></tr>
|
|
4249
|
+
<tr><td> </td><td class='result-snippet'>The Rust Standard Library & API reference.</td></tr>
|
|
4250
|
+
<tr><td>2. </td><td>
|
|
4251
|
+
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io%2F&rut=def" class='result-link'>crates.io</a>
|
|
4252
|
+
</td></tr>
|
|
4253
|
+
<tr><td> </td><td class='result-snippet'>The Rust package registry.</td></tr>
|
|
4254
|
+
</table></body></html>"#;
|
|
4255
|
+
|
|
4256
|
+
#[test]
|
|
4257
|
+
fn percent_decode_handles_escapes_and_plus() {
|
|
4258
|
+
assert_eq!(percent_decode("a%2Fb%20c+d"), "a/b c d");
|
|
4259
|
+
// A malformed trailing escape is left literal, not dropped.
|
|
4260
|
+
assert_eq!(percent_decode("100%"), "100%");
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
#[test]
|
|
4264
|
+
fn decode_html_entities_single_pass_no_double_decode() {
|
|
4265
|
+
// Single pass: & becomes a literal & and the following "lt;" is left
|
|
4266
|
+
// alone, so this must not collapse to "<".
|
|
4267
|
+
assert_eq!(decode_html_entities("a &lt; b"), "a < b");
|
|
4268
|
+
assert_eq!(
|
|
4269
|
+
decode_html_entities("x <y> "z""),
|
|
4270
|
+
"x <y> \"z\""
|
|
4271
|
+
);
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
#[test]
|
|
4275
|
+
fn decode_html_entities_resolves_numeric_and_typographic() {
|
|
4276
|
+
// Decimal and hex numeric references for a curly apostrophe.
|
|
4277
|
+
assert_eq!(decode_html_entities("it’s"), "it’s");
|
|
4278
|
+
assert_eq!(decode_html_entities("it’s"), "it’s");
|
|
4279
|
+
// Common typographic named entities.
|
|
4280
|
+
assert_eq!(decode_html_entities("a — b …"), "a — b …");
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
#[test]
|
|
4284
|
+
fn decode_html_entities_leaves_unknown_and_bare_amp_literal() {
|
|
4285
|
+
assert_eq!(decode_html_entities("Tom & Jerry"), "Tom & Jerry");
|
|
4286
|
+
assert_eq!(decode_html_entities("¬real;"), "¬real;");
|
|
4287
|
+
// A `&` with no nearby `;` is untouched.
|
|
4288
|
+
assert_eq!(
|
|
4289
|
+
decode_html_entities("cats & dogs everywhere"),
|
|
4290
|
+
"cats & dogs everywhere"
|
|
4291
|
+
);
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
#[test]
|
|
4295
|
+
fn ddg_real_url_recovers_target_from_redirect() {
|
|
4296
|
+
assert_eq!(
|
|
4297
|
+
ddg_real_url("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=z"),
|
|
4298
|
+
"https://example.com/a"
|
|
4299
|
+
);
|
|
4300
|
+
assert_eq!(ddg_real_url("//example.com/x"), "https://example.com/x");
|
|
4301
|
+
}
|
|
4302
|
+
|
|
4303
|
+
#[test]
|
|
4304
|
+
fn parse_ddg_lite_extracts_structured_hits() {
|
|
4305
|
+
let hits = parse_ddg_lite(DDG_LITE_SAMPLE);
|
|
4306
|
+
assert_eq!(hits.len(), 2);
|
|
4307
|
+
assert_eq!(hits[0].title, "Rust std docs");
|
|
4308
|
+
assert_eq!(hits[0].url, "https://doc.rust-lang.org/std/");
|
|
4309
|
+
assert_eq!(
|
|
4310
|
+
hits[0].snippet,
|
|
4311
|
+
"The Rust Standard Library & API reference."
|
|
4312
|
+
);
|
|
4313
|
+
assert_eq!(hits[1].title, "crates.io");
|
|
4314
|
+
assert_eq!(hits[1].url, "https://crates.io/");
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
#[test]
|
|
4318
|
+
fn parse_ddg_lite_returns_empty_on_unrelated_html() {
|
|
4319
|
+
assert!(parse_ddg_lite("<html><body>no results here</body></html>").is_empty());
|
|
4320
|
+
}
|
|
4321
|
+
|
|
4322
|
+
#[test]
|
|
4323
|
+
fn format_search_hits_numbers_and_caps() {
|
|
4324
|
+
let hits = parse_ddg_lite(DDG_LITE_SAMPLE);
|
|
4325
|
+
let out = format_search_hits(&hits, 1);
|
|
4326
|
+
assert!(out.starts_with("1. Rust std docs"));
|
|
4327
|
+
assert!(out.contains("https://doc.rust-lang.org/std/"));
|
|
4328
|
+
// Capped at 1 — the second hit must not appear.
|
|
4329
|
+
assert!(!out.contains("crates.io"));
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4332
|
+
// ── docx tables ─────────────────────────────────────────────────────────
|
|
4333
|
+
#[test]
|
|
4334
|
+
fn table_row_and_separator_detection() {
|
|
4335
|
+
assert!(is_table_row("| a | b |"));
|
|
4336
|
+
assert!(is_table_row("|a|b|"));
|
|
4337
|
+
assert!(!is_table_row("a | b")); // no leading pipe
|
|
4338
|
+
assert!(!is_table_row("- bullet"));
|
|
4339
|
+
assert!(is_table_separator("| --- | :---: |"));
|
|
4340
|
+
assert!(!is_table_separator("| a | b |"));
|
|
4341
|
+
}
|
|
4342
|
+
|
|
4343
|
+
#[test]
|
|
4344
|
+
fn parse_table_cells_drops_outer_pipes_and_trims() {
|
|
4345
|
+
assert_eq!(parse_table_cells("| a | b |c|"), vec!["a", "b", "c"]);
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
#[test]
|
|
4349
|
+
fn docx_document_renders_markdown_table() {
|
|
4350
|
+
let body = "Intro line.\n| Name | Price |\n| --- | --- |\n| Glazed | $1.50 |\n| Old Fashioned | $2.00 |\n\nOutro.";
|
|
4351
|
+
let doc = docx_document("Menu", body);
|
|
4352
|
+
// A real table with a header row is emitted.
|
|
4353
|
+
assert!(doc.contains("<w:tbl>"));
|
|
4354
|
+
assert_eq!(doc.matches("<w:tr>").count(), 3); // header + 2 body rows
|
|
4355
|
+
// Header cells are bold; the separator row is gone.
|
|
4356
|
+
assert!(doc.contains("<w:rPr><w:b/></w:rPr><w:t xml:space=\"preserve\">Name</w:t>"));
|
|
4357
|
+
assert!(!doc.contains("---"));
|
|
4358
|
+
// Surrounding prose still renders as paragraphs.
|
|
4359
|
+
assert!(doc.contains(">Intro line.</w:t>"));
|
|
4360
|
+
assert!(doc.contains(">Outro.</w:t>"));
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
#[test]
|
|
4364
|
+
fn docx_document_without_table_is_unchanged_shape() {
|
|
4365
|
+
let doc = docx_document("T", "# Head\n- item\nplain");
|
|
4366
|
+
assert!(!doc.contains("<w:tbl>"));
|
|
4367
|
+
assert!(doc.contains(">• item</w:t>"));
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
// ── docx inline formatting ──────────────────────────────────────────────
|
|
4371
|
+
#[test]
|
|
4372
|
+
fn docx_runs_renders_bold_italic_and_code() {
|
|
4373
|
+
let out = docx_runs("plain **bold** and *italic* and `code` end");
|
|
4374
|
+
// Bold run carries <w:b/>, italic <w:i/>, code the monospace font.
|
|
4375
|
+
assert!(out.contains("<w:rPr><w:b/></w:rPr><w:t xml:space=\"preserve\">bold</w:t>"));
|
|
4376
|
+
assert!(out.contains("<w:rPr><w:i/></w:rPr><w:t xml:space=\"preserve\">italic</w:t>"));
|
|
4377
|
+
assert!(out.contains("w:ascii=\"Consolas\""));
|
|
4378
|
+
assert!(out.contains(">code</w:t>"));
|
|
4379
|
+
// The markers themselves are consumed, not emitted as literal text.
|
|
4380
|
+
assert!(!out.contains('*'));
|
|
4381
|
+
assert!(!out.contains('`'));
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
#[test]
|
|
4385
|
+
fn docx_runs_leaves_arithmetic_and_snake_case_literal() {
|
|
4386
|
+
// Spaced `*` is not emphasis; `_` never toggles italic.
|
|
4387
|
+
let out = docx_runs("compute 5 * 3 for my_var_name");
|
|
4388
|
+
assert!(!out.contains("<w:i/>"));
|
|
4389
|
+
assert!(!out.contains("<w:b/>"));
|
|
4390
|
+
assert!(out.contains("5 * 3 for my_var_name"));
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
#[test]
|
|
4394
|
+
fn docx_runs_code_span_is_verbatim() {
|
|
4395
|
+
// Emphasis markers inside a code span stay literal.
|
|
4396
|
+
let out = docx_runs("call `a*b` now");
|
|
4397
|
+
assert!(out.contains(">a*b</w:t>"));
|
|
4398
|
+
assert!(!out.contains("<w:i/>"));
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4401
|
+
#[test]
|
|
4402
|
+
fn docx_runs_escapes_xml_in_runs() {
|
|
4403
|
+
let out = docx_runs("**a < b & c**");
|
|
4404
|
+
assert!(out.contains("a < b & c"));
|
|
4405
|
+
assert!(out.contains("<w:b/>"));
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
#[test]
|
|
4409
|
+
fn docx_runs_unmatched_marker_stays_literal() {
|
|
4410
|
+
// A lone trailing `**` with nothing after it must not open emphasis.
|
|
4411
|
+
let out = docx_runs("trailing **");
|
|
4412
|
+
assert!(!out.contains("<w:b/>"));
|
|
4413
|
+
assert!(out.contains("trailing **"));
|
|
4414
|
+
}
|
|
4415
|
+
|
|
3805
4416
|
// ── truncate ────────────────────────────────────────────────────────────
|
|
3806
4417
|
#[test]
|
|
3807
4418
|
fn truncate_shorter_than_max_unchanged() {
|
|
@@ -4017,7 +4628,6 @@ mod tests {
|
|
|
4017
4628
|
"grep_files",
|
|
4018
4629
|
"list_dir",
|
|
4019
4630
|
"finish",
|
|
4020
|
-
"question",
|
|
4021
4631
|
"Artifact",
|
|
4022
4632
|
"python_tool",
|
|
4023
4633
|
] {
|
|
@@ -4030,6 +4640,7 @@ mod tests {
|
|
|
4030
4640
|
"kb_record",
|
|
4031
4641
|
"verify",
|
|
4032
4642
|
"text_editor_20250124",
|
|
4643
|
+
"question",
|
|
4033
4644
|
"AskUserQuestion",
|
|
4034
4645
|
"read",
|
|
4035
4646
|
"write",
|
|
@@ -4858,6 +5469,53 @@ print("hello " + data.get("name", "world"))
|
|
|
4858
5469
|
let _ = fs::remove_dir_all(&d);
|
|
4859
5470
|
}
|
|
4860
5471
|
|
|
5472
|
+
#[test]
|
|
5473
|
+
fn artifact_rejects_local_stylesheet_link() {
|
|
5474
|
+
let d = tempdir();
|
|
5475
|
+
// A 1.5B model's canvas-game stub linked <link rel=stylesheet href=styles.css>;
|
|
5476
|
+
// it must be rejected with a message naming the file and telling the
|
|
5477
|
+
// model to inline the CSS.
|
|
5478
|
+
let body = html_app("draw();").replace(
|
|
5479
|
+
"<head>",
|
|
5480
|
+
"<head><link rel=\"stylesheet\" href=\"styles.css\">",
|
|
5481
|
+
);
|
|
5482
|
+
let r = run(
|
|
5483
|
+
"Artifact",
|
|
5484
|
+
&json!({"title": "Game", "contents": body, "type": "html"}),
|
|
5485
|
+
&d,
|
|
5486
|
+
);
|
|
5487
|
+
assert!(r.is_error);
|
|
5488
|
+
assert!(r.content.contains("styles.css"), "{}", r.content);
|
|
5489
|
+
assert!(r.content.contains("<style"), "{}", r.content);
|
|
5490
|
+
let _ = fs::remove_dir_all(&d);
|
|
5491
|
+
}
|
|
5492
|
+
|
|
5493
|
+
#[test]
|
|
5494
|
+
fn artifact_allows_external_stylesheet_link() {
|
|
5495
|
+
let d = tempdir();
|
|
5496
|
+
let body = html_app("draw();").replace(
|
|
5497
|
+
"<head>",
|
|
5498
|
+
"<head><link rel=\"stylesheet\" href=\"https://cdn.example.com/x.css\">",
|
|
5499
|
+
);
|
|
5500
|
+
let r = run(
|
|
5501
|
+
"Artifact",
|
|
5502
|
+
&json!({"title": "Game", "contents": body, "type": "html"}),
|
|
5503
|
+
&d,
|
|
5504
|
+
);
|
|
5505
|
+
assert!(!r.is_error, "{}", r.content);
|
|
5506
|
+
let _ = fs::remove_dir_all(&d);
|
|
5507
|
+
}
|
|
5508
|
+
|
|
5509
|
+
#[test]
|
|
5510
|
+
fn is_local_asset_classifies_paths() {
|
|
5511
|
+
assert!(is_local_asset("styles.css"));
|
|
5512
|
+
assert!(is_local_asset("./css/app.css"));
|
|
5513
|
+
assert!(!is_local_asset("https://x.com/a.css"));
|
|
5514
|
+
assert!(!is_local_asset("//cdn/a.css"));
|
|
5515
|
+
assert!(!is_local_asset("data:text/css,body{}"));
|
|
5516
|
+
assert!(!is_local_asset(""));
|
|
5517
|
+
}
|
|
5518
|
+
|
|
4861
5519
|
#[test]
|
|
4862
5520
|
fn artifact_allows_external_script_src() {
|
|
4863
5521
|
let d = tempdir();
|