buildwithnexus 0.11.1 → 0.11.2
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/provider.rs +203 -11
- package/harness/src/tools.rs +568 -15
- package/harness/src/tui.rs +94 -25
- package/package.json +1 -1
package/harness/Cargo.lock
CHANGED
package/harness/Cargo.toml
CHANGED
package/harness/src/provider.rs
CHANGED
|
@@ -531,7 +531,7 @@ fn anthropic_parse(v: Value) -> Result<Reply, String> {
|
|
|
531
531
|
}
|
|
532
532
|
let stop_reason = v["stop_reason"].as_str().map(normalize_stop_reason);
|
|
533
533
|
Ok(Reply {
|
|
534
|
-
text,
|
|
534
|
+
text: strip_think(&text),
|
|
535
535
|
calls,
|
|
536
536
|
stop_reason,
|
|
537
537
|
})
|
|
@@ -729,9 +729,35 @@ fn openai_request_at(
|
|
|
729
729
|
(req, body)
|
|
730
730
|
}
|
|
731
731
|
|
|
732
|
+
// Remove `<think>…</think>` reasoning blocks that local reasoning models
|
|
733
|
+
// (DeepSeek-R1 distills, Qwen thinking variants) emit inline in `content` on
|
|
734
|
+
// the non-streaming path. Handles multiple blocks and an unclosed `<think>`
|
|
735
|
+
// (drops the trailing leaked chain-of-thought). Text without think tags is
|
|
736
|
+
// returned untouched. The streaming path already routes these to on_thinking.
|
|
737
|
+
fn strip_think(text: &str) -> String {
|
|
738
|
+
if !text.contains("<think>") {
|
|
739
|
+
return text.to_string();
|
|
740
|
+
}
|
|
741
|
+
let mut out = String::with_capacity(text.len());
|
|
742
|
+
let mut rest = text;
|
|
743
|
+
while let Some(start) = rest.find("<think>") {
|
|
744
|
+
out.push_str(&rest[..start]);
|
|
745
|
+
let after = &rest[start + "<think>".len()..];
|
|
746
|
+
match after.find("</think>") {
|
|
747
|
+
Some(end) => rest = &after[end + "</think>".len()..],
|
|
748
|
+
None => {
|
|
749
|
+
rest = "";
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
out.push_str(rest);
|
|
755
|
+
out.trim().to_string()
|
|
756
|
+
}
|
|
757
|
+
|
|
732
758
|
fn openai_parse(v: Value) -> Result<Reply, String> {
|
|
733
759
|
let msg = &v["choices"][0]["message"];
|
|
734
|
-
let text = msg["content"].as_str().unwrap_or_default()
|
|
760
|
+
let text = strip_think(msg["content"].as_str().unwrap_or_default());
|
|
735
761
|
let mut calls = Vec::new();
|
|
736
762
|
if let Some(tcs) = msg["tool_calls"].as_array() {
|
|
737
763
|
for tc in tcs {
|
|
@@ -756,14 +782,38 @@ fn openai_parse(v: Value) -> Result<Reply, String> {
|
|
|
756
782
|
})
|
|
757
783
|
}
|
|
758
784
|
|
|
785
|
+
// If `s` ends with a non-empty *proper* prefix of `tag` (e.g. "a<thi" for
|
|
786
|
+
// "<think>"), split so the prefix can be held until the next chunk completes it.
|
|
787
|
+
// Returns (emit, hold). No trailing partial → (s, "").
|
|
788
|
+
fn split_trailing_partial<'a>(s: &'a str, tag: &str) -> (&'a str, &'a str) {
|
|
789
|
+
let max = (tag.len() - 1).min(s.len());
|
|
790
|
+
for k in (1..=max).rev() {
|
|
791
|
+
if s.is_char_boundary(s.len() - k) && s.as_bytes().ends_with(&tag.as_bytes()[..k]) {
|
|
792
|
+
return (&s[..s.len() - k], &s[s.len() - k..]);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
(s, "")
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Route streamed OpenAI/Ollama `content` deltas: `<think>…</think>` spans go to
|
|
799
|
+
// `on_thinking`, everything else to `text`/`on_text`. `carry` holds a `<think>`
|
|
800
|
+
// or `</think>` tag that was split across chunk boundaries (routine with
|
|
801
|
+
// token-by-token local streaming) so the partial marker isn't leaked as output.
|
|
759
802
|
fn route_openai_content(
|
|
760
803
|
chunk: &str,
|
|
761
804
|
in_think: &mut bool,
|
|
805
|
+
carry: &mut String,
|
|
762
806
|
text: &mut String,
|
|
763
807
|
on_text: &mut dyn FnMut(&str),
|
|
764
808
|
on_thinking: &mut dyn FnMut(&str),
|
|
765
809
|
) {
|
|
766
|
-
let
|
|
810
|
+
let combined = if carry.is_empty() {
|
|
811
|
+
chunk.to_string()
|
|
812
|
+
} else {
|
|
813
|
+
format!("{carry}{chunk}")
|
|
814
|
+
};
|
|
815
|
+
carry.clear();
|
|
816
|
+
let mut rest = combined.as_str();
|
|
767
817
|
loop {
|
|
768
818
|
if *in_think {
|
|
769
819
|
if let Some(end) = rest.find("</think>") {
|
|
@@ -777,9 +827,12 @@ fn route_openai_content(
|
|
|
777
827
|
break;
|
|
778
828
|
}
|
|
779
829
|
} else {
|
|
780
|
-
|
|
781
|
-
|
|
830
|
+
// Hold a possible partial `</think>` at the tail for next chunk.
|
|
831
|
+
let (emit, hold) = split_trailing_partial(rest, "</think>");
|
|
832
|
+
if !emit.is_empty() {
|
|
833
|
+
on_thinking(emit);
|
|
782
834
|
}
|
|
835
|
+
*carry = hold.to_string();
|
|
783
836
|
break;
|
|
784
837
|
}
|
|
785
838
|
} else if let Some(start) = rest.find("<think>") {
|
|
@@ -794,15 +847,33 @@ fn route_openai_content(
|
|
|
794
847
|
break;
|
|
795
848
|
}
|
|
796
849
|
} else {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
850
|
+
// Hold a possible partial `<think>` at the tail for the next chunk.
|
|
851
|
+
let (emit, hold) = split_trailing_partial(rest, "<think>");
|
|
852
|
+
if !emit.is_empty() {
|
|
853
|
+
text.push_str(emit);
|
|
854
|
+
on_text(emit);
|
|
800
855
|
}
|
|
856
|
+
*carry = hold.to_string();
|
|
801
857
|
break;
|
|
802
858
|
}
|
|
803
859
|
}
|
|
804
860
|
}
|
|
805
861
|
|
|
862
|
+
// Flush a leftover `carry` at stream end: if we're not mid-think it was ordinary
|
|
863
|
+
// text that merely resembled a tag prefix, so surface it; mid-think residue
|
|
864
|
+
// (a dangling `</think>` prefix with no answer after) is dropped.
|
|
865
|
+
fn flush_think_carry(
|
|
866
|
+
carry: &str,
|
|
867
|
+
in_think: bool,
|
|
868
|
+
text: &mut String,
|
|
869
|
+
on_text: &mut dyn FnMut(&str),
|
|
870
|
+
) {
|
|
871
|
+
if !in_think && !carry.is_empty() {
|
|
872
|
+
text.push_str(carry);
|
|
873
|
+
on_text(carry);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
806
877
|
fn openai_stream(
|
|
807
878
|
reader: impl Read,
|
|
808
879
|
on_text: &mut dyn FnMut(&str),
|
|
@@ -810,6 +881,7 @@ fn openai_stream(
|
|
|
810
881
|
) -> Result<Reply, String> {
|
|
811
882
|
let mut text = String::new();
|
|
812
883
|
let mut in_think = false;
|
|
884
|
+
let mut think_carry = String::new();
|
|
813
885
|
let mut stop_reason: Option<String> = None;
|
|
814
886
|
// index → (id, name, accumulated args)
|
|
815
887
|
let mut pending: Vec<(String, String, String)> = Vec::new();
|
|
@@ -832,7 +904,14 @@ fn openai_stream(
|
|
|
832
904
|
on_thinking(r);
|
|
833
905
|
}
|
|
834
906
|
if let Some(t) = delta["content"].as_str() {
|
|
835
|
-
route_openai_content(
|
|
907
|
+
route_openai_content(
|
|
908
|
+
t,
|
|
909
|
+
&mut in_think,
|
|
910
|
+
&mut think_carry,
|
|
911
|
+
&mut text,
|
|
912
|
+
on_text,
|
|
913
|
+
on_thinking,
|
|
914
|
+
);
|
|
836
915
|
}
|
|
837
916
|
if let Some(tcs) = delta["tool_calls"].as_array() {
|
|
838
917
|
for tc in tcs {
|
|
@@ -858,6 +937,7 @@ fn openai_stream(
|
|
|
858
937
|
}
|
|
859
938
|
false
|
|
860
939
|
})?;
|
|
940
|
+
flush_think_carry(&think_carry, in_think, &mut text, on_text);
|
|
861
941
|
let calls = pending
|
|
862
942
|
.into_iter()
|
|
863
943
|
.filter(|e| !e.1.is_empty())
|
|
@@ -1084,7 +1164,7 @@ fn ollama_call(i: usize, tc: &Value) -> ToolCall {
|
|
|
1084
1164
|
|
|
1085
1165
|
fn ollama_parse(v: Value) -> Result<Reply, String> {
|
|
1086
1166
|
let msg = &v["message"];
|
|
1087
|
-
let text = msg["content"].as_str().unwrap_or_default()
|
|
1167
|
+
let text = strip_think(msg["content"].as_str().unwrap_or_default());
|
|
1088
1168
|
let mut calls = Vec::new();
|
|
1089
1169
|
if let Some(tcs) = msg["tool_calls"].as_array() {
|
|
1090
1170
|
for tc in tcs {
|
|
@@ -1139,6 +1219,7 @@ fn ollama_stream(
|
|
|
1139
1219
|
) -> Result<Reply, String> {
|
|
1140
1220
|
let mut text = String::new();
|
|
1141
1221
|
let mut in_think = false;
|
|
1222
|
+
let mut think_carry = String::new();
|
|
1142
1223
|
let mut stop_reason: Option<String> = None;
|
|
1143
1224
|
let mut stream_err: Option<String> = None;
|
|
1144
1225
|
let mut calls: Vec<ToolCall> = Vec::new();
|
|
@@ -1161,7 +1242,14 @@ fn ollama_stream(
|
|
|
1161
1242
|
}
|
|
1162
1243
|
if let Some(t) = msg["content"].as_str() {
|
|
1163
1244
|
if !t.is_empty() {
|
|
1164
|
-
route_openai_content(
|
|
1245
|
+
route_openai_content(
|
|
1246
|
+
t,
|
|
1247
|
+
&mut in_think,
|
|
1248
|
+
&mut think_carry,
|
|
1249
|
+
&mut text,
|
|
1250
|
+
on_text,
|
|
1251
|
+
on_thinking,
|
|
1252
|
+
);
|
|
1165
1253
|
}
|
|
1166
1254
|
}
|
|
1167
1255
|
// Unlike OpenAI's fragmented deltas, each streamed tool call arrives
|
|
@@ -1180,6 +1268,7 @@ fn ollama_stream(
|
|
|
1180
1268
|
if let Some(e) = stream_err {
|
|
1181
1269
|
return Err(e);
|
|
1182
1270
|
}
|
|
1271
|
+
flush_think_carry(&think_carry, in_think, &mut text, on_text);
|
|
1183
1272
|
Ok(Reply {
|
|
1184
1273
|
text,
|
|
1185
1274
|
calls,
|
|
@@ -1518,6 +1607,75 @@ mod tests {
|
|
|
1518
1607
|
assert_eq!(think_out, "inspect first");
|
|
1519
1608
|
}
|
|
1520
1609
|
|
|
1610
|
+
#[test]
|
|
1611
|
+
fn split_trailing_partial_holds_tag_prefix() {
|
|
1612
|
+
assert_eq!(
|
|
1613
|
+
split_trailing_partial("hello <thi", "<think>"),
|
|
1614
|
+
("hello ", "<thi")
|
|
1615
|
+
);
|
|
1616
|
+
assert_eq!(
|
|
1617
|
+
split_trailing_partial("reason</thi", "</think>"),
|
|
1618
|
+
("reason", "</thi")
|
|
1619
|
+
);
|
|
1620
|
+
// No trailing partial → nothing held.
|
|
1621
|
+
assert_eq!(
|
|
1622
|
+
split_trailing_partial("plain text", "<think>"),
|
|
1623
|
+
("plain text", "")
|
|
1624
|
+
);
|
|
1625
|
+
// A complete tag ends with `>`, not a tag prefix, so nothing is held —
|
|
1626
|
+
// the caller's find() handles complete tags first.
|
|
1627
|
+
assert_eq!(
|
|
1628
|
+
split_trailing_partial("a<think>", "<think>"),
|
|
1629
|
+
("a<think>", "")
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
#[test]
|
|
1634
|
+
fn openai_stream_handles_think_open_tag_split_across_chunks() {
|
|
1635
|
+
// "<think>" is split as "<thi" | "nk>".
|
|
1636
|
+
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hello <thi\"}}]}\n\
|
|
1637
|
+
data: {\"choices\":[{\"delta\":{\"content\":\"nk>reason</think>done\"}}]}\n\
|
|
1638
|
+
data: [DONE]\n";
|
|
1639
|
+
let mut text_out = String::new();
|
|
1640
|
+
let mut think_out = String::new();
|
|
1641
|
+
openai_stream(
|
|
1642
|
+
Cursor::new(sse.as_bytes().to_vec()),
|
|
1643
|
+
&mut |t| text_out.push_str(t),
|
|
1644
|
+
&mut |t| think_out.push_str(t),
|
|
1645
|
+
)
|
|
1646
|
+
.unwrap();
|
|
1647
|
+
assert_eq!(text_out, "hello done");
|
|
1648
|
+
assert_eq!(think_out, "reason");
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
#[test]
|
|
1652
|
+
fn openai_stream_handles_think_close_tag_split_across_chunks() {
|
|
1653
|
+
// "</think>" is split as "</thi" | "nk>".
|
|
1654
|
+
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"<think>reason</thi\"}}]}\n\
|
|
1655
|
+
data: {\"choices\":[{\"delta\":{\"content\":\"nk>done\"}}]}\n\
|
|
1656
|
+
data: [DONE]\n";
|
|
1657
|
+
let mut text_out = String::new();
|
|
1658
|
+
let mut think_out = String::new();
|
|
1659
|
+
openai_stream(
|
|
1660
|
+
Cursor::new(sse.as_bytes().to_vec()),
|
|
1661
|
+
&mut |t| text_out.push_str(t),
|
|
1662
|
+
&mut |t| think_out.push_str(t),
|
|
1663
|
+
)
|
|
1664
|
+
.unwrap();
|
|
1665
|
+
assert_eq!(text_out, "done");
|
|
1666
|
+
assert_eq!(think_out, "reason");
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
#[test]
|
|
1670
|
+
fn openai_stream_flushes_false_partial_at_stream_end() {
|
|
1671
|
+
// Content that merely ends with a tag-prefix and then stops must not be
|
|
1672
|
+
// swallowed — it wasn't a real tag.
|
|
1673
|
+
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"answer<thi\"}}]}\n\
|
|
1674
|
+
data: [DONE]\n";
|
|
1675
|
+
let r = drain_openai(sse);
|
|
1676
|
+
assert_eq!(r.text, "answer<thi");
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1521
1679
|
#[test]
|
|
1522
1680
|
fn openai_stream_accumulates_text_and_stops_on_done() {
|
|
1523
1681
|
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\
|
|
@@ -1744,6 +1902,40 @@ mod tests {
|
|
|
1744
1902
|
assert_eq!(r.calls[0].input, json!({}));
|
|
1745
1903
|
}
|
|
1746
1904
|
|
|
1905
|
+
// ── think-tag stripping ─────────────────────────────────────────────────
|
|
1906
|
+
#[test]
|
|
1907
|
+
fn strip_think_removes_blocks_and_keeps_answer() {
|
|
1908
|
+
assert_eq!(
|
|
1909
|
+
strip_think("<think>let me reason</think>The answer is 42."),
|
|
1910
|
+
"The answer is 42."
|
|
1911
|
+
);
|
|
1912
|
+
// Multiple blocks, with real content between them.
|
|
1913
|
+
assert_eq!(strip_think("<think>a</think>X<think>b</think>Y"), "XY");
|
|
1914
|
+
// No tags → untouched.
|
|
1915
|
+
assert_eq!(strip_think("plain answer"), "plain answer");
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
#[test]
|
|
1919
|
+
fn strip_think_drops_unclosed_reasoning() {
|
|
1920
|
+
// An unterminated <think> means the model never emitted a final answer;
|
|
1921
|
+
// the leaked reasoning must not become the answer.
|
|
1922
|
+
assert_eq!(strip_think("intro <think>reasoning with no close"), "intro");
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
#[test]
|
|
1926
|
+
fn openai_parse_strips_think_from_content() {
|
|
1927
|
+
let v = json!({"choices": [{"message": {"content": "<think>plan</think>done"}}]});
|
|
1928
|
+
let r = openai_parse(v).unwrap();
|
|
1929
|
+
assert_eq!(r.text, "done");
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
#[test]
|
|
1933
|
+
fn ollama_parse_strips_think_from_content() {
|
|
1934
|
+
let v = json!({"message": {"content": "<think>hmm</think>result"}, "done_reason": "stop"});
|
|
1935
|
+
let r = ollama_parse(v).unwrap();
|
|
1936
|
+
assert_eq!(r.text, "result");
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1747
1939
|
// ── stop_reason ─────────────────────────────────────────────────────────
|
|
1748
1940
|
#[test]
|
|
1749
1941
|
fn anthropic_parse_captures_stop_reason() {
|
package/harness/src/tools.rs
CHANGED
|
@@ -1490,14 +1490,8 @@ fn strip_html(html: &str) -> String {
|
|
|
1490
1490
|
i += 1;
|
|
1491
1491
|
}
|
|
1492
1492
|
|
|
1493
|
-
// Collapse whitespace and decode
|
|
1494
|
-
let decoded = out
|
|
1495
|
-
.replace("&", "&")
|
|
1496
|
-
.replace("<", "<")
|
|
1497
|
-
.replace(">", ">")
|
|
1498
|
-
.replace(""", "\"")
|
|
1499
|
-
.replace(" ", " ")
|
|
1500
|
-
.replace("'", "'");
|
|
1493
|
+
// Collapse whitespace and decode HTML entities (named + numeric).
|
|
1494
|
+
let decoded = decode_html_entities(&out);
|
|
1501
1495
|
|
|
1502
1496
|
let mut result = String::new();
|
|
1503
1497
|
let mut prev_ws = true;
|
|
@@ -1515,6 +1509,205 @@ fn strip_html(html: &str) -> String {
|
|
|
1515
1509
|
result.trim().to_string()
|
|
1516
1510
|
}
|
|
1517
1511
|
|
|
1512
|
+
// One parsed web-search result.
|
|
1513
|
+
struct SearchHit {
|
|
1514
|
+
title: String,
|
|
1515
|
+
url: String,
|
|
1516
|
+
snippet: String,
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Percent-decode a URL-encoded string (also treats `+` as space). Invalid
|
|
1520
|
+
// escapes are left as-is. Bytes are reassembled as UTF-8 lossily.
|
|
1521
|
+
fn percent_decode(s: &str) -> String {
|
|
1522
|
+
let bytes = s.as_bytes();
|
|
1523
|
+
let mut out = Vec::with_capacity(bytes.len());
|
|
1524
|
+
let mut i = 0;
|
|
1525
|
+
while i < bytes.len() {
|
|
1526
|
+
match bytes[i] {
|
|
1527
|
+
b'%' if i + 2 < bytes.len() => {
|
|
1528
|
+
if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
|
1529
|
+
out.push(b);
|
|
1530
|
+
i += 3;
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
out.push(b'%');
|
|
1534
|
+
i += 1;
|
|
1535
|
+
}
|
|
1536
|
+
b'+' => {
|
|
1537
|
+
out.push(b' ');
|
|
1538
|
+
i += 1;
|
|
1539
|
+
}
|
|
1540
|
+
b => {
|
|
1541
|
+
out.push(b);
|
|
1542
|
+
i += 1;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
String::from_utf8_lossy(&out).into_owned()
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Resolve a single entity body (the text between `&` and `;`) to its character:
|
|
1550
|
+
// the common named entities plus decimal (`#8217`) and hex (`#x2019`) numeric
|
|
1551
|
+
// character references. Returns None for anything unrecognized.
|
|
1552
|
+
fn entity_char(ent: &str) -> Option<char> {
|
|
1553
|
+
match ent {
|
|
1554
|
+
"amp" => Some('&'),
|
|
1555
|
+
"lt" => Some('<'),
|
|
1556
|
+
"gt" => Some('>'),
|
|
1557
|
+
"quot" => Some('"'),
|
|
1558
|
+
"apos" => Some('\''),
|
|
1559
|
+
"nbsp" => Some(' '),
|
|
1560
|
+
"mdash" => Some('—'),
|
|
1561
|
+
"ndash" => Some('–'),
|
|
1562
|
+
"hellip" => Some('…'),
|
|
1563
|
+
"rsquo" => Some('’'),
|
|
1564
|
+
"lsquo" => Some('‘'),
|
|
1565
|
+
"rdquo" => Some('”'),
|
|
1566
|
+
"ldquo" => Some('“'),
|
|
1567
|
+
_ => {
|
|
1568
|
+
let num = ent.strip_prefix('#')?;
|
|
1569
|
+
let code = match num.strip_prefix('x').or_else(|| num.strip_prefix('X')) {
|
|
1570
|
+
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
|
|
1571
|
+
None => num.parse::<u32>().ok()?,
|
|
1572
|
+
};
|
|
1573
|
+
char::from_u32(code)
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Decode HTML entities in a single left-to-right pass — named entities and
|
|
1579
|
+
// decimal/hex numeric character references. Single-pass so `&lt;` decodes to
|
|
1580
|
+
// the literal `<` (not `<`), and an unrecognized `&…;` is left untouched.
|
|
1581
|
+
fn decode_html_entities(s: &str) -> String {
|
|
1582
|
+
if !s.contains('&') {
|
|
1583
|
+
return s.to_string();
|
|
1584
|
+
}
|
|
1585
|
+
let mut out = String::with_capacity(s.len());
|
|
1586
|
+
let mut rest = s;
|
|
1587
|
+
while let Some(amp) = rest.find('&') {
|
|
1588
|
+
out.push_str(&rest[..amp]);
|
|
1589
|
+
let tail = &rest[amp..];
|
|
1590
|
+
// `&` and `;` are ASCII, so `semi` is a valid char boundary; the ≤12
|
|
1591
|
+
// bound keeps a lone `&` in prose from swallowing a distant `;`.
|
|
1592
|
+
if let Some(semi) = tail.find(';') {
|
|
1593
|
+
if semi <= 12 {
|
|
1594
|
+
if let Some(ch) = entity_char(&tail[1..semi]) {
|
|
1595
|
+
out.push(ch);
|
|
1596
|
+
rest = &tail[semi + 1..];
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
out.push('&');
|
|
1602
|
+
rest = &tail[1..];
|
|
1603
|
+
}
|
|
1604
|
+
out.push_str(rest);
|
|
1605
|
+
out
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// Read attribute `attr` from the tag that opens at byte index `tag_start`,
|
|
1609
|
+
// scanning only within that tag (up to its closing `>`).
|
|
1610
|
+
fn tag_attr(html: &str, tag_start: usize, attr: &str) -> Option<String> {
|
|
1611
|
+
let end = html[tag_start..].find('>').map(|e| tag_start + e)?;
|
|
1612
|
+
let seg = &html[tag_start..end];
|
|
1613
|
+
let key = format!("{attr}=");
|
|
1614
|
+
let after = &seg[seg.find(&key)? + key.len()..];
|
|
1615
|
+
let quote = after.chars().next()?;
|
|
1616
|
+
if quote == '"' || quote == '\'' {
|
|
1617
|
+
let rest = &after[1..];
|
|
1618
|
+
rest.find(quote).map(|c| rest[..c].to_string())
|
|
1619
|
+
} else {
|
|
1620
|
+
let endv = after.find(char::is_whitespace).unwrap_or(after.len());
|
|
1621
|
+
Some(after[..endv].to_string())
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// DuckDuckGo Lite wraps result links in a redirect: `//duckduckgo.com/l/?uddg=
|
|
1626
|
+
// <percent-encoded-target>&rut=…`. Recover the real destination; fall back to
|
|
1627
|
+
// normalizing a protocol-relative href.
|
|
1628
|
+
fn ddg_real_url(href: &str) -> String {
|
|
1629
|
+
let href = href.replace("&", "&");
|
|
1630
|
+
if let Some(p) = href.find("uddg=") {
|
|
1631
|
+
let rest = &href[p + "uddg=".len()..];
|
|
1632
|
+
let end = rest.find('&').unwrap_or(rest.len());
|
|
1633
|
+
return percent_decode(&rest[..end]);
|
|
1634
|
+
}
|
|
1635
|
+
if let Some(stripped) = href.strip_prefix("//") {
|
|
1636
|
+
return format!("https://{stripped}");
|
|
1637
|
+
}
|
|
1638
|
+
href.to_string()
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// Parse DuckDuckGo Lite result rows into structured hits. Returns empty if the
|
|
1642
|
+
// markup doesn't match (the caller then falls back to a stripped-text blob).
|
|
1643
|
+
fn parse_ddg_lite(html: &str) -> Vec<SearchHit> {
|
|
1644
|
+
let mut titles: Vec<(String, String)> = Vec::new();
|
|
1645
|
+
let mut i = 0;
|
|
1646
|
+
while let Some(rel) = html[i..].find("<a ") {
|
|
1647
|
+
let tag_start = i + rel;
|
|
1648
|
+
let Some(gt) = html[tag_start..].find('>').map(|e| tag_start + e) else {
|
|
1649
|
+
break;
|
|
1650
|
+
};
|
|
1651
|
+
if html[tag_start..gt].contains("result-link") {
|
|
1652
|
+
let url = ddg_real_url(&tag_attr(html, tag_start, "href").unwrap_or_default());
|
|
1653
|
+
let after = &html[gt + 1..];
|
|
1654
|
+
if let Some(close) = after.to_lowercase().find("</a") {
|
|
1655
|
+
let title = decode_html_entities(&strip_html(&after[..close]))
|
|
1656
|
+
.trim()
|
|
1657
|
+
.to_string();
|
|
1658
|
+
if !title.is_empty() {
|
|
1659
|
+
titles.push((title, url));
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
i = gt + 1;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
let mut snippets: Vec<String> = Vec::new();
|
|
1667
|
+
let mut j = 0;
|
|
1668
|
+
while let Some(rel) = html[j..].find("result-snippet") {
|
|
1669
|
+
let pos = j + rel;
|
|
1670
|
+
if let Some(gt) = html[pos..].find('>') {
|
|
1671
|
+
let start = pos + gt + 1;
|
|
1672
|
+
if let Some(lt) = html[start..].find('<') {
|
|
1673
|
+
snippets.push(
|
|
1674
|
+
decode_html_entities(&strip_html(&html[start..start + lt]))
|
|
1675
|
+
.trim()
|
|
1676
|
+
.to_string(),
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
j = pos + "result-snippet".len();
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
titles
|
|
1684
|
+
.into_iter()
|
|
1685
|
+
.enumerate()
|
|
1686
|
+
.map(|(k, (title, url))| SearchHit {
|
|
1687
|
+
title,
|
|
1688
|
+
url,
|
|
1689
|
+
snippet: snippets.get(k).cloned().unwrap_or_default(),
|
|
1690
|
+
})
|
|
1691
|
+
.collect()
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Render parsed hits as compact, numbered `title / url / snippet` blocks that a
|
|
1695
|
+
// small model can read, capped at `max` results.
|
|
1696
|
+
fn format_search_hits(hits: &[SearchHit], max: usize) -> String {
|
|
1697
|
+
let mut out = String::new();
|
|
1698
|
+
for (n, h) in hits.iter().take(max).enumerate() {
|
|
1699
|
+
out.push_str(&format!("{}. {}\n", n + 1, h.title));
|
|
1700
|
+
if !h.url.is_empty() {
|
|
1701
|
+
out.push_str(&format!(" {}\n", h.url));
|
|
1702
|
+
}
|
|
1703
|
+
if !h.snippet.is_empty() {
|
|
1704
|
+
out.push_str(&format!(" {}\n", h.snippet));
|
|
1705
|
+
}
|
|
1706
|
+
out.push('\n');
|
|
1707
|
+
}
|
|
1708
|
+
out.trim_end().to_string()
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1518
1711
|
// Commands so destructive they require confirmation in every mode.
|
|
1519
1712
|
pub fn catastrophic(cmd: &str) -> bool {
|
|
1520
1713
|
let lower = cmd.to_lowercase();
|
|
@@ -1861,13 +2054,170 @@ fn xml_escape(s: &str) -> String {
|
|
|
1861
2054
|
}
|
|
1862
2055
|
|
|
1863
2056
|
fn docx_paragraph(text: &str, style: Option<&str>) -> String {
|
|
1864
|
-
let
|
|
2057
|
+
let ppr = style
|
|
1865
2058
|
.map(|s| format!("<w:pPr><w:pStyle w:val=\"{s}\"/></w:pPr>"))
|
|
1866
2059
|
.unwrap_or_default();
|
|
1867
|
-
format!(
|
|
1868
|
-
|
|
2060
|
+
format!("<w:p>{ppr}{}</w:p>", docx_runs(text))
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// Emit one Word run for `text` with the active inline formatting. Empty text
|
|
2064
|
+
// produces nothing (an all-empty paragraph is still valid OOXML).
|
|
2065
|
+
fn docx_push_run(out: &mut String, text: &str, bold: bool, italic: bool, code: bool) {
|
|
2066
|
+
if text.is_empty() {
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
let mut rpr = String::new();
|
|
2070
|
+
if bold {
|
|
2071
|
+
rpr.push_str("<w:b/>");
|
|
2072
|
+
}
|
|
2073
|
+
if italic {
|
|
2074
|
+
rpr.push_str("<w:i/>");
|
|
2075
|
+
}
|
|
2076
|
+
if code {
|
|
2077
|
+
rpr.push_str("<w:rFonts w:ascii=\"Consolas\" w:hAnsi=\"Consolas\" w:cs=\"Consolas\"/>");
|
|
2078
|
+
}
|
|
2079
|
+
if !rpr.is_empty() {
|
|
2080
|
+
rpr = format!("<w:rPr>{rpr}</w:rPr>");
|
|
2081
|
+
}
|
|
2082
|
+
out.push_str("<w:r>");
|
|
2083
|
+
out.push_str(&rpr);
|
|
2084
|
+
out.push_str(&format!(
|
|
2085
|
+
"<w:t xml:space=\"preserve\">{}</w:t></w:r>",
|
|
1869
2086
|
xml_escape(text)
|
|
1870
|
-
)
|
|
2087
|
+
));
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// Light-markdown inline formatting → Word runs: `**bold**`, `*italic*`, and
|
|
2091
|
+
// `` `code` `` (verbatim). Emphasis markers only toggle when they flank
|
|
2092
|
+
// non-space text, so arithmetic like `5 * 3` and glob patterns stay literal;
|
|
2093
|
+
// underscores are deliberately left alone so `snake_case` isn't italicized.
|
|
2094
|
+
fn docx_runs(text: &str) -> String {
|
|
2095
|
+
let chars: Vec<char> = text.chars().collect();
|
|
2096
|
+
let n = chars.len();
|
|
2097
|
+
let mut out = String::new();
|
|
2098
|
+
let mut buf = String::new();
|
|
2099
|
+
let (mut bold, mut italic, mut code) = (false, false, false);
|
|
2100
|
+
let mut i = 0;
|
|
2101
|
+
while i < n {
|
|
2102
|
+
let c = chars[i];
|
|
2103
|
+
// Inside a code span everything is literal until the closing backtick.
|
|
2104
|
+
if code {
|
|
2105
|
+
if c == '`' {
|
|
2106
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2107
|
+
buf.clear();
|
|
2108
|
+
code = false;
|
|
2109
|
+
} else {
|
|
2110
|
+
buf.push(c);
|
|
2111
|
+
}
|
|
2112
|
+
i += 1;
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
match c {
|
|
2116
|
+
'`' => {
|
|
2117
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2118
|
+
buf.clear();
|
|
2119
|
+
code = true;
|
|
2120
|
+
i += 1;
|
|
2121
|
+
}
|
|
2122
|
+
'*' if i + 1 < n && chars[i + 1] == '*' => {
|
|
2123
|
+
// Opening `**` must precede non-space; closing must follow it.
|
|
2124
|
+
let flanks = if bold {
|
|
2125
|
+
i > 0 && !chars[i - 1].is_whitespace()
|
|
2126
|
+
} else {
|
|
2127
|
+
i + 2 < n && !chars[i + 2].is_whitespace()
|
|
2128
|
+
};
|
|
2129
|
+
if flanks {
|
|
2130
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2131
|
+
buf.clear();
|
|
2132
|
+
bold = !bold;
|
|
2133
|
+
} else {
|
|
2134
|
+
buf.push('*');
|
|
2135
|
+
buf.push('*');
|
|
2136
|
+
}
|
|
2137
|
+
i += 2;
|
|
2138
|
+
}
|
|
2139
|
+
'*' => {
|
|
2140
|
+
let flanks = if italic {
|
|
2141
|
+
i > 0 && !chars[i - 1].is_whitespace()
|
|
2142
|
+
} else {
|
|
2143
|
+
i + 1 < n && !chars[i + 1].is_whitespace()
|
|
2144
|
+
};
|
|
2145
|
+
if flanks {
|
|
2146
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2147
|
+
buf.clear();
|
|
2148
|
+
italic = !italic;
|
|
2149
|
+
} else {
|
|
2150
|
+
buf.push('*');
|
|
2151
|
+
}
|
|
2152
|
+
i += 1;
|
|
2153
|
+
}
|
|
2154
|
+
_ => {
|
|
2155
|
+
buf.push(c);
|
|
2156
|
+
i += 1;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
docx_push_run(&mut out, &buf, bold, italic, code);
|
|
2161
|
+
out
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// Split a markdown table line into trimmed cells, dropping the outer pipes.
|
|
2165
|
+
fn parse_table_cells(line: &str) -> Vec<String> {
|
|
2166
|
+
let t = line.trim();
|
|
2167
|
+
let t = t.strip_prefix('|').unwrap_or(t);
|
|
2168
|
+
let t = t.strip_suffix('|').unwrap_or(t);
|
|
2169
|
+
t.split('|').map(|c| c.trim().to_string()).collect()
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// A markdown table row: trimmed, starts with `|`, and has at least two pipes.
|
|
2173
|
+
fn is_table_row(line: &str) -> bool {
|
|
2174
|
+
let t = line.trim();
|
|
2175
|
+
t.starts_with('|') && t.matches('|').count() >= 2
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// The `|---|:--:|` alignment row: every cell is only dashes/colons.
|
|
2179
|
+
fn is_table_separator(line: &str) -> bool {
|
|
2180
|
+
let cells = parse_table_cells(line);
|
|
2181
|
+
!cells.is_empty()
|
|
2182
|
+
&& cells
|
|
2183
|
+
.iter()
|
|
2184
|
+
.all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':'))
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// One table cell. Header cells are a single bold run; body cells get full
|
|
2188
|
+
// inline markdown formatting.
|
|
2189
|
+
fn docx_table_cell(text: &str, header: bool) -> String {
|
|
2190
|
+
let runs = if header {
|
|
2191
|
+
let mut s = String::new();
|
|
2192
|
+
docx_push_run(&mut s, text, true, false, false);
|
|
2193
|
+
s
|
|
2194
|
+
} else {
|
|
2195
|
+
docx_runs(text)
|
|
2196
|
+
};
|
|
2197
|
+
format!("<w:tc><w:tcPr><w:tcW w:w=\"0\" w:type=\"auto\"/></w:tcPr><w:p>{runs}</w:p></w:tc>")
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// A bordered Word table. The first row is treated as the (bold) header.
|
|
2201
|
+
fn docx_table(rows: &[Vec<String>]) -> String {
|
|
2202
|
+
let mut out = String::from(
|
|
2203
|
+
"<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>\
|
|
2204
|
+
<w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2205
|
+
<w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2206
|
+
<w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2207
|
+
<w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2208
|
+
<w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2209
|
+
<w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
|
|
2210
|
+
</w:tblBorders></w:tblPr>",
|
|
2211
|
+
);
|
|
2212
|
+
for (r, row) in rows.iter().enumerate() {
|
|
2213
|
+
out.push_str("<w:tr>");
|
|
2214
|
+
for cell in row {
|
|
2215
|
+
out.push_str(&docx_table_cell(cell, r == 0));
|
|
2216
|
+
}
|
|
2217
|
+
out.push_str("</w:tr>");
|
|
2218
|
+
}
|
|
2219
|
+
out.push_str("</w:tbl>");
|
|
2220
|
+
out
|
|
1871
2221
|
}
|
|
1872
2222
|
|
|
1873
2223
|
fn docx_document(title: &str, body: &str) -> String {
|
|
@@ -1875,8 +2225,25 @@ fn docx_document(title: &str, body: &str) -> String {
|
|
|
1875
2225
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>"#,
|
|
1876
2226
|
);
|
|
1877
2227
|
out.push_str(&docx_paragraph(title, Some("Title")));
|
|
1878
|
-
|
|
1879
|
-
|
|
2228
|
+
let lines: Vec<&str> = body.lines().collect();
|
|
2229
|
+
let mut idx = 0;
|
|
2230
|
+
while idx < lines.len() {
|
|
2231
|
+
// A run of consecutive `|`-delimited rows becomes one Word table; the
|
|
2232
|
+
// alignment separator row is dropped and the first row is the header.
|
|
2233
|
+
if is_table_row(lines[idx]) {
|
|
2234
|
+
let mut rows: Vec<Vec<String>> = Vec::new();
|
|
2235
|
+
while idx < lines.len() && is_table_row(lines[idx]) {
|
|
2236
|
+
if !is_table_separator(lines[idx]) {
|
|
2237
|
+
rows.push(parse_table_cells(lines[idx]));
|
|
2238
|
+
}
|
|
2239
|
+
idx += 1;
|
|
2240
|
+
}
|
|
2241
|
+
if !rows.is_empty() {
|
|
2242
|
+
out.push_str(&docx_table(&rows));
|
|
2243
|
+
}
|
|
2244
|
+
continue;
|
|
2245
|
+
}
|
|
2246
|
+
let trimmed = lines[idx].trim();
|
|
1880
2247
|
if trimmed.is_empty() {
|
|
1881
2248
|
out.push_str("<w:p/>");
|
|
1882
2249
|
} else if let Some(h) = trimmed.strip_prefix("### ") {
|
|
@@ -1890,6 +2257,7 @@ fn docx_document(title: &str, body: &str) -> String {
|
|
|
1890
2257
|
} else {
|
|
1891
2258
|
out.push_str(&docx_paragraph(trimmed, None));
|
|
1892
2259
|
}
|
|
2260
|
+
idx += 1;
|
|
1893
2261
|
}
|
|
1894
2262
|
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
2263
|
out
|
|
@@ -2983,7 +3351,20 @@ pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
|
|
|
2983
3351
|
.set("User-Agent", "Mozilla/5.0 (compatible; buildwithnexus/1.0)"),
|
|
2984
3352
|
) {
|
|
2985
3353
|
Ok(resp) => match resp.into_string() {
|
|
2986
|
-
Ok(html) =>
|
|
3354
|
+
Ok(html) => {
|
|
3355
|
+
let hits = parse_ddg_lite(&html);
|
|
3356
|
+
if hits.is_empty() {
|
|
3357
|
+
// Markup didn't match — fall back to the raw text so
|
|
3358
|
+
// the model still gets something.
|
|
3359
|
+
ok(truncate(strip_html(&html), MAX_OUT))
|
|
3360
|
+
} else {
|
|
3361
|
+
let body = format_search_hits(&hits, 10);
|
|
3362
|
+
ok(truncate(
|
|
3363
|
+
format!("{} results for \"{query}\":\n\n{body}", hits.len()),
|
|
3364
|
+
MAX_OUT,
|
|
3365
|
+
))
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
2987
3368
|
Err(e) => err(format!("search response error: {e}")),
|
|
2988
3369
|
},
|
|
2989
3370
|
Err(e) => err(format!("web search failed: {e}")),
|
|
@@ -3802,6 +4183,178 @@ mod tests {
|
|
|
3802
4183
|
use super::*;
|
|
3803
4184
|
use std::path::PathBuf;
|
|
3804
4185
|
|
|
4186
|
+
// ── web search parsing ──────────────────────────────────────────────────
|
|
4187
|
+
const DDG_LITE_SAMPLE: &str = r#"<html><body><form>search</form><table>
|
|
4188
|
+
<tr><td>1. </td><td>
|
|
4189
|
+
<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>
|
|
4190
|
+
</td></tr>
|
|
4191
|
+
<tr><td> </td><td class='result-snippet'>The Rust Standard Library & API reference.</td></tr>
|
|
4192
|
+
<tr><td>2. </td><td>
|
|
4193
|
+
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io%2F&rut=def" class='result-link'>crates.io</a>
|
|
4194
|
+
</td></tr>
|
|
4195
|
+
<tr><td> </td><td class='result-snippet'>The Rust package registry.</td></tr>
|
|
4196
|
+
</table></body></html>"#;
|
|
4197
|
+
|
|
4198
|
+
#[test]
|
|
4199
|
+
fn percent_decode_handles_escapes_and_plus() {
|
|
4200
|
+
assert_eq!(percent_decode("a%2Fb%20c+d"), "a/b c d");
|
|
4201
|
+
// A malformed trailing escape is left literal, not dropped.
|
|
4202
|
+
assert_eq!(percent_decode("100%"), "100%");
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
#[test]
|
|
4206
|
+
fn decode_html_entities_single_pass_no_double_decode() {
|
|
4207
|
+
// Single pass: & becomes a literal & and the following "lt;" is left
|
|
4208
|
+
// alone, so this must not collapse to "<".
|
|
4209
|
+
assert_eq!(decode_html_entities("a &lt; b"), "a < b");
|
|
4210
|
+
assert_eq!(
|
|
4211
|
+
decode_html_entities("x <y> "z""),
|
|
4212
|
+
"x <y> \"z\""
|
|
4213
|
+
);
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
#[test]
|
|
4217
|
+
fn decode_html_entities_resolves_numeric_and_typographic() {
|
|
4218
|
+
// Decimal and hex numeric references for a curly apostrophe.
|
|
4219
|
+
assert_eq!(decode_html_entities("it’s"), "it’s");
|
|
4220
|
+
assert_eq!(decode_html_entities("it’s"), "it’s");
|
|
4221
|
+
// Common typographic named entities.
|
|
4222
|
+
assert_eq!(decode_html_entities("a — b …"), "a — b …");
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
#[test]
|
|
4226
|
+
fn decode_html_entities_leaves_unknown_and_bare_amp_literal() {
|
|
4227
|
+
assert_eq!(decode_html_entities("Tom & Jerry"), "Tom & Jerry");
|
|
4228
|
+
assert_eq!(decode_html_entities("¬real;"), "¬real;");
|
|
4229
|
+
// A `&` with no nearby `;` is untouched.
|
|
4230
|
+
assert_eq!(
|
|
4231
|
+
decode_html_entities("cats & dogs everywhere"),
|
|
4232
|
+
"cats & dogs everywhere"
|
|
4233
|
+
);
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
#[test]
|
|
4237
|
+
fn ddg_real_url_recovers_target_from_redirect() {
|
|
4238
|
+
assert_eq!(
|
|
4239
|
+
ddg_real_url("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=z"),
|
|
4240
|
+
"https://example.com/a"
|
|
4241
|
+
);
|
|
4242
|
+
assert_eq!(ddg_real_url("//example.com/x"), "https://example.com/x");
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
#[test]
|
|
4246
|
+
fn parse_ddg_lite_extracts_structured_hits() {
|
|
4247
|
+
let hits = parse_ddg_lite(DDG_LITE_SAMPLE);
|
|
4248
|
+
assert_eq!(hits.len(), 2);
|
|
4249
|
+
assert_eq!(hits[0].title, "Rust std docs");
|
|
4250
|
+
assert_eq!(hits[0].url, "https://doc.rust-lang.org/std/");
|
|
4251
|
+
assert_eq!(
|
|
4252
|
+
hits[0].snippet,
|
|
4253
|
+
"The Rust Standard Library & API reference."
|
|
4254
|
+
);
|
|
4255
|
+
assert_eq!(hits[1].title, "crates.io");
|
|
4256
|
+
assert_eq!(hits[1].url, "https://crates.io/");
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
#[test]
|
|
4260
|
+
fn parse_ddg_lite_returns_empty_on_unrelated_html() {
|
|
4261
|
+
assert!(parse_ddg_lite("<html><body>no results here</body></html>").is_empty());
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
#[test]
|
|
4265
|
+
fn format_search_hits_numbers_and_caps() {
|
|
4266
|
+
let hits = parse_ddg_lite(DDG_LITE_SAMPLE);
|
|
4267
|
+
let out = format_search_hits(&hits, 1);
|
|
4268
|
+
assert!(out.starts_with("1. Rust std docs"));
|
|
4269
|
+
assert!(out.contains("https://doc.rust-lang.org/std/"));
|
|
4270
|
+
// Capped at 1 — the second hit must not appear.
|
|
4271
|
+
assert!(!out.contains("crates.io"));
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
// ── docx tables ─────────────────────────────────────────────────────────
|
|
4275
|
+
#[test]
|
|
4276
|
+
fn table_row_and_separator_detection() {
|
|
4277
|
+
assert!(is_table_row("| a | b |"));
|
|
4278
|
+
assert!(is_table_row("|a|b|"));
|
|
4279
|
+
assert!(!is_table_row("a | b")); // no leading pipe
|
|
4280
|
+
assert!(!is_table_row("- bullet"));
|
|
4281
|
+
assert!(is_table_separator("| --- | :---: |"));
|
|
4282
|
+
assert!(!is_table_separator("| a | b |"));
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
#[test]
|
|
4286
|
+
fn parse_table_cells_drops_outer_pipes_and_trims() {
|
|
4287
|
+
assert_eq!(parse_table_cells("| a | b |c|"), vec!["a", "b", "c"]);
|
|
4288
|
+
}
|
|
4289
|
+
|
|
4290
|
+
#[test]
|
|
4291
|
+
fn docx_document_renders_markdown_table() {
|
|
4292
|
+
let body = "Intro line.\n| Name | Price |\n| --- | --- |\n| Glazed | $1.50 |\n| Old Fashioned | $2.00 |\n\nOutro.";
|
|
4293
|
+
let doc = docx_document("Menu", body);
|
|
4294
|
+
// A real table with a header row is emitted.
|
|
4295
|
+
assert!(doc.contains("<w:tbl>"));
|
|
4296
|
+
assert_eq!(doc.matches("<w:tr>").count(), 3); // header + 2 body rows
|
|
4297
|
+
// Header cells are bold; the separator row is gone.
|
|
4298
|
+
assert!(doc.contains("<w:rPr><w:b/></w:rPr><w:t xml:space=\"preserve\">Name</w:t>"));
|
|
4299
|
+
assert!(!doc.contains("---"));
|
|
4300
|
+
// Surrounding prose still renders as paragraphs.
|
|
4301
|
+
assert!(doc.contains(">Intro line.</w:t>"));
|
|
4302
|
+
assert!(doc.contains(">Outro.</w:t>"));
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
#[test]
|
|
4306
|
+
fn docx_document_without_table_is_unchanged_shape() {
|
|
4307
|
+
let doc = docx_document("T", "# Head\n- item\nplain");
|
|
4308
|
+
assert!(!doc.contains("<w:tbl>"));
|
|
4309
|
+
assert!(doc.contains(">• item</w:t>"));
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
// ── docx inline formatting ──────────────────────────────────────────────
|
|
4313
|
+
#[test]
|
|
4314
|
+
fn docx_runs_renders_bold_italic_and_code() {
|
|
4315
|
+
let out = docx_runs("plain **bold** and *italic* and `code` end");
|
|
4316
|
+
// Bold run carries <w:b/>, italic <w:i/>, code the monospace font.
|
|
4317
|
+
assert!(out.contains("<w:rPr><w:b/></w:rPr><w:t xml:space=\"preserve\">bold</w:t>"));
|
|
4318
|
+
assert!(out.contains("<w:rPr><w:i/></w:rPr><w:t xml:space=\"preserve\">italic</w:t>"));
|
|
4319
|
+
assert!(out.contains("w:ascii=\"Consolas\""));
|
|
4320
|
+
assert!(out.contains(">code</w:t>"));
|
|
4321
|
+
// The markers themselves are consumed, not emitted as literal text.
|
|
4322
|
+
assert!(!out.contains('*'));
|
|
4323
|
+
assert!(!out.contains('`'));
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
#[test]
|
|
4327
|
+
fn docx_runs_leaves_arithmetic_and_snake_case_literal() {
|
|
4328
|
+
// Spaced `*` is not emphasis; `_` never toggles italic.
|
|
4329
|
+
let out = docx_runs("compute 5 * 3 for my_var_name");
|
|
4330
|
+
assert!(!out.contains("<w:i/>"));
|
|
4331
|
+
assert!(!out.contains("<w:b/>"));
|
|
4332
|
+
assert!(out.contains("5 * 3 for my_var_name"));
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
#[test]
|
|
4336
|
+
fn docx_runs_code_span_is_verbatim() {
|
|
4337
|
+
// Emphasis markers inside a code span stay literal.
|
|
4338
|
+
let out = docx_runs("call `a*b` now");
|
|
4339
|
+
assert!(out.contains(">a*b</w:t>"));
|
|
4340
|
+
assert!(!out.contains("<w:i/>"));
|
|
4341
|
+
}
|
|
4342
|
+
|
|
4343
|
+
#[test]
|
|
4344
|
+
fn docx_runs_escapes_xml_in_runs() {
|
|
4345
|
+
let out = docx_runs("**a < b & c**");
|
|
4346
|
+
assert!(out.contains("a < b & c"));
|
|
4347
|
+
assert!(out.contains("<w:b/>"));
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4350
|
+
#[test]
|
|
4351
|
+
fn docx_runs_unmatched_marker_stays_literal() {
|
|
4352
|
+
// A lone trailing `**` with nothing after it must not open emphasis.
|
|
4353
|
+
let out = docx_runs("trailing **");
|
|
4354
|
+
assert!(!out.contains("<w:b/>"));
|
|
4355
|
+
assert!(out.contains("trailing **"));
|
|
4356
|
+
}
|
|
4357
|
+
|
|
3805
4358
|
// ── truncate ────────────────────────────────────────────────────────────
|
|
3806
4359
|
#[test]
|
|
3807
4360
|
fn truncate_shorter_than_max_unchanged() {
|
package/harness/src/tui.rs
CHANGED
|
@@ -1074,39 +1074,89 @@ fn format_links(s: &str) -> String {
|
|
|
1074
1074
|
out
|
|
1075
1075
|
}
|
|
1076
1076
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1077
|
+
// Flush the pending plain-text run to `out`, wrapping it in the styles active
|
|
1078
|
+
// when it was collected. Italic is applied inside bold so both can nest.
|
|
1079
|
+
fn flush_styled_run(out: &mut String, buf: &mut String, bold_on: bool, italic_on: bool) {
|
|
1080
|
+
if buf.is_empty() {
|
|
1081
|
+
return;
|
|
1080
1082
|
}
|
|
1081
|
-
let mut
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
if i > 0 && is_italic {
|
|
1085
|
-
out.push_str(&italic(part));
|
|
1086
|
-
} else {
|
|
1087
|
-
out.push_str(part);
|
|
1088
|
-
}
|
|
1089
|
-
is_italic = !is_italic;
|
|
1083
|
+
let mut s = std::mem::take(buf);
|
|
1084
|
+
if italic_on {
|
|
1085
|
+
s = italic(&s);
|
|
1090
1086
|
}
|
|
1091
|
-
|
|
1087
|
+
if bold_on {
|
|
1088
|
+
s = bold(&s);
|
|
1089
|
+
}
|
|
1090
|
+
out.push_str(&s);
|
|
1092
1091
|
}
|
|
1093
1092
|
|
|
1093
|
+
// Render inline Markdown (`` `code` ``, `**bold**`, `*italic*`) into ANSI in a
|
|
1094
|
+
// single left-to-right pass. A marker only opens a style when a matching closer
|
|
1095
|
+
// exists ahead and it isn't followed by whitespace, so an unmatched `` ` ``,
|
|
1096
|
+
// `**`, or `*` — including arithmetic like `5 * 3` — stays literal instead of
|
|
1097
|
+
// styling the rest of the line. Links are resolved first by `format_links`.
|
|
1094
1098
|
fn format_inline_md(text: &str) -> String {
|
|
1095
|
-
let
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1099
|
+
let linked = format_links(text);
|
|
1100
|
+
let chars: Vec<char> = linked.chars().collect();
|
|
1101
|
+
let n = chars.len();
|
|
1102
|
+
let mut out = String::with_capacity(linked.len() + 32);
|
|
1103
|
+
let mut buf = String::new();
|
|
1104
|
+
let (mut bold_on, mut italic_on) = (false, false);
|
|
1105
|
+
let mut i = 0;
|
|
1106
|
+
while i < n {
|
|
1107
|
+
let c = chars[i];
|
|
1108
|
+
if c == '`' {
|
|
1109
|
+
// Code span: style only if there's a closing backtick ahead.
|
|
1110
|
+
if let Some(rel) = chars[i + 1..].iter().position(|&x| x == '`') {
|
|
1111
|
+
flush_styled_run(&mut out, &mut buf, bold_on, italic_on);
|
|
1112
|
+
let code: String = chars[i + 1..i + 1 + rel].iter().collect();
|
|
1113
|
+
out.push_str(&yellow(&code));
|
|
1114
|
+
i += rel + 2;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
buf.push('`');
|
|
1118
|
+
i += 1;
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if c == '*' && i + 1 < n && chars[i + 1] == '*' {
|
|
1122
|
+
let toggles = if bold_on {
|
|
1123
|
+
i > 0 && !chars[i - 1].is_whitespace()
|
|
1124
|
+
} else {
|
|
1125
|
+
i + 2 < n
|
|
1126
|
+
&& !chars[i + 2].is_whitespace()
|
|
1127
|
+
&& chars[i + 2..]
|
|
1128
|
+
.windows(2)
|
|
1129
|
+
.any(|w| w[0] == '*' && w[1] == '*')
|
|
1130
|
+
};
|
|
1131
|
+
if toggles {
|
|
1132
|
+
flush_styled_run(&mut out, &mut buf, bold_on, italic_on);
|
|
1133
|
+
bold_on = !bold_on;
|
|
1134
|
+
} else {
|
|
1135
|
+
buf.push('*');
|
|
1136
|
+
buf.push('*');
|
|
1137
|
+
}
|
|
1138
|
+
i += 2;
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
if c == '*' {
|
|
1142
|
+
let toggles = if italic_on {
|
|
1143
|
+
i > 0 && !chars[i - 1].is_whitespace()
|
|
1144
|
+
} else {
|
|
1145
|
+
i + 1 < n && !chars[i + 1].is_whitespace() && chars[i + 1..].contains(&'*')
|
|
1146
|
+
};
|
|
1147
|
+
if toggles {
|
|
1148
|
+
flush_styled_run(&mut out, &mut buf, bold_on, italic_on);
|
|
1149
|
+
italic_on = !italic_on;
|
|
1150
|
+
} else {
|
|
1151
|
+
buf.push('*');
|
|
1107
1152
|
}
|
|
1153
|
+
i += 1;
|
|
1154
|
+
continue;
|
|
1108
1155
|
}
|
|
1156
|
+
buf.push(c);
|
|
1157
|
+
i += 1;
|
|
1109
1158
|
}
|
|
1159
|
+
flush_styled_run(&mut out, &mut buf, bold_on, italic_on);
|
|
1110
1160
|
out
|
|
1111
1161
|
}
|
|
1112
1162
|
|
|
@@ -3591,6 +3641,25 @@ mod tests {
|
|
|
3591
3641
|
assert_eq!(super::end_word(&b, 10), 14);
|
|
3592
3642
|
}
|
|
3593
3643
|
|
|
3644
|
+
#[test]
|
|
3645
|
+
fn inline_md_unbalanced_markers_stay_literal() {
|
|
3646
|
+
// A single `*` (arithmetic), a lone backtick, and an unmatched `**`
|
|
3647
|
+
// must not style the rest of the line — they render verbatim.
|
|
3648
|
+
assert_eq!(format_inline_md("5 * 3 = 15"), "5 * 3 = 15");
|
|
3649
|
+
assert_eq!(format_inline_md("call `foo now"), "call `foo now");
|
|
3650
|
+
assert_eq!(format_inline_md("a ** b"), "a ** b");
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
#[test]
|
|
3654
|
+
fn inline_md_balanced_markers_consume_delimiters() {
|
|
3655
|
+
// Balanced emphasis/code: the markers are consumed and the inner text
|
|
3656
|
+
// preserved (checked after stripping ANSI, so this holds in any color
|
|
3657
|
+
// mode).
|
|
3658
|
+
assert_eq!(plain(&format_inline_md("a *word* b")), "a word b");
|
|
3659
|
+
assert_eq!(plain(&format_inline_md("a **bold** c")), "a bold c");
|
|
3660
|
+
assert_eq!(plain(&format_inline_md("use `code` here")), "use code here");
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3594
3663
|
#[test]
|
|
3595
3664
|
fn render_md_draws_fenced_code_blocks() {
|
|
3596
3665
|
let doc = "before\n```rust\nlet x = 1;\n```\nafter";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildwithnexus",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"description": "A hilariously fast, agentic AI CLI harness in Rust — remote (API key) or local models, full-screen TUI with sessions, plan/build/brainstorm modes, and hooks",
|
|
5
5
|
"bin": {
|
|
6
6
|
"buildwithnexus": "bin/buildwithnexus.js",
|