using System.IO; namespace AxCopilot.Services.Agent; internal sealed record ProjectScaffoldProfile( string Key, string Label, IReadOnlyList TriggerTokens, IReadOnlyList ExpectedDirectories, IReadOnlyList StarterPaths, IReadOnlyList AllowedRootFiles, int MinimumDirectoryHits = 2); internal sealed record ProjectScaffoldLayoutAssessment( bool IsSatisfied, int MatchedDirectoryCount, IReadOnlyList ExistingDirectories, IReadOnlyList MissingDirectories, IReadOnlyList SuspiciousRootFiles); internal static class ProjectScaffoldProfileCatalog { private static readonly string[] s_creationVerbs = [ "create", "generate", "build", "write", "scaffold", "bootstrap", "set up", "setup", "make", "implement", "start", "만들", "생성", "작성", "구현", "구축", "세팅", "스캐폴드" ]; private static readonly string[] s_projectNouns = [ "project", "app", "application", "service", "api", "solution", "starter", "template", "boilerplate", "workspace", "repo", "repository", "library", "package", "module", "프로젝트", "앱", "애플리케이션", "서비스", "솔루션", "템플릿", "보일러플레이트", "워크스페이스", "저장소", "라이브러리", "패키지", "모듈" ]; private static readonly string[] s_languageTokens = [ "c#", "dotnet", ".net", "csproj", "wpf", "xaml", "mvvm", "viewmodel", "resourcedictionary", "resource dictionary", "javascript", "typescript", "react", "next", "vue", "nuxt", "node", "express", "python", "fastapi", "flask", "django", "java", "spring", "spring boot", "kotlin", "android", "go", "golang", "rust", "cli", "web", "desktop", "mobile", "frontend", "backend", "api", "웹", "데스크톱", "모바일", "프론트엔드", "백엔드", "파이썬", "자바", "코틀린", "안드로이드", "리액트", "뷰", "고", "러스트", "스프링", "장고", "플라스크", "패스트api" ]; private static readonly string[] s_solutionShapeTokens = [ "frontend", "backend", "desktop", "mobile", "cli", "tool", "worker", "daemon", "game", "프론트엔드", "백엔드", "데스크톱", "모바일", "도구", "워커", "게임" ]; private static readonly HashSet s_commonAllowedRootFiles = new(StringComparer.OrdinalIgnoreCase) { "README.md", ".gitignore", ".editorconfig", ".gitattributes", ".env", ".env.example", "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "tsconfig.json", "vite.config.ts", "vite.config.js", "next.config.js", "next.config.mjs", "nuxt.config.ts", "requirements.txt", "pyproject.toml", "poetry.lock", "Pipfile", "Pipfile.lock", "pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "Cargo.toml", "go.mod", "go.sum", "composer.json", "Gemfile", "index.html" }; private static readonly string[] s_relevantRootExtensions = [ ".cs", ".xaml", ".razor", ".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".py", ".java", ".kt", ".go", ".rs", ".php", ".rb", ".json", ".xml", ".yaml", ".yml" ]; private static readonly IReadOnlyList s_profiles = [ new( Key: "wpf-mvvm", Label: "WPF / MVVM desktop app", TriggerTokens: [ "wpf", "xaml", "mvvm", "viewmodel", "resourcedictionary", "resource dictionary", "csproj", "desktop app", "window", "usercontrol", "user control" ], ExpectedDirectories: ["Views", "ViewModels", "Models", "Services", "Themes", "Assets"], StarterPaths: [ "App.xaml", "App.xaml.cs", "Views/MainWindow.xaml", "Views/MainWindow.xaml.cs", "ViewModels/MainWindowViewModel.cs", "Themes/Colors.xaml" ], AllowedRootFiles: ["App.xaml", "App.xaml.cs"], MinimumDirectoryHits: 2), new( Key: "aspnet-api", Label: "ASP.NET / Web API service", TriggerTokens: [ "asp.net", "aspnet", "web api", "webapi", "minimal api", "mvc", "blazor", "razor", "controller", "swagger" ], ExpectedDirectories: ["Controllers", "Services", "Models", "Contracts", "Data", "Properties"], StarterPaths: [ "Program.cs", "Controllers/HealthController.cs", "Services/HealthService.cs", "Models/HealthStatus.cs", "appsettings.json" ], AllowedRootFiles: ["Program.cs", "appsettings.json", "appsettings.Development.json"], MinimumDirectoryHits: 2), new( Key: "react-web", Label: "React / Next / Vue frontend", TriggerTokens: [ "react", "next", "next.js", "nextjs", "vue", "nuxt", "svelte", "vite", "frontend", "front-end", "spa", "component" ], ExpectedDirectories: ["src/components", "src/pages", "src/services", "src/styles", "public"], StarterPaths: [ "src/App.tsx", "src/main.tsx", "src/components/AppShell.tsx", "src/pages/HomePage.tsx", "src/services/api.ts" ], AllowedRootFiles: ["index.html"], MinimumDirectoryHits: 2), new( Key: "node-service", Label: "Node / Express / Nest backend", TriggerTokens: [ "node", "express", "nestjs", "nest", "koa", "backend", "back-end", "rest api", "server app", "middleware" ], ExpectedDirectories: ["src/controllers", "src/routes", "src/services", "src/models", "tests"], StarterPaths: [ "src/index.ts", "src/controllers/HealthController.ts", "src/routes/index.ts", "src/services/HealthService.ts", "tests/health.spec.ts" ], AllowedRootFiles: [], MinimumDirectoryHits: 2), new( Key: "python-service", Label: "Python API service", TriggerTokens: [ "python", "fastapi", "flask", "django", "uvicorn", "backend api", "python api" ], ExpectedDirectories: ["app/api", "app/models", "app/services", "app/core", "tests"], StarterPaths: [ "app/main.py", "app/api/routes.py", "app/services/health_service.py", "app/models/health.py", "tests/test_health.py" ], AllowedRootFiles: [], MinimumDirectoryHits: 2), new( Key: "spring-service", Label: "Java / Spring Boot service", TriggerTokens: [ "spring", "spring boot", "java api", "maven", "gradle", "jpa", "controller", "repository" ], ExpectedDirectories: ["src/main/java", "src/main/resources", "src/test/java"], StarterPaths: [ "src/main/java/com/example/Application.java", "src/main/java/com/example/controller/HealthController.java", "src/main/java/com/example/service/HealthService.java", "src/main/resources/application.yml", "src/test/java/com/example/ApplicationTests.java" ], AllowedRootFiles: [], MinimumDirectoryHits: 2), new( Key: "android-kotlin", Label: "Android / Kotlin app", TriggerTokens: [ "android", "kotlin", "jetpack compose", "compose", "xml layout", "activity", "fragment" ], ExpectedDirectories: ["app/src/main/java", "app/src/main/res/layout", "app/src/main/res/values", "app/src/test"], StarterPaths: [ "app/src/main/java/com/example/MainActivity.kt", "app/src/main/res/layout/activity_main.xml", "app/src/main/res/values/colors.xml", "app/src/test/java/com/example/MainActivityTest.kt" ], AllowedRootFiles: [], MinimumDirectoryHits: 2), new( Key: "go-service", Label: "Go service", TriggerTokens: [ "golang", "go api", "go service", "gin", "echo", "fiber", "handler" ], ExpectedDirectories: ["cmd", "internal", "pkg", "api", "tests"], StarterPaths: [ "cmd/server/main.go", "internal/handler/health.go", "internal/service/health.go", "pkg/config/config.go", "tests/health_test.go" ], AllowedRootFiles: [], MinimumDirectoryHits: 2), new( Key: "rust-cli", Label: "Rust CLI / tool", TriggerTokens: [ "rust", "cargo", "cli", "command line", "terminal tool", "binary", "subcommand" ], ExpectedDirectories: ["src/bin", "src/commands", "src/domain", "tests"], StarterPaths: [ "src/main.rs", "src/commands/root.rs", "src/domain/config.rs", "tests/cli_tests.rs" ], AllowedRootFiles: [], MinimumDirectoryHits: 2), new( Key: "generic-solution", Label: "Generic structured solution", TriggerTokens: [ "project", "solution", "starter", "template", "boilerplate", "library", "module", "workspace" ], ExpectedDirectories: ["src", "tests", "docs", "config"], StarterPaths: [ "src/main.ext", "src/services/service.ext", "tests/main.spec.ext", "docs/README.md" ], AllowedRootFiles: [], MinimumDirectoryHits: 2) ]; internal static ProjectScaffoldProfile? Detect(string? userQuery, string? activeTab) { if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)) return null; if (string.IsNullOrWhiteSpace(userQuery)) return null; var query = userQuery.ToLowerInvariant(); if (!ContainsAny(query, s_creationVerbs)) return null; ProjectScaffoldProfile? bestProfile = null; var bestScore = 0; foreach (var profile in s_profiles) { var score = profile.TriggerTokens.Count(token => query.Contains(token, StringComparison.OrdinalIgnoreCase)); if (score > bestScore) { bestScore = score; bestProfile = profile; } } if (bestProfile != null && bestScore > 0) return bestProfile; if (ContainsAny(query, s_projectNouns) && ContainsAny(query, s_languageTokens)) return GetProfile("generic-solution"); if (ContainsAny(query, s_projectNouns) && ContainsAny(query, s_solutionShapeTokens)) return GetProfile("generic-solution"); return null; } internal static bool IsStructuredProjectRequest(string? userQuery, string? activeTab) => Detect(userQuery, activeTab) != null; internal static string BuildDirectoryPreview(ProjectScaffoldProfile profile, int maxCount = 5) => string.Join(", ", profile.ExpectedDirectories.Take(Math.Max(1, maxCount))); internal static string BuildStarterPathPreview(ProjectScaffoldProfile profile, int maxCount = 5) => string.Join(", ", profile.StarterPaths.Take(Math.Max(1, maxCount)).Select(path => $"'{path}'")); internal static ProjectScaffoldLayoutAssessment AssessLayout(ProjectScaffoldProfile profile, string? workFolder) { if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder)) { return new ProjectScaffoldLayoutAssessment( IsSatisfied: false, MatchedDirectoryCount: 0, ExistingDirectories: [], MissingDirectories: profile.ExpectedDirectories.ToList(), SuspiciousRootFiles: []); } var existingDirectories = profile.ExpectedDirectories .Where(dir => Directory.Exists(Path.Combine(workFolder, NormalizeDirectory(dir)))) .ToList(); var missingDirectories = profile.ExpectedDirectories .Where(dir => !existingDirectories.Contains(dir, StringComparer.OrdinalIgnoreCase)) .ToList(); var allowedRootFiles = new HashSet(s_commonAllowedRootFiles, StringComparer.OrdinalIgnoreCase); foreach (var file in profile.AllowedRootFiles) allowedRootFiles.Add(file); var suspiciousRootFiles = Directory.EnumerateFiles(workFolder) .Select(Path.GetFileName) .Where(name => !string.IsNullOrWhiteSpace(name)) .Select(name => name!) .Where(name => { if (allowedRootFiles.Contains(name)) return false; var extension = Path.GetExtension(name); return s_relevantRootExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); }) .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) .ToList(); var isSatisfied = existingDirectories.Count >= profile.MinimumDirectoryHits && suspiciousRootFiles.Count == 0; return new ProjectScaffoldLayoutAssessment( IsSatisfied: isSatisfied, MatchedDirectoryCount: existingDirectories.Count, ExistingDirectories: existingDirectories, MissingDirectories: missingDirectories, SuspiciousRootFiles: suspiciousRootFiles); } private static ProjectScaffoldProfile GetProfile(string key) => s_profiles.First(profile => string.Equals(profile.Key, key, StringComparison.OrdinalIgnoreCase)); private static string NormalizeDirectory(string value) => value.Replace('/', Path.DirectorySeparatorChar); private static bool ContainsAny(string text, IEnumerable tokens) { foreach (var token in tokens) { if (text.Contains(token, StringComparison.OrdinalIgnoreCase)) return true; } return false; } }