""" Solution file (.sln) generation utilities. Provides functions to: - Build solution folder hierarchy from projects - Generate complete .sln file content """ import logging from pathlib import Path from typing import Optional from .csproj_parser import get_deterministic_guid from .models import ( BYPASS_MARKER, CSHARP_PROJECT_TYPE_GUID, SOLUTION_FOLDER_TYPE_GUID, CsprojProject, SolutionFolder, ) logger = logging.getLogger(__name__) # Solution file header SOLUTION_HEADER = """\ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 """ def build_folder_hierarchy( projects: list[CsprojProject], base_dir: Path, prefix: str = "", ) -> dict[str, SolutionFolder]: """ Build solution folder hierarchy from project paths. Creates nested folders matching the physical directory structure. Args: projects: List of projects to organize base_dir: Base directory for calculating relative paths prefix: Optional prefix for folder paths (e.g., "__External") Returns: Dictionary mapping folder path to SolutionFolder object """ folders: dict[str, SolutionFolder] = {} base_dir = base_dir.resolve() for project in projects: try: rel_path = project.path.parent.relative_to(base_dir) except ValueError: # Project outside base_dir - skip folder creation continue parts = list(rel_path.parts) if not parts: continue # Add prefix if specified if prefix: parts = [prefix] + list(parts) # Create folders for each level current_path = "" parent_guid: Optional[str] = None for part in parts: if current_path: current_path = f"{current_path}/{part}" else: current_path = part if current_path not in folders: folder_guid = get_deterministic_guid( Path(current_path), Path("") ) folders[current_path] = SolutionFolder( name=part, guid=folder_guid, path=current_path, parent_guid=parent_guid, ) parent_guid = folders[current_path].guid # Assign project to its folder if current_path in folders: folders[current_path].projects.append(project) return folders def build_external_folder_hierarchy( external_groups: dict[str, list[CsprojProject]], src_root: Path, ) -> dict[str, SolutionFolder]: """ Build folder hierarchy for external dependencies. Organizes external projects under __External//. Args: external_groups: Dictionary mapping source category to projects src_root: Root of the src/ directory Returns: Dictionary mapping folder path to SolutionFolder object """ folders: dict[str, SolutionFolder] = {} src_root = src_root.resolve() # Create __External root folder external_root_path = "__External" external_root_guid = get_deterministic_guid(Path(external_root_path), Path("")) folders[external_root_path] = SolutionFolder( name="__External", guid=external_root_guid, path=external_root_path, parent_guid=None, ) for category, projects in sorted(external_groups.items()): if not projects: continue # Create category folder (e.g., __External/__Libraries, __External/Authority) category_path = f"{external_root_path}/{category}" category_guid = get_deterministic_guid(Path(category_path), Path("")) if category_path not in folders: folders[category_path] = SolutionFolder( name=category, guid=category_guid, path=category_path, parent_guid=external_root_guid, ) # For each project, create intermediate folders based on path within source for project in projects: try: if category == "__Libraries": # Path relative to src/__Libraries/ lib_root = src_root / "__Libraries" rel_path = project.path.parent.relative_to(lib_root) else: # Path relative to src// module_root = src_root / category rel_path = project.path.parent.relative_to(module_root) except ValueError: # Just put directly in category folder folders[category_path].projects.append(project) continue parts = list(rel_path.parts) if not parts: folders[category_path].projects.append(project) continue # Create intermediate folders current_path = category_path parent_guid = category_guid for part in parts: current_path = f"{current_path}/{part}" if current_path not in folders: folder_guid = get_deterministic_guid(Path(current_path), Path("")) folders[current_path] = SolutionFolder( name=part, guid=folder_guid, path=current_path, parent_guid=parent_guid, ) parent_guid = folders[current_path].guid # Assign project to deepest folder folders[current_path].projects.append(project) return folders def generate_solution_content( sln_path: Path, projects: list[CsprojProject], folders: dict[str, SolutionFolder], external_folders: Optional[dict[str, SolutionFolder]] = None, add_bypass_marker: bool = False, ) -> str: """ Generate complete .sln file content. Args: sln_path: Path where the solution will be written (for relative paths) projects: List of internal projects folders: Internal folder hierarchy external_folders: External dependency folders (optional) add_bypass_marker: Whether to add the bypass marker comment Returns: Complete .sln file content as string """ lines: list[str] = [] sln_dir = sln_path.parent.resolve() # Add header if add_bypass_marker: lines.append(BYPASS_MARKER) lines.append("") lines.append(SOLUTION_HEADER.rstrip()) # Merge folders all_folders = dict(folders) if external_folders: all_folders.update(external_folders) # Collect all projects (internal + external from folders) all_projects: list[CsprojProject] = list(projects) project_to_folder: dict[Path, str] = {} for folder_path, folder in all_folders.items(): for proj in folder.projects: if proj not in all_projects: all_projects.append(proj) project_to_folder[proj.path] = folder_path # Write solution folder entries for folder_path in sorted(all_folders.keys()): folder = all_folders[folder_path] lines.append( f'Project("{{{SOLUTION_FOLDER_TYPE_GUID}}}") = "{folder.name}", "{folder.name}", "{{{folder.guid}}}"' ) lines.append("EndProject") # Write project entries for project in sorted(all_projects, key=lambda p: p.name): rel_path = _get_relative_path(sln_dir, project.path) lines.append( f'Project("{{{CSHARP_PROJECT_TYPE_GUID}}}") = "{project.name}", "{rel_path}", "{{{project.guid}}}"' ) lines.append("EndProject") # Write Global section lines.append("Global") # SolutionConfigurationPlatforms lines.append("\tGlobalSection(SolutionConfigurationPlatforms) = preSolution") lines.append("\t\tDebug|Any CPU = Debug|Any CPU") lines.append("\t\tRelease|Any CPU = Release|Any CPU") lines.append("\tEndGlobalSection") # ProjectConfigurationPlatforms lines.append("\tGlobalSection(ProjectConfigurationPlatforms) = postSolution") for project in sorted(all_projects, key=lambda p: p.name): guid = project.guid lines.append(f"\t\t{{{guid}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU") lines.append(f"\t\t{{{guid}}}.Debug|Any CPU.Build.0 = Debug|Any CPU") lines.append(f"\t\t{{{guid}}}.Release|Any CPU.ActiveCfg = Release|Any CPU") lines.append(f"\t\t{{{guid}}}.Release|Any CPU.Build.0 = Release|Any CPU") lines.append("\tEndGlobalSection") # SolutionProperties lines.append("\tGlobalSection(SolutionProperties) = preSolution") lines.append("\t\tHideSolutionNode = FALSE") lines.append("\tEndGlobalSection") # NestedProjects - assign folders and projects to parent folders lines.append("\tGlobalSection(NestedProjects) = preSolution") # Nest folders under their parents for folder_path in sorted(all_folders.keys()): folder = all_folders[folder_path] if folder.parent_guid: lines.append(f"\t\t{{{folder.guid}}} = {{{folder.parent_guid}}}") # Nest projects under their folders for project in sorted(all_projects, key=lambda p: p.name): if project.path in project_to_folder: folder_path = project_to_folder[project.path] folder = all_folders[folder_path] lines.append(f"\t\t{{{project.guid}}} = {{{folder.guid}}}") lines.append("\tEndGlobalSection") # ExtensibilityGlobals (required by VS) lines.append("\tGlobalSection(ExtensibilityGlobals) = postSolution") # Generate a solution GUID sln_guid = get_deterministic_guid(sln_path, sln_path.parent.parent) lines.append(f"\t\tSolutionGuid = {{{sln_guid}}}") lines.append("\tEndGlobalSection") lines.append("EndGlobal") lines.append("") # Trailing newline return "\r\n".join(lines) def _get_relative_path(from_dir: Path, to_path: Path) -> str: """ Get relative path from directory to file, using backslashes. Args: from_dir: Directory to calculate from to_path: Target path Returns: Relative path with backslashes (Windows format for .sln) """ try: rel = to_path.relative_to(from_dir) return str(rel).replace("/", "\\") except ValueError: # Different drive or not relative - use absolute with backslashes return str(to_path).replace("/", "\\") def has_bypass_marker(sln_path: Path) -> bool: """ Check if a solution file has the bypass marker. Args: sln_path: Path to the solution file Returns: True if the bypass marker is found in the first 10 lines """ if not sln_path.exists(): return False try: with open(sln_path, "r", encoding="utf-8-sig") as f: for i, line in enumerate(f): if i >= 10: break if BYPASS_MARKER in line: return True except Exception as e: logger.warning(f"Failed to read solution file {sln_path}: {e}") return False def write_solution_file( sln_path: Path, content: str, dry_run: bool = False, ) -> bool: """ Write solution content to file. Args: sln_path: Path to write to content: Solution file content dry_run: If True, don't actually write Returns: True if successful (or would be successful in dry run) """ if dry_run: logger.info(f"Would write solution to: {sln_path}") return True try: # Ensure parent directory exists sln_path.parent.mkdir(parents=True, exist_ok=True) # Write with UTF-8 BOM and CRLF line endings with open(sln_path, "w", encoding="utf-8-sig", newline="\r\n") as f: f.write(content) logger.info(f"Wrote solution to: {sln_path}") return True except Exception as e: logger.error(f"Failed to write solution {sln_path}: {e}") return False