1use anyhow::{Context, Result};
15use chrono::Utc;
16use serde::{Deserialize, Serialize};
17use std::fmt;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ResearchConfig {
26 pub topic: String,
28
29 pub working_dir: PathBuf,
31
32 #[serde(default = "default_output_dir")]
34 pub output_dir: PathBuf,
35
36 #[serde(default = "default_max_searches")]
38 pub max_searches: usize,
39
40 #[serde(default = "default_true")]
42 pub analyze_codebase: bool,
43
44 pub focus: Option<String>,
46}
47
48fn default_output_dir() -> PathBuf {
49 PathBuf::from("docs/research")
50}
51
52fn default_max_searches() -> usize {
53 5
54}
55
56fn default_true() -> bool {
57 true
58}
59
60impl Default for ResearchConfig {
61 fn default() -> Self {
62 Self {
63 topic: String::new(),
64 working_dir: std::env::current_dir().unwrap_or_default(),
65 output_dir: default_output_dir(),
66 max_searches: default_max_searches(),
67 analyze_codebase: true,
68 focus: None,
69 }
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SearchResult {
76 pub query: String,
78 pub title: String,
80 pub url: String,
82 pub snippet: String,
84 pub source: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ApproachComparison {
91 pub name: String,
93 pub description: String,
95 pub pros: Vec<String>,
97 pub cons: Vec<String>,
99 pub complexity: u8,
101 pub suitability: u8,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct CodebaseAnalysis {
108 pub files: Vec<String>,
110 pub patterns: Vec<String>,
112 pub dependencies: Vec<String>,
114 pub summary: String,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ResearchReport {
121 pub meta: ReportMeta,
123 pub topic: String,
125 pub focus: Option<String>,
127 pub summary: String,
129 pub background: String,
131 pub codebase_analysis: Option<CodebaseAnalysis>,
133 pub search_results: Vec<SearchResult>,
135 pub approaches: Vec<ApproachComparison>,
137 pub recommendation: String,
139 pub next_steps: Vec<String>,
141 pub references: Vec<Reference>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ReportMeta {
148 pub date: String,
150 pub slug: String,
152 pub version: u32,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct Reference {
159 pub title: String,
161 pub url: String,
163 #[serde(rename = "type")]
165 pub ref_type: ReferenceType,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum ReferenceType {
172 Web,
174 Documentation,
176 Code,
178 Paper,
180 Other,
182}
183
184pub fn slugify(topic: &str) -> String {
195 let mut slug = String::with_capacity(topic.len());
196 let mut prev_dash = false;
197
198 for ch in topic.chars() {
199 if ch.is_ascii_alphanumeric() {
200 slug.push(ch.to_ascii_lowercase());
201 prev_dash = false;
202 } else if ch == ' ' || ch == '_' || ch == '-' {
203 if !prev_dash && !slug.is_empty() {
204 slug.push('-');
205 prev_dash = true;
206 }
207 }
208 }
210
211 if slug.ends_with('-') {
213 slug.pop();
214 }
215
216 slug
217}
218
219pub struct DeepResearchSkill;
233
234impl DeepResearchSkill {
235 pub fn new() -> Self {
237 Self
238 }
239
240 pub fn report_filename(topic: &str) -> String {
244 let date = Utc::now().format("%Y-%m-%d").to_string();
245 let slug = slugify(topic);
246 format!("{}-{}.md", date, slug)
247 }
248
249 pub fn report_path(config: &ResearchConfig) -> PathBuf {
251 config.output_dir.join(Self::report_filename(&config.topic))
252 }
253
254 pub fn render_markdown(report: &ResearchReport) -> String {
256 let mut md = String::with_capacity(4096);
257
258 md.push_str(&format!("# {}\n\n", report.topic));
260 md.push_str(&format!("> Date: {} | Version: {}\n", report.meta.date, report.meta.version));
261 if let Some(ref focus) = report.focus {
262 md.push_str(&format!("> Focus: {}\n", focus));
263 }
264 md.push('\n');
265
266 md.push_str("## Summary\n\n");
268 md.push_str(&report.summary);
269 md.push_str("\n\n");
270
271 md.push_str("## Background\n\n");
273 md.push_str(&report.background);
274 md.push_str("\n\n");
275
276 if let Some(ref analysis) = report.codebase_analysis {
278 md.push_str("## Codebase Analysis\n\n");
279 md.push_str(&analysis.summary);
280 md.push_str("\n\n");
281
282 if !analysis.files.is_empty() {
283 md.push_str("### Relevant Files\n\n");
284 for file in &analysis.files {
285 md.push_str(&format!("- `{}`\n", file));
286 }
287 md.push('\n');
288 }
289
290 if !analysis.patterns.is_empty() {
291 md.push_str("### Patterns\n\n");
292 for pattern in &analysis.patterns {
293 md.push_str(&format!("- {}\n", pattern));
294 }
295 md.push('\n');
296 }
297
298 if !analysis.dependencies.is_empty() {
299 md.push_str("### Dependencies\n\n");
300 for dep in &analysis.dependencies {
301 md.push_str(&format!("- {}\n", dep));
302 }
303 md.push('\n');
304 }
305 }
306
307 if !report.search_results.is_empty() {
309 md.push_str("## Research Findings\n\n");
310 let mut i = 1;
311 for result in &report.search_results {
312 md.push_str(&format!(
313 "### {}. {} [{}]({})\n\n{}\n\nSource: {}\n\n",
314 i, result.title, result.query, result.url, result.snippet, result.source
315 ));
316 i += 1;
317 }
318 }
319
320 if !report.approaches.is_empty() {
322 md.push_str("## Approach Comparison\n\n");
323 md.push_str("| Approach | Complexity | Suitability | Pros | Cons |\n");
324 md.push_str("|----------|-----------|-------------|------|------|\n");
325 for approach in &report.approaches {
326 let pros = approach.pros.join(", ");
327 let cons = approach.cons.join(", ");
328 md.push_str(&format!(
329 "| {} | {}/5 | {}/5 | {} | {} |\n",
330 approach.name,
331 approach.complexity,
332 approach.suitability,
333 pros,
334 cons,
335 ));
336 }
337 md.push('\n');
338
339 for approach in &report.approaches {
341 md.push_str(&format!("### {}\n\n", approach.name));
342 md.push_str(&format!("{}\n\n", approach.description));
343 md.push_str("**Pros:**\n");
344 for pro in &approach.pros {
345 md.push_str(&format!("- {}\n", pro));
346 }
347 md.push_str("\n**Cons:**\n");
348 for con in &approach.cons {
349 md.push_str(&format!("- {}\n", con));
350 }
351 md.push_str(&format!(
352 "\nComplexity: {}/5 | Suitability: {}/5\n\n",
353 approach.complexity, approach.suitability
354 ));
355 }
356 }
357
358 md.push_str("## Recommendation\n\n");
360 md.push_str(&report.recommendation);
361 md.push_str("\n\n");
362
363 if !report.next_steps.is_empty() {
365 md.push_str("## Next Steps\n\n");
366 for (i, step) in report.next_steps.iter().enumerate() {
367 md.push_str(&format!("{}. {}\n", i + 1, step));
368 }
369 md.push('\n');
370 }
371
372 if !report.references.is_empty() {
374 md.push_str("## References\n\n");
375 for reference in &report.references {
376 let type_label = match reference.ref_type {
377 ReferenceType::Web => "🌐",
378 ReferenceType::Documentation => "📖",
379 ReferenceType::Code => "💻",
380 ReferenceType::Paper => "📄",
381 ReferenceType::Other => "🔗",
382 };
383 md.push_str(&format!(
384 "- {} [{}]({})\n",
385 type_label, reference.title, reference.url
386 ));
387 }
388 md.push('\n');
389 }
390
391 md
392 }
393
394 pub fn write_report(config: &ResearchConfig, report: &ResearchReport) -> Result<PathBuf> {
399 let output_dir = if config.output_dir.is_absolute() {
400 config.output_dir.clone()
401 } else {
402 config.working_dir.join(&config.output_dir)
403 };
404
405 fs::create_dir_all(&output_dir)
407 .with_context(|| format!("Failed to create output directory: {}", output_dir.display()))?;
408
409 let filename = Self::report_filename(&config.topic);
410 let path = output_dir.join(&filename);
411
412 let markdown = Self::render_markdown(report);
413
414 fs::write(&path, &markdown)
415 .with_context(|| format!("Failed to write report to {}", path.display()))?;
416
417 tracing::info!("Research report written to {}", path.display());
418 Ok(path)
419 }
420
421 pub fn skill_instructions() -> String {
427 include_str!("deep_research_prompt.md").to_string()
428 }
429
430 pub fn analyze_project(dir: &Path, topic_keywords: &[&str]) -> Result<CodebaseAnalysis> {
437 let mut files = Vec::new();
438 let mut patterns = Vec::new();
439 let mut dependencies = Vec::new();
440
441 Self::walk_dir(dir, "", 0, 4, topic_keywords, &mut files, &mut patterns)?;
443
444 let cargo_toml = dir.join("Cargo.toml");
446 if cargo_toml.exists() {
447 if let Ok(content) = fs::read_to_string(&cargo_toml) {
448 Self::extract_cargo_deps(&content, topic_keywords, &mut dependencies);
449 patterns.push("Rust project (Cargo)".to_string());
450 }
451 }
452
453 let package_json = dir.join("package.json");
454 if package_json.exists() {
455 if let Ok(content) = fs::read_to_string(&package_json) {
456 Self::extract_npm_deps(&content, topic_keywords, &mut dependencies);
457 patterns.push("Node.js project (npm/yarn)".to_string());
458 }
459 }
460
461 if dir.join("src").is_dir() {
463 patterns.push("Standard src/ layout".to_string());
464 }
465 if dir.join("tests").is_dir() {
466 patterns.push("Has tests/ directory".to_string());
467 }
468 if dir.join(".github").is_dir() {
469 patterns.push("GitHub Actions CI".to_string());
470 }
471 if dir.join("Dockerfile").exists() {
472 patterns.push("Dockerized".to_string());
473 }
474
475 let file_count = files.len();
476 let summary = format!(
477 "Found {} relevant file(s) across the project. {} pattern(s) and {} related dep(s) identified.",
478 file_count,
479 patterns.len(),
480 dependencies.len()
481 );
482
483 Ok(CodebaseAnalysis {
484 files,
485 patterns,
486 dependencies,
487 summary,
488 })
489 }
490
491 fn walk_dir(
493 dir: &Path,
494 prefix: &str,
495 depth: usize,
496 max_depth: usize,
497 keywords: &[&str],
498 files: &mut Vec<String>,
499 patterns: &mut Vec<String>,
500 ) -> Result<()> {
501 if depth > max_depth {
502 return Ok(());
503 }
504
505 let entries = fs::read_dir(dir)
506 .with_context(|| format!("Failed to read directory: {}", dir.display()))?;
507
508 for entry in entries {
509 let entry = entry?;
510 let name = entry.file_name().to_string_lossy().to_string();
511
512 if name.starts_with('.') || name == "target" || name == "node_modules"
514 || name == "__pycache__" || name == "dist" || name == "build"
515 || name == ".git" || name == "vendor" || name == "coverage"
516 {
517 continue;
518 }
519
520 let path = entry.path();
521 let rel = if prefix.is_empty() {
522 name.clone()
523 } else {
524 format!("{}/{}", prefix, name)
525 };
526
527 if path.is_dir() {
528 Self::walk_dir(&path, &rel, depth + 1, max_depth, keywords, files, patterns)?;
529 } else {
530 let name_lower = name.to_lowercase();
532
533 let is_config = matches!(
535 name_lower.as_str(),
536 "cargo.toml"
537 | "package.json"
538 | "tsconfig.json"
539 | "pyproject.toml"
540 | "go.mod"
541 | "makefile"
542 | "dockerfile"
543 | "docker-compose.yml"
544 | "docker-compose.yaml"
545 | ".env.example"
546 | "readme.md"
547 | "license"
548 );
549
550 let keyword_match = keywords.iter().any(|kw| {
552 let kw_lower = kw.to_lowercase();
553 name_lower.contains(&kw_lower)
555 || Self::is_source_file(&name_lower) && !keywords.is_empty()
557 });
558
559 if is_config || keyword_match || keywords.is_empty() {
560 files.push(rel);
561 }
562 }
563 }
564
565 Ok(())
566 }
567
568 fn is_source_file(name: &str) -> bool {
569 name.ends_with(".rs")
570 || name.ends_with(".ts")
571 || name.ends_with(".js")
572 || name.ends_with(".py")
573 || name.ends_with(".go")
574 || name.ends_with(".java")
575 || name.ends_with(".tsx")
576 || name.ends_with(".jsx")
577 }
578
579 fn extract_cargo_deps(content: &str, keywords: &[&str], deps: &mut Vec<String>) {
581 let in_deps = content.lines()
582 .skip_while(|line| line.trim() != "[dependencies]" && line.trim() != "[dev-dependencies]")
583 .take_while(|line| !line.starts_with('[') || line.trim() == "[dependencies]" || line.trim() == "[dev-dependencies]");
584
585 for line in in_deps {
586 let line = line.trim();
587 if line.starts_with('[') || line.is_empty() {
588 continue;
589 }
590 if let Some((name, _rest)) = line.split_once('=') {
591 let name = name.trim();
592 if keywords.is_empty() {
593 deps.push(format!("{} (Rust crate)", name));
594 } else {
595 let name_lower = name.to_lowercase();
596 let relevant = keywords.iter().any(|kw| name_lower.contains(&kw.to_lowercase()));
597 if relevant {
598 deps.push(format!("{} (Rust crate)", name));
599 }
600 }
601 }
602 }
603 }
604
605 fn extract_npm_deps(content: &str, keywords: &[&str], deps: &mut Vec<String>) {
607 if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
609 for section in &["dependencies", "devDependencies"] {
610 if let Some(obj) = json.get(section).and_then(|v| v.as_object()) {
611 for name in obj.keys() {
612 if keywords.is_empty() {
613 deps.push(format!("{} (npm)", name));
614 } else {
615 let name_lower = name.to_lowercase();
616 let relevant = keywords.iter().any(|kw| name_lower.contains(&kw.to_lowercase()));
617 if relevant {
618 deps.push(format!("{} (npm)", name));
619 }
620 }
621 }
622 }
623 }
624 }
625 }
626}
627
628impl Default for DeepResearchSkill {
629 fn default() -> Self {
630 Self::new()
631 }
632}
633
634impl fmt::Debug for DeepResearchSkill {
635 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
636 f.debug_struct("DeepResearchSkill").finish()
637 }
638}
639
640#[cfg(test)]
643mod tests {
644 use super::*;
645 use std::fs;
646
647 #[test]
648 fn test_slugify_simple() {
649 assert_eq!(slugify("What is the best ORM for Rust?"), "what-is-the-best-orm-for-rust");
650 }
651
652 #[test]
653 fn test_slugify_with_special_chars() {
654 assert_eq!(
655 slugify("React vs. Vue: A Comparison (2024)"),
656 "react-vs-vue-a-comparison-2024"
657 );
658 }
659
660 #[test]
661 fn test_slugify_with_underscores() {
662 assert_eq!(slugify("my_important_topic"), "my-important-topic");
663 }
664
665 #[test]
666 fn test_slugify_consecutive_spaces() {
667 assert_eq!(slugify("hello world"), "hello-world");
668 }
669
670 #[test]
671 fn test_slugify_empty() {
672 assert_eq!(slugify(""), "");
673 }
674
675 #[test]
676 fn test_slugify_only_special() {
677 assert_eq!(slugify("!!!"), "");
678 }
679
680 #[test]
681 fn test_report_filename() {
682 let filename = DeepResearchSkill::report_filename("Best database for Rust");
683 let date = Utc::now().format("%Y-%m-%d").to_string();
684 assert_eq!(filename, format!("{}-best-database-for-rust.md", date));
685 }
686
687 #[test]
688 fn test_report_path() {
689 let config = ResearchConfig {
690 topic: "Test Topic".to_string(),
691 output_dir: PathBuf::from("docs/research"),
692 ..Default::default()
693 };
694 let path = DeepResearchSkill::report_path(&config);
695 assert!(path.to_string_lossy().contains("docs/research"));
696 assert!(path.to_string_lossy().ends_with(".md"));
697 }
698
699 #[test]
700 fn test_render_markdown_minimal() {
701 let report = ResearchReport {
702 meta: ReportMeta {
703 date: "2024-01-15".to_string(),
704 slug: "test-topic".to_string(),
705 version: 1,
706 },
707 topic: "Test Topic".to_string(),
708 focus: None,
709 summary: "This is a test summary.".to_string(),
710 background: "Some background info.".to_string(),
711 codebase_analysis: None,
712 search_results: vec![],
713 approaches: vec![],
714 recommendation: "Do the thing.".to_string(),
715 next_steps: vec![],
716 references: vec![],
717 };
718
719 let md = DeepResearchSkill::render_markdown(&report);
720 assert!(md.contains("# Test Topic"));
721 assert!(md.contains("## Summary"));
722 assert!(md.contains("This is a test summary."));
723 assert!(md.contains("## Background"));
724 assert!(md.contains("## Recommendation"));
725 assert!(md.contains("Do the thing."));
726 }
727
728 #[test]
729 fn test_render_markdown_full() {
730 let report = ResearchReport {
731 meta: ReportMeta {
732 date: "2024-03-01".to_string(),
733 slug: "auth-strategies".to_string(),
734 version: 2,
735 },
736 topic: "Authentication Strategies".to_string(),
737 focus: Some("JWT vs Session-based".to_string()),
738 summary: "Comparison of auth strategies.".to_string(),
739 background: "Web apps need auth.".to_string(),
740 codebase_analysis: Some(CodebaseAnalysis {
741 files: vec!["src/auth.rs".to_string(), "src/middleware.rs".to_string()],
742 patterns: vec!["Middleware pattern".to_string()],
743 dependencies: vec!["jsonwebtoken (Rust crate)".to_string()],
744 summary: "Found auth-related files.".to_string(),
745 }),
746 search_results: vec![SearchResult {
747 query: "JWT vs session auth".to_string(),
748 title: "JWT vs Session Authentication".to_string(),
749 url: "https://example.com/jwt-vs-session".to_string(),
750 snippet: "A comparison of authentication methods.".to_string(),
751 source: "ddg".to_string(),
752 }],
753 approaches: vec![ApproachComparison {
754 name: "JWT".to_string(),
755 description: "Stateless token-based auth.".to_string(),
756 pros: vec!["Stateless".to_string(), "Scalable".to_string()],
757 cons: vec!["Token revocation is hard".to_string()],
758 complexity: 3,
759 suitability: 4,
760 }],
761 recommendation: "Use JWT for this project.".to_string(),
762 next_steps: vec!["Implement JWT middleware".to_string()],
763 references: vec![Reference {
764 title: "JWT RFC".to_string(),
765 url: "https://tools.ietf.org/html/rfc7519".to_string(),
766 ref_type: ReferenceType::Documentation,
767 }],
768 };
769
770 let md = DeepResearchSkill::render_markdown(&report);
771 assert!(md.contains("# Authentication Strategies"));
772 assert!(md.contains("> Focus: JWT vs Session-based"));
773 assert!(md.contains("## Codebase Analysis"));
774 assert!(md.contains("`src/auth.rs`"));
775 assert!(md.contains("## Research Findings"));
776 assert!(md.contains("## Approach Comparison"));
777 assert!(md.contains("| JWT |"));
778 assert!(md.contains("## Next Steps"));
779 assert!(md.contains("1. Implement JWT middleware"));
780 assert!(md.contains("## References"));
781 assert!(md.contains("JWT RFC"));
782 }
783
784 #[test]
785 fn test_write_report_creates_file() {
786 let tmp = tempfile::tempdir().unwrap();
787 let config = ResearchConfig {
788 topic: "Test Report".to_string(),
789 working_dir: tmp.path().to_path_buf(),
790 output_dir: PathBuf::from("docs/research"),
791 ..Default::default()
792 };
793
794 let report = ResearchReport {
795 meta: ReportMeta {
796 date: "2024-01-01".to_string(),
797 slug: "test-report".to_string(),
798 version: 1,
799 },
800 topic: "Test Report".to_string(),
801 focus: None,
802 summary: "Test summary.".to_string(),
803 background: "Test background.".to_string(),
804 codebase_analysis: None,
805 search_results: vec![],
806 approaches: vec![],
807 recommendation: "Test recommendation.".to_string(),
808 next_steps: vec![],
809 references: vec![],
810 };
811
812 let path = DeepResearchSkill::write_report(&config, &report).unwrap();
813 assert!(path.exists());
814
815 let content = fs::read_to_string(&path).unwrap();
816 assert!(content.contains("# Test Report"));
817 assert!(content.contains("Test summary."));
818 }
819
820 #[test]
821 fn test_write_report_absolute_output_dir() {
822 let tmp = tempfile::tempdir().unwrap();
823 let abs_dir = tmp.path().join("output").join("research");
824
825 let config = ResearchConfig {
826 topic: "Absolute Path Test".to_string(),
827 working_dir: tmp.path().to_path_buf(),
828 output_dir: abs_dir.clone(),
829 ..Default::default()
830 };
831
832 let report = ResearchReport {
833 meta: ReportMeta {
834 date: "2024-06-15".to_string(),
835 slug: "absolute-path-test".to_string(),
836 version: 1,
837 },
838 topic: "Absolute Path Test".to_string(),
839 focus: None,
840 summary: "Testing absolute paths.".to_string(),
841 background: "Context.".to_string(),
842 codebase_analysis: None,
843 search_results: vec![],
844 approaches: vec![],
845 recommendation: "Works.".to_string(),
846 next_steps: vec![],
847 references: vec![],
848 };
849
850 let path = DeepResearchSkill::write_report(&config, &report).unwrap();
851 assert!(path.exists());
852 assert!(path.starts_with(&abs_dir));
853 }
854
855 #[test]
856 fn test_analyze_project_empty_dir() {
857 let tmp = tempfile::tempdir().unwrap();
858 let analysis = DeepResearchSkill::analyze_project(tmp.path(), &[]).unwrap();
859 assert!(analysis.files.is_empty() || analysis.files.iter().any(|f| f.contains("Cargo.toml") || f.contains("package.json")));
861 }
862
863 #[test]
864 fn test_analyze_project_rust_project() {
865 let tmp = tempfile::tempdir().unwrap();
866
867 let src_dir = tmp.path().join("src");
869 fs::create_dir_all(&src_dir).unwrap();
870 fs::write(tmp.path().join("Cargo.toml"), r#"
871[package]
872name = "test-project"
873version = "0.1.0"
874
875[dependencies]
876serde = { version = "1", features = ["derive"] }
877tokio = "1"
878"#).unwrap();
879 fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
880
881 let analysis = DeepResearchSkill::analyze_project(tmp.path(), &["serde"]).unwrap();
882 assert!(analysis.patterns.iter().any(|p| p.contains("Rust")));
883 assert!(analysis.dependencies.iter().any(|d| d.contains("serde")));
884 }
885
886 #[test]
887 fn test_analyze_project_npm_project() {
888 let tmp = tempfile::tempdir().unwrap();
889 let src_dir = tmp.path().join("src");
890 fs::create_dir_all(&src_dir).unwrap();
891 fs::write(
892 tmp.path().join("package.json"),
893 r#"{"dependencies": {"express": "^4.18.0", "lodash": "^4.17.21"}}"#,
894 )
895 .unwrap();
896 fs::write(src_dir.join("index.ts"), "console.log('hi')").unwrap();
897
898 let analysis = DeepResearchSkill::analyze_project(tmp.path(), &["express"]).unwrap();
899 assert!(analysis.patterns.iter().any(|p| p.contains("Node.js")));
900 assert!(analysis.dependencies.iter().any(|d| d.contains("express")));
901 }
902
903 #[test]
904 fn test_analyze_project_skips_hidden_and_noise() {
905 let tmp = tempfile::tempdir().unwrap();
906 fs::create_dir_all(tmp.path().join(".git")).unwrap();
907 fs::create_dir_all(tmp.path().join("target")).unwrap();
908 fs::create_dir_all(tmp.path().join("node_modules")).unwrap();
909 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
910
911 let analysis = DeepResearchSkill::analyze_project(tmp.path(), &[]).unwrap();
912 for file in &analysis.files {
914 assert!(!file.starts_with(".git/"), "Should skip .git: {}", file);
915 assert!(!file.starts_with("target/"), "Should skip target: {}", file);
916 assert!(!file.starts_with("node_modules/"), "Should skip node_modules: {}", file);
917 }
918 }
919
920 #[test]
921 fn test_analyze_project_depth_limited() {
922 let tmp = tempfile::tempdir().unwrap();
923 let deep = tmp.path().join("a").join("b").join("c").join("d").join("e").join("f");
925 fs::create_dir_all(&deep).unwrap();
926 fs::write(deep.join("deep.txt"), "content").unwrap();
927 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
928
929 let analysis = DeepResearchSkill::analyze_project(tmp.path(), &[]).unwrap();
930 assert!(!analysis.files.iter().any(|f| f.contains("deep.txt")));
932 }
933
934 #[test]
935 fn test_skill_instructions_not_empty() {
936 let instructions = DeepResearchSkill::skill_instructions();
937 assert!(!instructions.is_empty());
938 }
939
940 #[test]
941 fn test_research_config_default() {
942 let config = ResearchConfig::default();
943 assert!(config.topic.is_empty());
944 assert_eq!(config.max_searches, 5);
945 assert!(config.analyze_codebase);
946 assert!(config.focus.is_none());
947 assert_eq!(config.output_dir, PathBuf::from("docs/research"));
948 }
949
950 #[test]
951 fn test_research_config_serde_roundtrip() {
952 let config = ResearchConfig {
953 topic: "Test".to_string(),
954 working_dir: PathBuf::from("/tmp/project"),
955 output_dir: PathBuf::from("docs/research"),
956 max_searches: 10,
957 analyze_codebase: false,
958 focus: Some("narrow".to_string()),
959 };
960
961 let json = serde_json::to_string(&config).unwrap();
962 let parsed: ResearchConfig = serde_json::from_str(&json).unwrap();
963 assert_eq!(parsed.topic, "Test");
964 assert_eq!(parsed.max_searches, 10);
965 assert!(!parsed.analyze_codebase);
966 assert_eq!(parsed.focus, Some("narrow".to_string()));
967 }
968
969 #[test]
970 fn test_report_serde_roundtrip() {
971 let report = ResearchReport {
972 meta: ReportMeta {
973 date: "2024-01-01".to_string(),
974 slug: "test".to_string(),
975 version: 1,
976 },
977 topic: "Test".to_string(),
978 focus: None,
979 summary: "Summary.".to_string(),
980 background: "BG.".to_string(),
981 codebase_analysis: None,
982 search_results: vec![SearchResult {
983 query: "q".to_string(),
984 title: "t".to_string(),
985 url: "https://example.com".to_string(),
986 snippet: "s".to_string(),
987 source: "ddg".to_string(),
988 }],
989 approaches: vec![ApproachComparison {
990 name: "A".to_string(),
991 description: "desc".to_string(),
992 pros: vec!["good".to_string()],
993 cons: vec!["bad".to_string()],
994 complexity: 2,
995 suitability: 4,
996 }],
997 recommendation: "Do it.".to_string(),
998 next_steps: vec!["Step 1".to_string()],
999 references: vec![Reference {
1000 title: "Ref".to_string(),
1001 url: "https://example.com".to_string(),
1002 ref_type: ReferenceType::Web,
1003 }],
1004 };
1005
1006 let json = serde_json::to_string_pretty(&report).unwrap();
1007 let parsed: ResearchReport = serde_json::from_str(&json).unwrap();
1008 assert_eq!(parsed.topic, report.topic);
1009 assert_eq!(parsed.search_results.len(), 1);
1010 assert_eq!(parsed.approaches.len(), 1);
1011 assert_eq!(parsed.references.len(), 1);
1012 assert_eq!(parsed.next_steps.len(), 1);
1013 }
1014
1015 #[test]
1016 fn test_extract_cargo_deps() {
1017 let content = r#"
1018[package]
1019name = "test"
1020
1021[dependencies]
1022serde = { version = "1", features = ["derive"] }
1023tokio = "1"
1024serde_json = "1"
1025
1026[dev-dependencies]
1027tempfile = "3"
1028"#;
1029 let mut deps = Vec::new();
1030 DeepResearchSkill::extract_cargo_deps(content, &["serde"], &mut deps);
1031 assert!(deps.iter().any(|d| d.contains("serde")));
1032 }
1033
1034 #[test]
1035 fn test_extract_npm_deps() {
1036 let content = r#"{"dependencies": {"express": "^4.18.0", "lodash": "^4.17.21"}}"#;
1037 let mut deps = Vec::new();
1038 DeepResearchSkill::extract_npm_deps(content, &["express"], &mut deps);
1039 assert!(deps.iter().any(|d| d.contains("express")));
1040 }
1041}