1#![forbid(unsafe_code)]
16
17use async_trait::async_trait;
18
19use serde_json::{Value, json};
20use std::collections::HashMap;
21use std::sync::Arc;
22use std::sync::Mutex;
23use std::time::Duration;
24use wm_cognitive::{EventType, GanYingBus};
25use wm_core::security::is_url_safe;
26use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
27use wm_memory::{Memory, MemoryStore};
28
29use super::web::{Fetched, fetch_bounded, web_search};
30
31const STOPWORDS: &[&str] = &[
32 "the",
33 "a",
34 "an",
35 "and",
36 "or",
37 "but",
38 "for",
39 "with",
40 "of",
41 "in",
42 "on",
43 "at",
44 "to",
45 "from",
46 "by",
47 "is",
48 "are",
49 "was",
50 "were",
51 "be",
52 "been",
53 "being",
54 "it",
55 "its",
56 "this",
57 "that",
58 "these",
59 "those",
60 "as",
61 "into",
62 "over",
63 "under",
64 "about",
65 "between",
66 "after",
67 "before",
68 "during",
69 "through",
70 "against",
71 "within",
72 "without",
73 "not",
74 "no",
75 "nor",
76 "so",
77 "such",
78 "too",
79 "very",
80 "can",
81 "will",
82 "just",
83 "don",
84 "does",
85 "did",
86 "has",
87 "have",
88 "had",
89 "more",
90 "most",
91 "other",
92 "some",
93 "which",
94 "what",
95 "when",
96 "where",
97 "why",
98 "how",
99 "all",
100 "any",
101 "both",
102 "each",
103 "few",
104 "own",
105 "same",
106 "than",
107 "then",
108 "there",
109 "they",
110 "them",
111 "their",
112 "you",
113 "your",
114 "we",
115 "our",
116 "us",
117 "also",
118 "may",
119 "could",
120 "would",
121 "should",
122 "if",
123 "else",
124 "while",
125 "via",
126 "etc",
127 "e.g",
128 "i.e",
129 "per",
130 "new",
131 "use",
132 "used",
133 "using",
134 "one",
135 "two",
136 "via",
137 "e.g",
138 "say",
139 "says",
140 "said",
141 "get",
142 "got",
143 "see",
144 "also",
145 "well",
146 "way",
147 "like",
148 "make",
149 "made",
150 "much",
151 "many",
152 "even",
153 "still",
154 "though",
155 "however",
156 "include",
157 "includes",
158 "including",
159 "provide",
160 "provides",
161 "provide",
162 "based",
163 "because",
164];
165
166fn clean_word(w: &str) -> Option<String> {
168 let cleaned: String = w
169 .chars()
170 .filter(|c| c.is_alphabetic())
171 .map(|c| c.to_ascii_lowercase())
172 .collect();
173 if cleaned.len() < 4 || STOPWORDS.contains(&cleaned.as_str()) {
174 return None;
175 }
176 Some(cleaned)
177}
178
179fn key_terms(texts: &[String], limit: usize) -> Vec<String> {
183 let mut counts: HashMap<String, usize> = HashMap::new();
184 let mut per_source: HashMap<String, Vec<usize>> = HashMap::new();
185 for (idx, text) in texts.iter().enumerate() {
186 for token in text
187 .split(|c: char| !c.is_alphabetic() && c != '-' && c != ' ')
188 .flat_map(|s| s.split_whitespace())
189 {
190 if let Some(word) = clean_word(token) {
191 *counts.entry(word.clone()).or_default() += 1;
192 let seen = per_source.entry(word).or_default();
193 if !seen.contains(&idx) {
194 seen.push(idx);
195 }
196 }
197 }
198 }
199 let mut terms: Vec<(String, usize, usize)> = counts
200 .into_iter()
201 .filter(|(word, _)| per_source.get(word).map_or(0, Vec::len) >= 2)
202 .map(|(word, count)| {
203 let sources = per_source.get(&word).map_or(0, Vec::len);
204 (word, count, sources)
205 })
206 .collect();
207 terms.sort_by(|a, b| {
208 b.1.cmp(&a.1)
209 .then_with(|| b.2.cmp(&a.2))
210 .then_with(|| a.0.cmp(&b.0))
211 });
212 terms.into_iter().take(limit).map(|(t, _, _)| t).collect()
213}
214
215async fn fetch_sources(
217 urls: &[String],
218 max_sources: usize,
219 max_chars: usize,
220 timeout: Duration,
221) -> Vec<Fetched> {
222 let mut fetched = Vec::new();
223 for url in urls.iter().take(max_sources) {
224 if !is_url_safe(url) {
225 continue;
226 }
227 let url_c = url.clone();
228 let max_c = max_chars;
229 let t = timeout;
230 if let Ok(Ok(f)) =
231 tokio::task::spawn_blocking(move || fetch_bounded(&url_c, max_c, t)).await
232 {
233 if !f.content.trim().is_empty() {
234 fetched.push(f);
235 }
236 }
237 }
238 fetched
239}
240
241fn finding(f: &Fetched) -> Value {
243 let domain = f
244 .url
245 .strip_prefix("https://")
246 .or_else(|| f.url.strip_prefix("http://"))
247 .and_then(|d| d.split('/').next())
248 .unwrap_or("")
249 .to_string();
250 json!({
251 "url": f.url,
252 "domain": domain,
253 "title": f.title,
254 "content": f.content,
255 "content_length": f.content.len(),
256 })
257}
258
259fn store_research(store: Option<&Arc<MemoryStore>>, topic: &str, report: &Value) -> Option<String> {
261 let store = store?;
262 let content = format!(
263 "RESEARCH: {topic}\n{}",
264 report
265 .get("synthesis")
266 .and_then(Value::as_str)
267 .unwrap_or("")
268 );
269 let mut memory = Memory::new(Galaxy::Research, content);
270 memory = memory.with_tags(vec!["research".into(), topic.to_ascii_lowercase()]);
271 if let Some(sources) = report.get("sources").and_then(Value::as_array) {
272 if let Some(first) = sources.first() {
273 if let Some(url) = first.get("url").and_then(Value::as_str) {
274 memory = memory.with_source(url.to_string(), 0.8);
275 }
276 }
277 }
278 store.put(Galaxy::Research, &memory).ok()?;
279 Some(memory.metadata.id.to_string())
280}
281
282pub struct ResearchTopicTool {
286 store: Option<Arc<MemoryStore>>,
287 stats: ToolStats,
288 effects: EffectRow,
289}
290
291impl ResearchTopicTool {
292 #[must_use]
293 pub fn new(store: Option<Arc<MemoryStore>>) -> Self {
294 Self {
295 store,
296 stats: ToolStats::default(),
297 effects: EffectRow::read_only(vec![
298 Resource::Network,
299 Resource::Galaxy("research".into()),
300 ]),
301 }
302 }
303}
304
305#[async_trait]
306impl Tool for ResearchTopicTool {
307 fn name(&self) -> &str {
308 "research.topic"
309 }
310 fn gana(&self) -> Gana {
311 Gana::Mound
312 }
313 fn effects(&self) -> &EffectRow {
314 &self.effects
315 }
316 fn description(&self) -> &str {
317 "Deep research on a topic: search, fetch top sources, extract key terms, synthesize. Args: topic (required), num_results (default 6), max_sources (default 4), max_chars_per_source (default 15000), store_memories (default true)."
318 }
319 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
320 let topic = args
321 .get("topic")
322 .and_then(Value::as_str)
323 .ok_or_else(|| wm_core::CoreError::InvalidArgs("topic is required".into()))?;
324 let num_results = args.get("num_results").and_then(Value::as_u64).unwrap_or(6) as usize;
325 let max_sources = args.get("max_sources").and_then(Value::as_u64).unwrap_or(4) as usize;
326 let max_chars = args
327 .get("max_chars_per_source")
328 .and_then(Value::as_u64)
329 .unwrap_or(15_000) as usize;
330 let store_memories = args
331 .get("store_memories")
332 .and_then(Value::as_bool)
333 .unwrap_or(true);
334 let timeout = Duration::from_secs(20);
335
336 let topic_s = topic.to_string();
337 let results =
338 tokio::task::spawn_blocking(move || web_search(&topic_s, num_results, timeout))
339 .await
340 .map_err(|e| {
341 wm_core::CoreError::Tool(format!("research.topic search task: {e}"))
342 })??;
343
344 let urls: Vec<String> = results
345 .iter()
346 .filter(|r| !r.url.is_empty())
347 .map(|r| r.url.clone())
348 .collect();
349 let fetched = fetch_sources(&urls, max_sources, max_chars, timeout).await;
350
351 let contents: Vec<String> = fetched.iter().map(|f| f.content.clone()).collect();
352 let terms = key_terms(&contents, 14);
353
354 let findings: Vec<Value> = fetched.iter().map(finding).collect();
355 let synthesis = if findings.is_empty() {
356 format!(
357 "No readable sources found for '{topic}' — refine the topic or check network access."
358 )
359 } else {
360 let source_list: Vec<String> = findings
361 .iter()
362 .map(|f| {
363 let title = f.get("title").and_then(Value::as_str).unwrap_or("");
364 let domain = f.get("domain").and_then(Value::as_str).unwrap_or("");
365 format!("{title} ({domain})")
366 })
367 .collect();
368 format!(
369 "Research on '{topic}' synthesized from {} source(s): {}. Key terms across sources: {}.",
370 findings.len(),
371 source_list.join("; "),
372 terms.join(", ")
373 )
374 };
375
376 let report = json!({
377 "status": "success",
378 "topic": topic,
379 "query": topic,
380 "sources_fetched": findings.len(),
381 "search_results": results.len(),
382 "key_terms": terms,
383 "synthesis": synthesis,
384 "sources": findings,
385 });
386
387 let mut report = report;
388 if store_memories {
389 if let Some(id) = store_research(self.store.as_ref(), topic, &report) {
390 report["memory_id"] = json!(id);
391 }
392 }
393 Ok(report)
394 }
395 fn stats(&self) -> &ToolStats {
396 &self.stats
397 }
398}
399
400pub struct ResearchRepoTool {
404 stats: ToolStats,
405 effects: EffectRow,
406}
407
408impl ResearchRepoTool {
409 #[must_use]
410 pub fn new() -> Self {
411 Self {
412 stats: ToolStats::default(),
413 effects: EffectRow::read_only(vec![Resource::Network]),
414 }
415 }
416}
417
418impl Default for ResearchRepoTool {
419 fn default() -> Self {
420 Self::new()
421 }
422}
423
424#[async_trait]
425impl Tool for ResearchRepoTool {
426 fn name(&self) -> &str {
427 "research.repo"
428 }
429 fn gana(&self) -> Gana {
430 Gana::Mound
431 }
432 fn effects(&self) -> &EffectRow {
433 &self.effects
434 }
435 fn description(&self) -> &str {
436 "Research a GitHub repo by deep-reading its README. Args: repo (required, owner/name), max_chars (default 50000)."
437 }
438 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
439 let repo = args.get("repo").and_then(Value::as_str).ok_or_else(|| {
440 wm_core::CoreError::InvalidArgs("repo (owner/name) is required".into())
441 })?;
442 let max_chars = args
443 .get("max_chars")
444 .and_then(Value::as_u64)
445 .unwrap_or(50_000) as usize;
446 let timeout = Duration::from_secs(25);
447
448 let repo = repo.trim().trim_start_matches("https://github.com/");
449 let parts: Vec<&str> = repo.split('/').filter(|p| !p.is_empty()).collect();
450 if parts.len() < 2 {
451 return Err(wm_core::CoreError::InvalidArgs(
452 "repo must be 'owner/name'".into(),
453 ));
454 }
455 let owner = parts[0];
456 let name = parts[1];
457
458 let mut raw: Option<Fetched> = None;
460 for candidate in [
461 "README.md",
462 "README.rst",
463 "readme.md",
464 "readme.rst",
465 "README",
466 ] {
467 let url = format!("https://raw.githubusercontent.com/{owner}/{name}/HEAD/{candidate}");
468 if !is_url_safe(&url) {
469 continue;
470 }
471 let url_c = url.clone();
472 let max_c = max_chars;
473 let t = timeout;
474 if let Ok(Ok(f)) =
475 tokio::task::spawn_blocking(move || fetch_bounded(&url_c, max_c, t)).await
476 {
477 if !f.content.trim().is_empty() {
478 raw = Some(f);
479 break;
480 }
481 }
482 }
483
484 let (content, source_url, title) = if let Some(f) = raw {
485 (f.content, f.url, name.to_string())
486 } else {
487 let page_url = format!("https://github.com/{owner}/{name}");
488 let page_url_c = page_url.clone();
489 let max_c = max_chars;
490 let t = timeout;
491 let fetched = tokio::task::spawn_blocking(move || fetch_bounded(&page_url_c, max_c, t))
492 .await
493 .map_err(|e| wm_core::CoreError::Tool(format!("research.repo task: {e}")))?
494 .map_err(|e| wm_core::CoreError::Tool(format!("research.repo: {e}")))?;
495 (fetched.content, page_url, name.to_string())
496 };
497
498 let mut sections: Vec<String> = Vec::new();
500 for line in content.lines() {
501 let line = line.trim();
502 if let Some(h) = line.strip_prefix("## ") {
503 sections.push(h.to_string());
504 } else if line.starts_with("### ") {
505 sections.push(line.trim_start_matches("### ").to_string());
506 }
507 }
508
509 let description = content
510 .lines()
511 .map(str::trim)
512 .find(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with('!'))
513 .unwrap_or("")
514 .to_string();
515
516 Ok(json!({
517 "status": "success",
518 "repo": format!("{owner}/{name}"),
519 "source_url": source_url,
520 "title": title,
521 "description": description,
522 "sections": sections,
523 "content_length": content.len(),
524 "content": content,
525 }))
526 }
527 fn stats(&self) -> &ToolStats {
528 &self.stats
529 }
530}
531
532pub struct ResearchRabbitHoleTool {
541 store: Option<Arc<MemoryStore>>,
542 bus: Option<Arc<Mutex<GanYingBus>>>,
543 stats: ToolStats,
544 effects: EffectRow,
545}
546
547impl ResearchRabbitHoleTool {
548 #[must_use]
549 pub fn new(store: Option<Arc<MemoryStore>>) -> Self {
550 Self {
551 store,
552 bus: None,
553 stats: ToolStats::default(),
554 effects: EffectRow::read_only(vec![
555 Resource::Network,
556 Resource::Galaxy("research".into()),
557 ]),
558 }
559 }
560
561 #[must_use]
565 pub fn with_bus(mut self, bus: Arc<Mutex<GanYingBus>>) -> Self {
566 self.bus = Some(bus);
567 self
568 }
569}
570
571fn publish_pattern_detected(
574 bus: Option<&Arc<Mutex<GanYingBus>>>,
575 topic: &str,
576 terms: &[String],
577 depth_used: usize,
578) {
579 if terms.is_empty() {
580 return;
581 }
582 let Some(bus) = bus else {
583 return;
584 };
585 if let Ok(mut bus) = bus.lock() {
586 bus.emit_with(
587 EventType::PatternDetected,
588 "research.rabbit_hole",
589 json!({
590 "topic": topic,
591 "terms": terms,
592 "entries_count": terms.len(),
593 "depth_used": depth_used,
594 }),
595 0.7,
596 false,
597 );
598 }
599}
600
601#[async_trait]
602impl Tool for ResearchRabbitHoleTool {
603 fn name(&self) -> &str {
604 "research.rabbit_hole"
605 }
606 fn gana(&self) -> Gana {
607 Gana::Mound
608 }
609 fn effects(&self) -> &EffectRow {
610 &self.effects
611 }
612 fn description(&self) -> &str {
613 "Recursive spiral research: search the topic, extract unfamiliar terms, search each term, fetch top results, synthesize. Args: topic (required), max_depth (default 2, max 3), num_search_results (default 5), fetch_top_results (default 2), max_parallel_terms (default 6), store_memories (default true)."
614 }
615 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
616 let topic = args
617 .get("topic")
618 .and_then(Value::as_str)
619 .ok_or_else(|| wm_core::CoreError::InvalidArgs("topic is required".into()))?;
620 let max_depth = args
621 .get("max_depth")
622 .and_then(Value::as_u64)
623 .unwrap_or(2)
624 .clamp(1, 3) as usize;
625 let num_results = args
626 .get("num_search_results")
627 .and_then(Value::as_u64)
628 .unwrap_or(5) as usize;
629 let fetch_top = args
630 .get("fetch_top_results")
631 .and_then(Value::as_u64)
632 .unwrap_or(2) as usize;
633 let max_parallel_terms = args
634 .get("max_parallel_terms")
635 .and_then(Value::as_u64)
636 .unwrap_or(6)
637 .clamp(1, 12) as usize;
638 let max_chars = args
639 .get("max_chars_per_fetch")
640 .and_then(Value::as_u64)
641 .unwrap_or(50_000) as usize;
642 let store_memories = args
643 .get("store_memories")
644 .and_then(Value::as_bool)
645 .unwrap_or(true);
646 let timeout = Duration::from_secs(20);
647
648 let topic_s = topic.to_string();
650 let level0 =
651 tokio::task::spawn_blocking(move || web_search(&topic_s, num_results, timeout))
652 .await
653 .map_err(|e| {
654 wm_core::CoreError::Tool(format!("research.rabbit_hole search task: {e}"))
655 })??;
656
657 let topic_urls: Vec<String> = level0
659 .iter()
660 .filter(|r| !r.url.is_empty())
661 .map(|r| r.url.clone())
662 .collect();
663 let topic_fetched = fetch_sources(&topic_urls, fetch_top, max_chars, timeout).await;
664
665 let mut terms: Vec<String> = Vec::new();
667 {
668 let mut seen: Vec<String> = Vec::new();
669 let candidates: Vec<String> = level0
670 .iter()
671 .flat_map(|r| {
672 r.title
673 .split_whitespace()
674 .chain(r.snippet.split_whitespace())
675 .map(str::to_string)
676 .collect::<Vec<_>>()
677 })
678 .collect();
679 for word in candidates {
680 let word = word.trim_matches(|c: char| !c.is_alphanumeric());
681 if word.len() < 5 || STOPWORDS.contains(&word.to_ascii_lowercase().as_str()) {
682 continue;
683 }
684 if word.eq_ignore_ascii_case(topic) {
685 continue;
686 }
687 let key = word.to_ascii_lowercase();
688 if !seen.contains(&key) {
689 seen.push(key);
690 terms.push(word.to_string());
691 }
692 if terms.len() >= max_parallel_terms {
693 break;
694 }
695 }
696 }
697
698 let mut entries: Vec<Value> = Vec::new();
700 for term in terms.iter().take(max_parallel_terms) {
701 let term_s = term.clone();
702 let n = num_results.max(3);
703 let t = timeout;
704 let results = tokio::task::spawn_blocking(move || web_search(&term_s, n, t))
705 .await
706 .map_err(|e| {
707 wm_core::CoreError::Tool(format!("research.rabbit_hole term task: {e}"))
708 })??;
709 let best = results.first();
710 entries.push(json!({
711 "term": term,
712 "depth": 1,
713 "definition": best.map(|b| b.snippet.clone()).unwrap_or_default(),
714 "source": best.map(|b| b.url.clone()).unwrap_or_default(),
715 "related_terms": results.iter().take(3).map(|r| r.title.clone()).collect::<Vec<_>>(),
716 }));
717 }
718
719 let mut extra_entries: Vec<Value> = Vec::new();
721 if max_depth >= 2 && !entries.is_empty() {
722 let mut best_term = String::new();
724 let mut best_score = 0usize;
725 for entry in &entries {
726 let related = entry
727 .get("related_terms")
728 .and_then(Value::as_array)
729 .map_or(0, Vec::len);
730 if related > best_score {
731 best_score = related;
732 best_term = entry
733 .get("term")
734 .and_then(Value::as_str)
735 .unwrap_or("")
736 .to_string();
737 }
738 }
739 if !best_term.is_empty() && !best_term.eq_ignore_ascii_case(topic) {
740 let bt = best_term.clone();
741 let n = num_results.max(3);
742 let t = timeout;
743 let results = tokio::task::spawn_blocking(move || web_search(&bt, n, t))
744 .await
745 .map_err(|e| {
746 wm_core::CoreError::Tool(format!("research.rabbit_hole depth-2 task: {e}"))
747 })??;
748 for r in results.iter().take(3) {
749 extra_entries.push(json!({
750 "term": best_term,
751 "depth": 2,
752 "definition": r.snippet,
753 "source": r.url,
754 "related_terms": [],
755 }));
756 }
757 }
758 }
759 entries.extend(extra_entries);
760
761 let mut connections: Vec<String> = Vec::new();
763 for entry in &entries {
764 if let (Some(term), Some(src)) = (
765 entry.get("term").and_then(Value::as_str),
766 entry.get("source").and_then(Value::as_str),
767 ) {
768 if !src.is_empty() {
769 connections.push(format!("{term} → {src}"));
770 }
771 }
772 }
773 let synthesis = format!(
774 "Rabbit-hole exploration of '{topic}' (depth {max_depth}): {} unfamiliar term(s) explored — {}. Top sources on the topic itself: {}.",
775 entries.len(),
776 entries
777 .iter()
778 .map(|e| e.get("term").and_then(Value::as_str).unwrap_or(""))
779 .collect::<Vec<_>>()
780 .join(", "),
781 topic_fetched
782 .iter()
783 .map(|f| f.title.as_str())
784 .collect::<Vec<_>>()
785 .join("; "),
786 );
787
788 let topic_sources: Vec<Value> = topic_fetched.iter().map(finding).collect();
789 let report = json!({
790 "status": "success",
791 "title": topic,
792 "topics": entries.iter().map(|e| e["term"].clone()).collect::<Vec<_>>(),
793 "entries_count": entries.len(),
794 "synthesis": synthesis,
795 "connections_count": connections.len(),
796 "connections": connections,
797 "depth_used": max_depth,
798 "entries": entries,
799 "sources": topic_sources,
800 });
801
802 let mut report = report;
803 if store_memories {
804 if let Some(id) = store_research(self.store.as_ref(), topic, &report) {
805 report["memory_id"] = json!(id);
806 }
807 }
808 if !entries.is_empty() {
811 let terms: Vec<String> = entries
812 .iter()
813 .filter_map(|e| e.get("term").and_then(Value::as_str))
814 .map(str::to_string)
815 .collect();
816 publish_pattern_detected(self.bus.as_ref(), topic, &terms, max_depth);
817 }
818 Ok(report)
819 }
820 fn stats(&self) -> &ToolStats {
821 &self.stats
822 }
823}
824
825#[must_use]
828pub fn register_research(
829 registry: &wm_dispatch::ToolRegistry,
830 store: &Arc<MemoryStore>,
831 bus: Option<&Arc<Mutex<GanYingBus>>>,
832) -> wm_dispatch::ToolRegistry {
833 let mut rabbit_hole = ResearchRabbitHoleTool::new(Some(store.clone()));
834 if let Some(bus) = bus {
835 rabbit_hole = rabbit_hole.with_bus(Arc::clone(bus));
836 }
837 registry
838 .register(Arc::new(ResearchTopicTool::new(Some(store.clone()))))
839 .register(Arc::new(ResearchRepoTool::new()))
840 .register(Arc::new(rabbit_hole))
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 #[test]
848 fn key_terms_filters_stopwords() {
849 let texts = vec![
850 "The architecture of the agentic system and the governance layer".to_string(),
851 "The governance layer architecture and the agentic system".to_string(),
852 "Architecture governance agentic system".to_string(),
853 ];
854 let terms = key_terms(&texts, 5);
855 assert_eq!(terms.len(), 5);
856 assert!(!terms.contains(&"the".to_string()));
857 assert!(terms.contains(&"governance".to_string()));
858 assert!(terms.contains(&"architecture".to_string()));
859 assert!(terms.contains(&"agentic".to_string()));
860 assert!(terms.contains(&"system".to_string()));
861 assert!(terms.contains(&"layer".to_string()));
862 }
863
864 #[test]
865 fn key_terms_require_two_sources() {
866 let texts = vec![
868 "onlyonce appears only here in the alpha bravo".to_string(),
869 "alpha bravo both sources share the delta".to_string(),
870 ];
871 let terms = key_terms(&texts, 10);
872 assert!(!terms.contains(&"onlyonce".to_string()));
873 assert!(!terms.contains(&"delta".to_string()));
874 assert!(terms.contains(&"bravo".to_string()));
875 assert!(terms.contains(&"alpha".to_string()));
876 }
877
878 #[test]
879 fn repo_name_validation() {
880 let tool = ResearchRepoTool::new();
881 assert_eq!(tool.name(), "research.repo");
882 assert_eq!(ResearchTopicTool::new(None).name(), "research.topic");
883 assert_eq!(
884 ResearchRabbitHoleTool::new(None).name(),
885 "research.rabbit_hole"
886 );
887 }
888
889 #[tokio::test]
890 async fn topic_requires_topic() {
891 let tool = ResearchTopicTool::new(None);
892 let mut ctx = Context::default();
893 assert!(tool.call(&mut ctx, json!({})).await.is_err());
894 }
895
896 #[tokio::test]
897 async fn rabbit_hole_requires_topic() {
898 let tool = ResearchRabbitHoleTool::new(None);
899 let mut ctx = Context::default();
900 assert!(tool.call(&mut ctx, json!({})).await.is_err());
901 }
902
903 #[test]
904 fn rabbit_hole_publishes_pattern_detected() {
905 let bus = Arc::new(Mutex::new(GanYingBus::default()));
906 let seen: Arc<Mutex<Vec<EventType>>> = Arc::new(Mutex::new(Vec::new()));
907 let seen_cb = Arc::clone(&seen);
908 bus.lock().unwrap().subscribe(
909 wm_cognitive::SubscriptionFilter::All,
910 Box::new(move |event| {
911 seen_cb.lock().unwrap().push(event.event_type);
912 }),
913 );
914 let terms = vec!["photosynthesis".to_string(), "chlorophyll".to_string()];
915 publish_pattern_detected(Some(&bus), "plants", &terms, 2);
916 assert_eq!(
917 seen.lock().unwrap().as_slice(),
918 &[EventType::PatternDetected]
919 );
920
921 publish_pattern_detected(Some(&bus), "plants", &[], 2);
923 publish_pattern_detected(None, "plants", &terms, 2);
924 assert_eq!(seen.lock().unwrap().len(), 1);
925 }
926
927 #[tokio::test]
928 async fn repo_requires_owner_and_name() {
929 let tool = ResearchRepoTool::new();
930 let mut ctx = Context::default();
931 assert!(
932 tool.call(&mut ctx, json!({"repo": "singleword"}))
933 .await
934 .is_err()
935 );
936 }
937
938 #[test]
939 fn effects_declare_network_read() {
940 let topic = ResearchTopicTool::new(None);
941 assert_eq!(topic.effects().reads[0], Resource::Network);
942 assert!(topic.effects().writes.is_empty());
943 }
944}