papayu/desktop/src-tauri/src/commands/analyze_project.rs
2026-01-29 12:21:43 +03:00

746 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String, u64>,
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<String>,
max_depth: u32,
}
const PROGRESS_EVENT: &str = "analyze_progress";
#[tauri::command]
pub fn analyze_project(window: tauri::Window, path: String) -> Result<AnalyzeReport, String> {
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<Finding> = Vec::new();
let mut recommendations: Vec<Recommendation> = Vec::new();
let mut signals: Vec<ProjectSignal> = 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<Action> {
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<Recommendation>) -> Vec<Recommendation> {
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<String> = report
.findings
.iter()
.filter(|f| f.severity == "high")
.map(|f| format!("{}: {}", f.title, f.details))
.collect();
let top_recommendations: Vec<String> = 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<String> = 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(())
}