use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Instant; use tauri::Emitter; use crate::types::{ Action, ActionKind, AnalyzeReport, Finding, LlmContext, ProjectContext, ProjectSignal, ProjectStructure, Recommendation, ReportStats, }; const MAX_FILES: u64 = 50_000; const MAX_DURATION_SECS: u64 = 60; const TOP_EXTENSIONS_N: usize = 15; const MAX_DEPTH_WARN: u32 = 6; const ROOT_FILES_WARN: u64 = 20; const EXCLUDED_DIRS: &[&str] = &[ ".git", "node_modules", "dist", "build", ".next", "target", ".cache", "coverage", ]; const MARKER_README: &[&str] = &["README", "readme", "Readme"]; const MARKER_VITE: &[&str] = &["vite.config.js", "vite.config.ts", "vite.config.mjs"]; #[derive(Default)] struct ScanState { file_count: u64, dir_count: u64, total_size_bytes: u64, extensions: HashMap, has_readme: bool, has_package_json: bool, has_cargo_toml: bool, has_env: bool, has_docker: bool, has_tsconfig: bool, has_vite: bool, has_next: bool, has_gitignore: bool, has_license: bool, has_eslint: bool, has_prettier: bool, has_tests_dir: bool, has_src: bool, has_components: bool, has_pages: bool, has_requirements_txt: bool, has_pyproject: bool, has_setup_py: bool, package_json_count: u32, cargo_toml_count: u32, root_file_count: u64, root_dirs: HashSet, max_depth: u32, } const PROGRESS_EVENT: &str = "analyze_progress"; #[tauri::command] pub fn analyze_project(window: tauri::Window, path: String) -> Result { let root = PathBuf::from(&path); if !root.exists() { return Err("Путь не существует".to_string()); } if !root.is_dir() { return Err("Путь не является папкой".to_string()); } let _ = window.emit(PROGRESS_EVENT, "Сканирую структуру…"); let deadline = Instant::now() + std::time::Duration::from_secs(MAX_DURATION_SECS); let mut state = ScanState::default(); scan_dir(&root, &root, 0, &mut state, &deadline)?; let _ = window.emit(PROGRESS_EVENT, "Анализирую архитектуру…"); let top_extensions: Vec<(String, u64)> = { let mut v: Vec<_> = state.extensions.iter().map(|(k, v)| (k.clone(), *v)).collect(); v.sort_by(|a, b| b.1.cmp(&a.1)); v.into_iter().take(TOP_EXTENSIONS_N).collect() }; let stats = ReportStats { file_count: state.file_count, dir_count: state.dir_count, total_size_bytes: state.total_size_bytes, top_extensions, max_depth: state.max_depth as u64, }; let structure = build_structure(&state); let mut findings: Vec = Vec::new(); let mut recommendations: Vec = Vec::new(); let mut signals: Vec = Vec::new(); if state.has_env { findings.push(Finding { severity: "high".to_string(), title: "Риск секретов".to_string(), details: "Обнаружены файлы .env или .env.* — не коммитьте секреты в репозиторий.".to_string(), }); signals.push(ProjectSignal { category: "security".to_string(), level: "high".to_string(), message: "Есть .env файл — риск утечки секретов.".to_string(), }); } if !state.has_readme { recommendations.push(Recommendation { title: "Добавить README".to_string(), details: "Опишите проект и инструкцию по запуску.".to_string(), priority: "medium".to_string(), effort: "low".to_string(), impact: "high".to_string(), }); signals.push(ProjectSignal { category: "quality".to_string(), level: "warn".to_string(), message: "Нет README.".to_string(), }); } if !state.has_gitignore { recommendations.push(Recommendation { title: "Добавить .gitignore".to_string(), details: "Исключите артефакты сборки и зависимости из репозитория.".to_string(), priority: "medium".to_string(), effort: "low".to_string(), impact: "medium".to_string(), }); signals.push(ProjectSignal { category: "quality".to_string(), level: "warn".to_string(), message: "Нет .gitignore.".to_string(), }); } if !state.has_license { recommendations.push(Recommendation { title: "Указать лицензию".to_string(), details: "Добавьте LICENSE или LICENSE.md.".to_string(), priority: "low".to_string(), effort: "low".to_string(), impact: "medium".to_string(), }); signals.push(ProjectSignal { category: "quality".to_string(), level: "info".to_string(), message: "Нет файла лицензии.".to_string(), }); } if state.has_src && !state.has_tests_dir { recommendations.push(Recommendation { title: "Добавить тесты".to_string(), details: "Есть src/, но нет папки tests/ — добавьте базовые тесты.".to_string(), priority: "high".to_string(), effort: "medium".to_string(), impact: "high".to_string(), }); signals.push(ProjectSignal { category: "structure".to_string(), level: "warn".to_string(), message: "Есть src/, нет tests/.".to_string(), }); } if state.has_components && !state.has_pages && state.has_package_json { recommendations.push(Recommendation { title: "Проверить структуру фронтенда".to_string(), details: "Есть components/, но нет pages/ — возможно, маршруты или страницы в другом месте.".to_string(), priority: "low".to_string(), effort: "low".to_string(), impact: "low".to_string(), }); } if state.root_file_count >= ROOT_FILES_WARN { findings.push(Finding { severity: "warn".to_string(), title: "Много файлов в корне".to_string(), details: format!("В корне {} файлов — рассмотрите группировку по папкам.", state.root_file_count), }); signals.push(ProjectSignal { category: "structure".to_string(), level: "warn".to_string(), message: "Слишком много файлов в корне проекта.".to_string(), }); } if state.max_depth >= MAX_DEPTH_WARN { findings.push(Finding { severity: "warn".to_string(), title: "Глубокая вложенность".to_string(), details: format!("Вложенность до {} уровней — усложняет навигацию.", state.max_depth), }); signals.push(ProjectSignal { category: "structure".to_string(), level: "warn".to_string(), message: "Глубокая вложенность папок.".to_string(), }); } if state.has_package_json && !state.has_eslint && !state.has_cargo_toml { recommendations.push(Recommendation { title: "Добавить линтер".to_string(), details: "Рекомендуется ESLint (и при необходимости Prettier) для JavaScript/TypeScript.".to_string(), priority: "medium".to_string(), effort: "low".to_string(), impact: "medium".to_string(), }); } if state.has_cargo_toml && !state.has_eslint { recommendations.push(Recommendation { title: "Использовать Clippy".to_string(), details: "Добавьте в CI или pre-commit: cargo clippy.".to_string(), priority: "medium".to_string(), effort: "low".to_string(), impact: "medium".to_string(), }); } if !state.has_package_json && !state.has_cargo_toml && !state.has_pyproject && !state.has_requirements_txt { findings.push(Finding { severity: "warn".to_string(), title: "Неопределён тип проекта".to_string(), details: "Не найдены привычные манифесты (package.json, Cargo.toml, pyproject.toml).".to_string(), }); } if state.file_count > 30_000 || state.dir_count > 5_000 { recommendations.push(Recommendation { title: "Сузить область анализа".to_string(), details: "Очень много файлов или папок — добавьте исключения или выберите подпапку.".to_string(), priority: "medium".to_string(), effort: "low".to_string(), impact: "low".to_string(), }); } let _ = window.emit(PROGRESS_EVENT, "Формирую вывод…"); let recommendations = enrich_recommendations(recommendations); let project_context = build_project_context(&state, &findings, &signals); let actions = build_actions(state.has_readme, state.has_tests_dir, state.has_gitignore); let narrative = build_narrative(&state, &structure, &findings, &recommendations); let report = AnalyzeReport { path: path.clone(), narrative: narrative.clone(), stats: stats.clone(), structure: structure.clone(), project_context: project_context.clone(), findings: findings.clone(), recommendations: recommendations.clone(), actions: actions.clone(), signals: signals.clone(), report_md: String::new(), llm_context: LlmContext { concise_summary: String::new(), key_risks: Vec::new(), top_recommendations: Vec::new(), signals: Vec::new(), }, }; let report_md = build_markdown_report(&report); let llm_context = build_llm_context(&report); Ok(AnalyzeReport { path: path.clone(), narrative: report.narrative, stats: report.stats, structure: report.structure, project_context: report.project_context, findings: report.findings, recommendations: report.recommendations, actions: report.actions, signals: report.signals, report_md, llm_context, }) } fn build_actions(has_readme: bool, has_tests: bool, has_gitignore: bool) -> Vec { let mut actions = vec![]; if !has_readme { actions.push(Action { id: "add-readme".into(), title: "Добавить README.md".into(), description: "В проекте отсутствует README.md".into(), kind: ActionKind::CreateFile, path: "README.md".into(), content: Some("# Project\n\nDescribe your project.\n".into()), }); } if !has_tests { actions.push(Action { id: "add-tests-dir".into(), title: "Создать папку tests/".into(), description: "В проекте нет tests/ (минимальная структура для тестов)".into(), kind: ActionKind::CreateDir, path: "tests".into(), content: None, }); } if !has_gitignore { actions.push(Action { id: "add-gitignore".into(), title: "Добавить .gitignore".into(), description: "Рекомендуется добавить базовый .gitignore".into(), kind: ActionKind::CreateFile, path: ".gitignore".into(), content: Some("node_modules/\ndist/\nbuild/\n.target/\n.DS_Store\n".into()), }); } actions } fn enrich_recommendations(mut recs: Vec) -> Vec { for r in &mut recs { let (p, e, i) = if r.title.contains("README") { ("high", "low", "high") } else if r.title.contains("тест") || r.title.contains("тесты") || r.title.contains("Add tests") || r.title.contains("tests") { ("high", "medium", "high") } else if r.title.contains(".gitignore") { ("medium", "low", "medium") } else if r.title.contains(".env") || r.title.contains("секрет") { ("high", "low", "high") } else if r.title.contains("лицензи") { ("low", "low", "medium") } else if r.title.contains("линтер") || r.title.contains("Clippy") { ("medium", "low", "medium") } else { (r.priority.as_str(), r.effort.as_str(), r.impact.as_str()) }; r.priority = p.to_string(); r.effort = e.to_string(); r.impact = i.to_string(); } recs } fn build_project_context( state: &ScanState, findings: &[Finding], signals: &[ProjectSignal], ) -> ProjectContext { let risk_level = if state.has_env || signals.iter().any(|s| s.category == "security" && s.level == "high") { "High" } else if findings.len() > 5 || signals.iter().any(|s| s.level == "warn") { "Medium" } else { "Low" }; let complexity = if state.file_count > 5000 || state.dir_count > 500 || state.max_depth > 8 { "High" } else if state.file_count > 800 || state.dir_count > 120 { "Medium" } else { "Low" }; let maturity = if state.has_readme && (state.has_tests_dir || state.has_eslint) { "Production-like" } else if state.has_readme { "MVP" } else { "Prototype" }; let mut stack = Vec::new(); if state.has_package_json { stack.push("Node.js".to_string()); } if state.has_cargo_toml { stack.push("Rust".to_string()); } if state.has_vite { stack.push("Vite".to_string()); } if state.has_next { stack.push("Next.js".to_string()); } if state.has_pyproject || state.has_requirements_txt { stack.push("Python".to_string()); } if stack.is_empty() { stack.push("Unknown".to_string()); } let domain = if state.has_next || state.has_vite { "frontend" } else if state.has_cargo_toml { "systems" } else if state.has_package_json { "fullstack" } else { "general" } .to_string(); ProjectContext { stack, domain, maturity: maturity.to_string(), complexity: complexity.to_string(), risk_level: risk_level.to_string(), } } fn build_markdown_report(report: &AnalyzeReport) -> String { let mut md = String::new(); md.push_str("# PAPA YU — отчёт анализа проекта\n\n"); md.push_str(&report.narrative); md.push_str("\n\n---\n\n"); md.push_str("## Статистика\n\n"); md.push_str(&format!( "- Файлов: {}\n- Папок: {}\n- Max depth: {}\n- Размер: {} Б\n\n", report.stats.file_count, report.stats.dir_count, report.stats.max_depth, report.stats.total_size_bytes )); md.push_str("## Контекст проекта\n\n"); md.push_str(&format!( "- Стек: {}\n- Зрелость: {}\n- Сложность: {}\n- Риск: {}\n\n", report.project_context.stack.join(", "), report.project_context.maturity, report.project_context.complexity, report.project_context.risk_level )); if !report.findings.is_empty() { md.push_str("## Находки\n\n"); for f in &report.findings { md.push_str(&format!("- **{}**: {}\n", f.title, f.details)); } md.push_str("\n"); } if !report.recommendations.is_empty() { md.push_str("## Рекомендации\n\n"); for r in &report.recommendations { md.push_str(&format!( "- **{}** [{} / effort:{} / impact:{}]\n {}\n", r.title, r.priority, r.effort, r.impact, r.details )); } } md } fn build_llm_context(report: &AnalyzeReport) -> LlmContext { let concise_summary = format!( "{}; {}; {} файлов, {} папок. Риск: {}, зрелость: {}.", report.structure.project_type, report.structure.architecture, report.stats.file_count, report.stats.dir_count, report.project_context.risk_level, report.project_context.maturity ); let key_risks: Vec = report .findings .iter() .filter(|f| f.severity == "high") .map(|f| format!("{}: {}", f.title, f.details)) .collect(); let top_recommendations: Vec = report .recommendations .iter() .take(5) .map(|r| format!("[{}] {}", r.priority, r.title)) .collect(); LlmContext { concise_summary, key_risks, top_recommendations, signals: report.signals.clone(), } } fn build_structure(state: &ScanState) -> ProjectStructure { let mut project_type = String::new(); let mut architecture = String::new(); let mut structure_notes: Vec = Vec::new(); let is_monorepo = state.package_json_count > 1 || state.cargo_toml_count > 1; if state.has_cargo_toml { project_type = "Rust / Cargo".to_string(); architecture = "Rust-проект".to_string(); if is_monorepo { project_type = "Rust monorepo".to_string(); } } if state.has_package_json { if !project_type.is_empty() { project_type = format!("{} + Node", project_type); } else if state.has_next { project_type = "Next.js".to_string(); architecture = "React (Next.js) fullstack".to_string(); } else if state.has_vite { project_type = "React + Vite".to_string(); architecture = "Frontend SPA (Vite)".to_string(); } else { project_type = "Node.js".to_string(); architecture = "Node / frontend или backend".to_string(); } if is_monorepo && !project_type.contains("monorepo") { project_type = format!("{} (monorepo)", project_type); } } if state.has_pyproject || state.has_requirements_txt || state.has_setup_py { if !project_type.is_empty() { project_type = format!("{} + Python", project_type); } else { project_type = "Python".to_string(); architecture = "Python-проект (Django/FastAPI или скрипты)".to_string(); } } if project_type.is_empty() { project_type = "Неопределён".to_string(); architecture = "Тип по манифестам не определён".to_string(); } if state.has_src && state.has_tests_dir { structure_notes.push("Есть src/ и tests/ — хорошее разделение.".to_string()); } else if state.has_src && !state.has_tests_dir { structure_notes.push("Есть src/, нет tests/ — стоит добавить тесты.".to_string()); } if state.root_file_count >= ROOT_FILES_WARN { structure_notes.push("Много файлов в корне — структура упрощённая.".to_string()); } if state.max_depth >= MAX_DEPTH_WARN { structure_notes.push("Глубокая вложенность папок.".to_string()); } if structure_notes.is_empty() { structure_notes.push("Структура без явного разделения на домены.".to_string()); } ProjectStructure { project_type, architecture, structure_notes, } } fn build_narrative( state: &ScanState, structure: &ProjectStructure, findings: &[Finding], recommendations: &[Recommendation], ) -> String { let mut parts = Vec::new(); parts.push("Я проанализировал ваш проект.".to_string()); parts.push(format!( "Это {} ({}).", structure.project_type.to_lowercase(), structure.architecture )); if !structure.structure_notes.is_empty() { parts.push(structure.structure_notes.join(" ")); } let size_label = if state.file_count < 50 { "небольшой" } else if state.file_count < 500 { "среднего размера" } else { "крупный" }; parts.push(format!( "По размеру — {} проект: {} файлов, {} папок.", size_label, state.file_count, state.dir_count )); if !findings.is_empty() { parts.push("".to_string()); parts.push("Основные проблемы:".to_string()); for f in findings.iter().take(7) { parts.push(format!("– {}", f.title)); } } if !recommendations.is_empty() { parts.push("".to_string()); parts.push("Я бы рекомендовал начать с:".to_string()); for (i, r) in recommendations.iter().take(5).enumerate() { parts.push(format!("{}. {}", i + 1, r.title)); } } parts.join("\n\n") } fn scan_dir( root: &Path, dir: &Path, depth: u32, state: &mut ScanState, deadline: &Instant, ) -> Result<(), String> { if Instant::now() > *deadline { return Err("Превышено время анализа (таймаут)".to_string()); } if state.file_count >= MAX_FILES { return Err("Превышен лимит количества файлов".to_string()); } if depth > state.max_depth { state.max_depth = depth; } let is_root = dir == root; let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(_) => return Ok(()), }; for entry in entries.flatten() { let path = entry.path(); let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue, }; if meta.is_symlink() { continue; } if meta.is_dir() { state.dir_count += 1; let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if is_root { state.root_dirs.insert(name.to_lowercase()); } let name_lower = name.to_lowercase(); if EXCLUDED_DIRS.contains(&name) { continue; } if name_lower == "src" { state.has_src = true; } if name_lower == "tests" || name_lower == "test" || name_lower == "__tests__" { state.has_tests_dir = true; } if name_lower == "components" { state.has_components = true; } if name_lower == "pages" || name_lower == "app" { state.has_pages = true; } scan_dir(root, &path, depth + 1, state, deadline)?; continue; } state.file_count += 1; if state.file_count >= MAX_FILES { return Err("Превышен лимит количества файлов".to_string()); } if is_root { state.root_file_count += 1; } state.total_size_bytes = state.total_size_bytes.saturating_add(meta.len()); if let Some(name) = path.file_name().and_then(|n| n.to_str()) { let name_lower = name.to_lowercase(); if let Some(ext) = path.extension() { let ext = ext.to_string_lossy().to_string(); *state.extensions.entry(ext).or_insert(0) += 1; } if name_lower == "package.json" { state.has_package_json = true; state.package_json_count += 1; } if name_lower == "cargo.toml" { state.has_cargo_toml = true; state.cargo_toml_count += 1; } if name_lower == "tsconfig.json" { state.has_tsconfig = true; } if name_lower == "dockerfile" || name_lower == "docker-compose.yml" { state.has_docker = true; } if name_lower.starts_with(".env") { state.has_env = true; } if name_lower == ".gitignore" { state.has_gitignore = true; } if name_lower == "license" || name_lower == "license.md" || name_lower.starts_with("license.") { state.has_license = true; } if name_lower == "eslint.config.js" || name_lower == ".eslintrc" || name_lower.starts_with(".eslintrc") { state.has_eslint = true; } if name_lower == ".prettierrc" || name_lower == "prettier.config" || name_lower.starts_with("prettier.config") { state.has_prettier = true; } if name_lower == "next.config.js" || name_lower == "next.config.mjs" || name_lower == "next.config.ts" { state.has_next = true; } if name_lower == "requirements.txt" { state.has_requirements_txt = true; } if name_lower == "pyproject.toml" { state.has_pyproject = true; } if name_lower == "setup.py" { state.has_setup_py = true; } for m in MARKER_README { if name_lower.starts_with(&m.to_lowercase()) { state.has_readme = true; break; } } for m in MARKER_VITE { if name_lower == *m { state.has_vite = true; break; } } } } Ok(()) }