Skip to main content

oxi/skills/
deep_research.rs

1//! Deep-research skill for oxi
2//!
3//! Produces structured research reports by:
4//! 1. Analyzing a codebase or topic
5//! 2. Searching the web for information
6//! 3. Comparing approaches
7//! 4. Writing a research report to `docs/research/YYYY-MM-DD-<slug>.md`
8//!
9//! This module provides both:
10//! - A [`DeepResearchSkill`] struct that can be used programmatically
11//! - A skill content generator that produces the system-prompt instructions
12//!   for the LLM-driven deep-research workflow
13
14use anyhow::{Context, Result};
15use chrono::Utc;
16use serde::{Deserialize, Serialize};
17use std::fmt;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21// ── Research report types ────────────────────────────────────────────
22
23/// Configuration for a deep-research run.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ResearchConfig {
26    /// Topic or question to research.
27    pub topic: String,
28
29    /// Working directory for the project (used to scope codebase analysis).
30    pub working_dir: PathBuf,
31
32    /// Output directory for research reports (default: `docs/research/`).
33    #[serde(default = "default_output_dir")]
34    pub output_dir: PathBuf,
35
36    /// Maximum number of web search queries to execute.
37    #[serde(default = "default_max_searches")]
38    pub max_searches: usize,
39
40    /// Whether to include codebase analysis in the report.
41    #[serde(default = "default_true")]
42    pub analyze_codebase: bool,
43
44    /// Optional focus area to narrow the research scope.
45    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/// A single web search result.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SearchResult {
76    /// The search query used.
77    pub query: String,
78    /// Title of the result.
79    pub title: String,
80    /// URL of the result.
81    pub url: String,
82    /// Snippet or summary.
83    pub snippet: String,
84    /// Source engine (e.g., "ddg", "bing").
85    pub source: String,
86}
87
88/// An approach comparison entry.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ApproachComparison {
91    /// Name of the approach.
92    pub name: String,
93    /// Short description.
94    pub description: String,
95    /// Pros of this approach.
96    pub pros: Vec<String>,
97    /// Cons of this approach.
98    pub cons: Vec<String>,
99    /// Complexity rating (1-5).
100    pub complexity: u8,
101    /// Suitability rating for the given topic (1-5).
102    pub suitability: u8,
103}
104
105/// Codebase analysis findings.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct CodebaseAnalysis {
108    /// Relevant files found.
109    pub files: Vec<String>,
110    /// Key patterns identified.
111    pub patterns: Vec<String>,
112    /// Dependencies relevant to the topic.
113    pub dependencies: Vec<String>,
114    /// Summary of findings.
115    pub summary: String,
116}
117
118/// A complete research report.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ResearchReport {
121    /// Report metadata.
122    pub meta: ReportMeta,
123    /// The original research topic.
124    pub topic: String,
125    /// Optional focus area.
126    pub focus: Option<String>,
127    /// Executive summary.
128    pub summary: String,
129    /// Background context.
130    pub background: String,
131    /// Findings from codebase analysis (if performed).
132    pub codebase_analysis: Option<CodebaseAnalysis>,
133    /// Web search results grouped by query.
134    pub search_results: Vec<SearchResult>,
135    /// Comparison of approaches.
136    pub approaches: Vec<ApproachComparison>,
137    /// Recommended approach.
138    pub recommendation: String,
139    /// Action items / next steps.
140    pub next_steps: Vec<String>,
141    /// References (URLs, docs).
142    pub references: Vec<Reference>,
143}
144
145/// Metadata for a research report.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ReportMeta {
148    /// ISO date string (YYYY-MM-DD).
149    pub date: String,
150    /// URL-friendly slug derived from the topic.
151    pub slug: String,
152    /// Version of the report (incremented on updates).
153    pub version: u32,
154}
155
156/// A reference / citation.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct Reference {
159    /// Title of the reference.
160    pub title: String,
161    /// URL or path.
162    pub url: String,
163    /// Type of reference.
164    #[serde(rename = "type")]
165    pub ref_type: ReferenceType,
166}
167
168/// Type of reference.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum ReferenceType {
172    /// Web page or article.
173    Web,
174    /// Documentation page.
175    Documentation,
176    /// Source code file.
177    Code,
178    /// Academic paper.
179    Paper,
180    /// Other / unknown.
181    Other,
182}
183
184// ── Slug generation ──────────────────────────────────────────────────
185
186/// Convert a topic string into a URL-friendly slug.
187///
188/// ```
189/// assert_eq!(
190///     oxi::skills::deep_research::slugify("What is the best ORM for Rust?"),
191///     "what-is-the-best-orm-for-rust"
192/// );
193/// ```
194pub 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        // Skip other characters (punctuation, etc.)
209    }
210
211    // Remove trailing dash
212    if slug.ends_with('-') {
213        slug.pop();
214    }
215
216    slug
217}
218
219// ── Deep-research skill ──────────────────────────────────────────────
220
221/// The deep-research skill.
222///
223/// This struct provides methods to:
224/// - Generate a filename for a research report
225/// - Render a report as markdown
226/// - Write a report to disk
227/// - Produce the skill instructions that get injected into the LLM system prompt
228///
229/// The actual research (web searches, codebase analysis) is orchestrated by
230/// the LLM agent using its tools (bash, read, web_search). This skill provides
231/// the structure, templates, and I/O.
232pub struct DeepResearchSkill;
233
234impl DeepResearchSkill {
235    /// Create a new deep-research skill instance.
236    pub fn new() -> Self {
237        Self
238    }
239
240    /// Generate the output filename for a research report.
241    ///
242    /// Format: `YYYY-MM-DD-<slug>.md`
243    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    /// Generate the full output path for a report.
250    pub fn report_path(config: &ResearchConfig) -> PathBuf {
251        config.output_dir.join(Self::report_filename(&config.topic))
252    }
253
254    /// Render a [`ResearchReport`] as a markdown string.
255    pub fn render_markdown(report: &ResearchReport) -> String {
256        let mut md = String::with_capacity(4096);
257
258        // Title and metadata
259        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        // Executive summary
267        md.push_str("## Summary\n\n");
268        md.push_str(&report.summary);
269        md.push_str("\n\n");
270
271        // Background
272        md.push_str("## Background\n\n");
273        md.push_str(&report.background);
274        md.push_str("\n\n");
275
276        // Codebase analysis
277        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        // Search findings
308        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        // Approach comparison
321        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            // Detailed breakdowns
340            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        // Recommendation
359        md.push_str("## Recommendation\n\n");
360        md.push_str(&report.recommendation);
361        md.push_str("\n\n");
362
363        // Next steps
364        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        // References
373        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    /// Write a research report to disk.
395    ///
396    /// Creates the output directory if it doesn't exist.
397    /// Returns the path to the written file.
398    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        // Create output directory
406        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    /// Generate the skill instructions to be injected into the system prompt
422    /// when the deep-research skill is active.
423    ///
424    /// This tells the LLM how to conduct deep research using the tools
425    /// available to it (bash, read, write, web_search).
426    pub fn skill_instructions() -> String {
427        include_str!("deep_research_prompt.md").to_string()
428    }
429
430    /// Analyze a project directory to find files relevant to a topic.
431    ///
432    /// This performs a lightweight static analysis: scanning filenames,
433    /// reading key config files (Cargo.toml, package.json), and identifying
434    /// patterns. It does NOT use AI — it's a heuristic pre-pass to give
435    /// the researcher context before the LLM-driven phase.
436    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        // Walk the directory tree (depth-limited)
442        Self::walk_dir(dir, "", 0, 4, topic_keywords, &mut files, &mut patterns)?;
443
444        // Read dependency files
445        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        // Check for common patterns
462        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    /// Recursively walk a directory, collecting relevant files.
492    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            // Skip hidden dirs and common noise
513            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                // Check if the filename or extension is relevant
531                let name_lower = name.to_lowercase();
532
533                // Always include config files
534                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                // Check keyword relevance
551                let keyword_match = keywords.iter().any(|kw| {
552                    let kw_lower = kw.to_lowercase();
553                    // Match keyword in filename
554                    name_lower.contains(&kw_lower)
555                    // Also check common source extensions
556                    || 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    /// Extract relevant dependencies from a Cargo.toml file.
580    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    /// Extract relevant dependencies from a package.json file.
606    fn extract_npm_deps(content: &str, keywords: &[&str], deps: &mut Vec<String>) {
607        // Simple heuristic parse — avoid pulling in a full JSON parser dependency
608        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// ── Tests ────────────────────────────────────────────────────────────
641
642#[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        // Empty dir, no keywords — should still return valid analysis
860        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        // Create a minimal Rust project structure
868        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        // Should not include .git, target, or node_modules in files
913        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        // Create deeply nested structure
924        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        // deep.txt should not be found (depth > 4)
931        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}