project-examer 0.1.0

A fast system analysis tool for scanning codebases and building intelligent relationships between files using AST parsing and LLM analysis
Documentation
use crate::config::{LLMConfig, LLMProvider};
use anyhow::{anyhow, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;

#[derive(Debug, Serialize, Deserialize)]
pub struct AnalysisRequest {
    pub prompt: String,
    pub context: AnalysisContext,
    pub analysis_type: AnalysisType,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisContext {
    pub files: Vec<FileContext>,
    pub dependencies: Vec<DependencyContext>,
    pub project_info: ProjectInfo,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileContext {
    pub path: String,
    pub language: String,
    pub content_summary: String,
    pub functions: Vec<String>,
    pub classes: Vec<String>,
    pub imports: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyContext {
    pub from_file: String,
    pub to_file: String,
    pub dependency_type: String,
    pub strength: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectInfo {
    pub name: String,
    pub total_files: usize,
    pub total_lines: usize,
    pub languages: Vec<String>,
    pub architecture_patterns: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub enum AnalysisType {
    Overview,
    Architecture,
    Dependencies,
    Security,
    Refactoring,
    Documentation,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisResponse {
    pub analysis: String,
    pub insights: Vec<Insight>,
    pub recommendations: Vec<Recommendation>,
    pub confidence: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Insight {
    pub title: String,
    pub description: String,
    pub category: InsightCategory,
    pub confidence: f64,
    pub evidence: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InsightCategory {
    Architecture,
    CodeQuality,
    Performance,
    Security,
    Maintainability,
    Testing,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendation {
    pub title: String,
    pub description: String,
    pub priority: Priority,
    pub effort: Effort,
    pub impact: Impact,
    pub action_items: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Priority {
    Low,
    Medium,
    High,
    Critical,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Effort {
    Low,
    Medium,
    High,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Impact {
    Low,
    Medium,
    High,
}

pub struct LLMClient {
    config: LLMConfig,
    client: Client,
}

impl LLMClient {
    pub fn new(config: LLMConfig) -> Self {
        let client = Client::builder()
            .timeout(Duration::from_secs(120))
            .build()
            .unwrap();

        Self { config, client }
    }

    pub async fn analyze(&self, request: AnalysisRequest) -> Result<AnalysisResponse> {
        match self.config.provider {
            LLMProvider::OpenAI => self.analyze_with_openai(request).await,
            LLMProvider::Ollama => self.analyze_with_ollama(request).await,
            LLMProvider::Anthropic => self.analyze_with_anthropic(request).await,
        }
    }

    async fn analyze_with_openai(&self, request: AnalysisRequest) -> Result<AnalysisResponse> {
        let api_key = self.config.api_key.as_ref()
            .ok_or_else(|| anyhow!("OpenAI API key not provided"))?;

        let system_prompt = self.create_system_prompt(&request.analysis_type);
        let user_prompt = self.create_user_prompt(&request);

        let payload = serde_json::json!({
            "model": self.config.model,
            "messages": [
                {
                    "role": "system",
                    "content": system_prompt
                },
                {
                    "role": "user",
                    "content": user_prompt
                }
            ],
            "max_tokens": self.config.max_tokens,
            "temperature": self.config.temperature,
            "response_format": {
                "type": "json_object"
            }
        });

        let response = self.client
            .post("https://api.openai.com/v1/chat/completions")
            .header("Authorization", format!("Bearer {}", api_key))
            .header("Content-Type", "application/json")
            .json(&payload)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await?;
            return Err(anyhow!("OpenAI API error: {}", error_text));
        }

        let response_json: serde_json::Value = response.json().await?;
        let content = response_json["choices"][0]["message"]["content"]
            .as_str()
            .ok_or_else(|| anyhow!("Invalid response format from OpenAI"))?;

        let analysis_response: AnalysisResponse = serde_json::from_str(content)?;
        Ok(analysis_response)
    }

    async fn analyze_with_ollama(&self, request: AnalysisRequest) -> Result<AnalysisResponse> {
        let default_url = "http://localhost:11434".to_string();
        let base_url = self.config.base_url.as_ref().unwrap_or(&default_url);

        let system_prompt = self.create_system_prompt(&request.analysis_type);
        let user_prompt = self.create_user_prompt(&request);

        let payload = serde_json::json!({
            "model": self.config.model,
            "messages": [
                {
                    "role": "system",
                    "content": system_prompt
                },
                {
                    "role": "user", 
                    "content": user_prompt
                }
            ],
            "stream": false,
            "format": "json",
            "options": {
                "temperature": self.config.temperature,
                "num_predict": self.config.max_tokens
            }
        });

        let response = self.client
            .post(&format!("{}/api/chat", base_url))
            .header("Content-Type", "application/json")
            .json(&payload)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await?;
            return Err(anyhow!("Ollama API error: {}", error_text));
        }

        let response_json: serde_json::Value = response.json().await?;
        let content = response_json["message"]["content"]
            .as_str()
            .ok_or_else(|| anyhow!("Invalid response format from Ollama"))?;

        let analysis_response: AnalysisResponse = serde_json::from_str(content)?;
        Ok(analysis_response)
    }

    async fn analyze_with_anthropic(&self, request: AnalysisRequest) -> Result<AnalysisResponse> {
        let api_key = self.config.api_key.as_ref()
            .ok_or_else(|| anyhow!("Anthropic API key not provided"))?;

        let system_prompt = self.create_system_prompt(&request.analysis_type);
        let user_prompt = self.create_user_prompt(&request);

        let payload = serde_json::json!({
            "model": self.config.model,
            "max_tokens": self.config.max_tokens,
            "system": system_prompt,
            "messages": [
                {
                    "role": "user",
                    "content": user_prompt
                }
            ]
        });

        let response = self.client
            .post("https://api.anthropic.com/v1/messages")
            .header("x-api-key", api_key)
            .header("Content-Type", "application/json")
            .header("anthropic-version", "2023-06-01")
            .json(&payload)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await?;
            return Err(anyhow!("Anthropic API error: {}", error_text));
        }

        let response_json: serde_json::Value = response.json().await?;
        let content = response_json["content"][0]["text"]
            .as_str()
            .ok_or_else(|| anyhow!("Invalid response format from Anthropic"))?;

        let analysis_response: AnalysisResponse = serde_json::from_str(content)?;
        Ok(analysis_response)
    }

    fn create_system_prompt(&self, analysis_type: &AnalysisType) -> String {
        match analysis_type {
            AnalysisType::Overview => {
                "You are a senior software architect analyzing a codebase. Provide a comprehensive overview of the software architecture, including key components, patterns used, and overall design philosophy. Return your response as JSON with the following structure: {\"analysis\": \"...\", \"insights\": [...], \"recommendations\": [...], \"confidence\": 0.0-1.0}".to_string()
            }
            AnalysisType::Architecture => {
                "You are a software architect expert. Analyze the architectural patterns, design principles, and structural organization of this codebase. Identify patterns like MVC, microservices, layered architecture, etc. Return your response as JSON.".to_string()
            }
            AnalysisType::Dependencies => {
                "You are a dependency analysis expert. Examine the dependency relationships, identify potential issues like circular dependencies, tight coupling, or unused dependencies. Return your response as JSON.".to_string()
            }
            AnalysisType::Security => {
                "You are a security expert analyzing code for potential vulnerabilities. Look for common security issues, insecure patterns, and provide recommendations for improvement. Return your response as JSON.".to_string()
            }
            AnalysisType::Refactoring => {
                "You are a code quality expert. Identify opportunities for refactoring, code smells, and suggest improvements for maintainability and readability. Return your response as JSON.".to_string()
            }
            AnalysisType::Documentation => {
                "You are a technical documentation expert. Generate comprehensive documentation based on the code structure and patterns. Create explanations for how the software works. Return your response as JSON.".to_string()
            }
        }
    }

    fn create_user_prompt(&self, request: &AnalysisRequest) -> String {
        let mut prompt = format!("Analyze this codebase:\n\n{}\n\n", request.prompt);

        prompt.push_str("Project Information:\n");
        prompt.push_str(&format!("- Name: {}\n", request.context.project_info.name));
        prompt.push_str(&format!("- Total files: {}\n", request.context.project_info.total_files));
        prompt.push_str(&format!("- Languages: {}\n", request.context.project_info.languages.join(", ")));

        if !request.context.files.is_empty() {
            prompt.push_str("\nFile Structure:\n");
            for file in &request.context.files {
                prompt.push_str(&format!("- {} ({})\n", file.path, file.language));
                prompt.push_str(&format!("  Functions: {}\n", file.functions.join(", ")));
                if !file.classes.is_empty() {
                    prompt.push_str(&format!("  Classes: {}\n", file.classes.join(", ")));
                }
                if !file.imports.is_empty() {
                    prompt.push_str(&format!("  Imports: {}\n", file.imports.join(", ")));
                }
            }
        }

        if !request.context.dependencies.is_empty() {
            prompt.push_str("\nDependency Relationships:\n");
            for dep in &request.context.dependencies {
                prompt.push_str(&format!("- {} -> {} ({}, strength: {:.2})\n", 
                    dep.from_file, dep.to_file, dep.dependency_type, dep.strength));
            }
        }

        prompt.push_str("\nPlease provide a detailed analysis with specific insights and actionable recommendations.");
        prompt
    }

    pub async fn batch_analyze(&self, requests: Vec<AnalysisRequest>) -> Result<Vec<AnalysisResponse>> {
        let mut responses = Vec::new();
        
        for request in requests {
            let response = self.analyze(request).await?;
            responses.push(response);
            
            tokio::time::sleep(Duration::from_millis(100)).await;
        }
        
        Ok(responses)
    }
}