import re import xml.etree.ElementTree as ET from pathlib import Path, PureWindowsPath from .models import DiscoveryResult, HarnessConfig, ProjectKind, ProjectSelection from .paths import iter_source_files class DiscoveryError(RuntimeError): pass def validate_csproj(path: Path) -> None: if path.suffix.lower() != ".csproj" or not path.is_file(): raise DiscoveryError(f"expected an existing SDK-style .csproj: {path}") try: project = ET.parse(path).getroot() except (ET.ParseError, OSError) as exc: raise DiscoveryError(f"cannot read project {path}: {exc}") from exc name = lambda element: element.tag.rsplit("}", 1)[-1] if name(project) != "Project" or not ( project.get("Sdk", "").strip() or any(name(child) == "Sdk" and child.get("Name", "").strip() for child in project) ): raise DiscoveryError(f"project must use SDK style; .NET Framework is unsupported: {path}") for element in project.iter(): tag = name(element) value = (element.text or "").strip() if tag == "TargetFrameworkVersion" and value: raise DiscoveryError(f".NET Framework metadata is unsupported: {path}") if tag in {"TargetFramework", "TargetFrameworks"}: # Build evaluates inherited properties and expressions. This preflight # rejects explicit incompatible values rather than guessing expansion. for target in value.split(";"): target = target.strip() if target and "$(" not in target and not re.fullmatch(r"net8\.0(?:-[A-Za-z0-9.]+)?", target): raise DiscoveryError(f"only net8.0 targets are supported; found {target!r}: {path}") def _validate_solution(path: Path, root: Path) -> tuple[Path, ...]: try: content = path.read_text(encoding="utf-8-sig") except (OSError, UnicodeError) as exc: raise DiscoveryError(f"cannot read solution {path}: {exc}") from exc if "Microsoft Visual Studio Solution File" not in content: raise DiscoveryError(f"invalid .sln file: {path}") projects = [] folder_types = {"66A26720-8FB5-11D2-AA7E-00C04F688DDE", "2150E333-8FDC-42A3-9474-1A3956D46DE8"} csharp_types = {"FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", "9A19103F-16F7-4668-BE54-9A1E7A4F7556"} for line in content.splitlines(): if not line.lstrip().startswith("Project("): continue match = re.match(r'\s*Project\("\{([^}]+)\}"\)\s*=\s*"[^"]*",\s*"([^"]+)"', line) if not match: raise DiscoveryError(f"invalid project entry in solution: {path}") kind, reference = match.groups() if kind.upper() in folder_types: continue if kind.upper() not in csharp_types: raise DiscoveryError(f"unsupported project type in solution: {reference}") windows_path = PureWindowsPath(reference) if windows_path.drive or windows_path.root or Path(reference).is_absolute(): raise DiscoveryError(f"solution project must be repository-relative: {reference}") candidate = (path.parent / reference.replace("\\", "/")).resolve() if not candidate.is_relative_to(root): raise DiscoveryError(f"solution project resolves outside repository: {reference}") validate_csproj(candidate) projects.append(candidate) if not projects: raise DiscoveryError(f"solution has no supported C# projects: {path}") return tuple(projects) def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult: root = root.resolve() target = config.dotnet.target if target is None: files = tuple(iter_source_files(root)) solutions = [path for path in files if path.parent == root and path.suffix.lower() == ".sln"] candidates = solutions or [path for path in files if path.suffix.lower() == ".csproj"] if len(candidates) > 1: raise DiscoveryError("multiple project candidates; set dotnet.target") if candidates: target = candidates[0] elif config.project_type == "dotnet" or any( path.suffix.lower() in {".cs", ".sln", ".slnx", ".vcxproj", ".fsproj", ".vbproj"} for path in files ): raise DiscoveryError("no supported C# metadata found; set dotnet.target to a .sln or SDK-style .csproj") else: return DiscoveryResult(None) target = target.resolve() if not target.is_relative_to(root) or not target.is_file(): raise DiscoveryError(f"project target must be an existing repository file: {target}") if target.suffix.lower() == ".sln": projects = _validate_solution(target, root) else: validate_csproj(target) projects = (target,) if not config.dotnet.test_projects: raise DiscoveryError("product validation requires an explicit non-empty dotnet.testProjects list") for test_project in config.dotnet.test_projects: if not test_project.resolve().is_relative_to(root): raise DiscoveryError(f"test project resolves outside repository: {test_project}") validate_csproj(test_project) return DiscoveryResult(ProjectSelection(ProjectKind.DOTNET, target, projects))