|
| 1 | +-- SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +-- (MPL-2.0 is automatic legal fallback until PMPL is formally recognised) |
| 3 | +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 4 | +-- |
| 5 | +-- RSR_Adapter body - Repository customization implementation |
| 6 | +-- |
| 7 | +-- Implements safe file operations for customizing repositories. |
| 8 | +-- All file operations validate paths to prevent traversal attacks. |
| 9 | + |
| 10 | +with Ada.Text_IO; use Ada.Text_IO; |
| 11 | +with Ada.Directories; use Ada.Directories; |
| 12 | +with Ada.Strings.Fixed; use Ada.Strings.Fixed; |
| 13 | +with Ada.Strings.Maps; use Ada.Strings.Maps; |
| 14 | +with Ada.IO_Exceptions; |
| 15 | + |
| 16 | +package body RSR_Adapter is |
| 17 | + |
| 18 | + -- Character set for path validation |
| 19 | + Safe_Path_Chars : constant Character_Set := |
| 20 | + To_Set ("abcdefghijklmnopqrstuvwxyz" & |
| 21 | + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" & |
| 22 | + "0123456789" & |
| 23 | + "-_./"); |
| 24 | + |
| 25 | + --------------------------------------------------------------------------- |
| 26 | + -- Is_Safe_Path |
| 27 | + --------------------------------------------------------------------------- |
| 28 | + function Is_Safe_Path (Path : String) return Boolean is |
| 29 | + begin |
| 30 | + -- Reject empty paths |
| 31 | + if Path'Length = 0 then |
| 32 | + return False; |
| 33 | + end if; |
| 34 | + |
| 35 | + -- Reject paths with directory traversal |
| 36 | + if Index (Path, "..") > 0 then |
| 37 | + return False; |
| 38 | + end if; |
| 39 | + |
| 40 | + -- Reject absolute paths (must be relative) |
| 41 | + if Path (Path'First) = '/' then |
| 42 | + return False; |
| 43 | + end if; |
| 44 | + |
| 45 | + -- Verify all characters are safe |
| 46 | + for I in Path'Range loop |
| 47 | + if not Is_In (Path (I), Safe_Path_Chars) then |
| 48 | + return False; |
| 49 | + end if; |
| 50 | + end loop; |
| 51 | + |
| 52 | + return True; |
| 53 | + end Is_Safe_Path; |
| 54 | + |
| 55 | + --------------------------------------------------------------------------- |
| 56 | + -- Get_Input |
| 57 | + --------------------------------------------------------------------------- |
| 58 | + function Get_Input (Prompt : String) return Unbounded_String is |
| 59 | + Line : String (1 .. Max_Input_Length); |
| 60 | + Last : Natural; |
| 61 | + begin |
| 62 | + Put (Prompt & " "); |
| 63 | + Get_Line (Line, Last); |
| 64 | + |
| 65 | + if Last > 0 then |
| 66 | + return To_Unbounded_String (Line (1 .. Last)); |
| 67 | + else |
| 68 | + return Null_Unbounded_String; |
| 69 | + end if; |
| 70 | + exception |
| 71 | + when Ada.IO_Exceptions.End_Error => |
| 72 | + return Null_Unbounded_String; |
| 73 | + end Get_Input; |
| 74 | + |
| 75 | + --------------------------------------------------------------------------- |
| 76 | + -- Get_AI_Target_Choice |
| 77 | + --------------------------------------------------------------------------- |
| 78 | + function Get_AI_Target_Choice return AI_Target is |
| 79 | + Choice : Unbounded_String; |
| 80 | + begin |
| 81 | + Put_Line ("Select AI Target:"); |
| 82 | + Put_Line (" 1. Claude"); |
| 83 | + Put_Line (" 2. Copilot"); |
| 84 | + Put_Line (" 3. None"); |
| 85 | + Choice := Get_Input ("Enter choice (1-3):"); |
| 86 | + |
| 87 | + declare |
| 88 | + S : constant String := To_String (Choice); |
| 89 | + begin |
| 90 | + if S = "1" or else S = "claude" or else S = "Claude" then |
| 91 | + return Claude; |
| 92 | + elsif S = "2" or else S = "copilot" or else S = "Copilot" then |
| 93 | + return Copilot; |
| 94 | + elsif S = "3" or else S = "none" or else S = "None" then |
| 95 | + return None; |
| 96 | + else |
| 97 | + Put_Line ("Invalid selection, defaulting to None"); |
| 98 | + return None; |
| 99 | + end if; |
| 100 | + end; |
| 101 | + end Get_AI_Target_Choice; |
| 102 | + |
| 103 | + --------------------------------------------------------------------------- |
| 104 | + -- Update_README |
| 105 | + --------------------------------------------------------------------------- |
| 106 | + function Update_README |
| 107 | + (Project_Name : Unbounded_String; |
| 108 | + Author : Unbounded_String) return Boolean |
| 109 | + is |
| 110 | + Readme_Path : constant String := "README.adoc"; |
| 111 | + File : File_Type; |
| 112 | + Template : constant String := |
| 113 | + "// SPDX-License-Identifier: AGPL-3.0-or-later" & ASCII.LF & |
| 114 | + "// SPDX-FileCopyrightText: 2026 " & To_String (Author) & ASCII.LF & |
| 115 | + "= " & To_String (Project_Name) & ASCII.LF & |
| 116 | + ASCII.LF & |
| 117 | + "== Overview" & ASCII.LF & |
| 118 | + ASCII.LF & |
| 119 | + "This project uses the agentic-scm template." & ASCII.LF; |
| 120 | + begin |
| 121 | + -- Check if README exists |
| 122 | + if not Exists (Readme_Path) then |
| 123 | + Put_Line ("Warning: README.adoc not found, creating new file"); |
| 124 | + end if; |
| 125 | + |
| 126 | + -- Write the updated README |
| 127 | + Create (File, Out_File, Readme_Path); |
| 128 | + Put (File, Template); |
| 129 | + Close (File); |
| 130 | + |
| 131 | + Put_Line ("Updated " & Readme_Path); |
| 132 | + return True; |
| 133 | + |
| 134 | + exception |
| 135 | + when Ada.IO_Exceptions.Name_Error => |
| 136 | + Put_Line ("Error: Cannot create " & Readme_Path); |
| 137 | + return False; |
| 138 | + when Ada.IO_Exceptions.Use_Error => |
| 139 | + Put_Line ("Error: Cannot write to " & Readme_Path); |
| 140 | + return False; |
| 141 | + end Update_README; |
| 142 | + |
| 143 | + --------------------------------------------------------------------------- |
| 144 | + -- Blank_File |
| 145 | + --------------------------------------------------------------------------- |
| 146 | + function Blank_File (Path : String) return Boolean is |
| 147 | + File : File_Type; |
| 148 | + begin |
| 149 | + -- Validate path safety |
| 150 | + if not Is_Safe_Path (Path) then |
| 151 | + Put_Line ("Error: Unsafe path rejected: " & Path); |
| 152 | + return False; |
| 153 | + end if; |
| 154 | + |
| 155 | + -- Check if file exists |
| 156 | + if not Exists (Path) then |
| 157 | + Put_Line ("Warning: File not found: " & Path); |
| 158 | + return False; |
| 159 | + end if; |
| 160 | + |
| 161 | + -- Overwrite with empty content |
| 162 | + Create (File, Out_File, Path); |
| 163 | + Close (File); |
| 164 | + |
| 165 | + Put_Line ("Blanked: " & Path); |
| 166 | + return True; |
| 167 | + |
| 168 | + exception |
| 169 | + when Ada.IO_Exceptions.Name_Error => |
| 170 | + Put_Line ("Error: Cannot access " & Path); |
| 171 | + return False; |
| 172 | + when Ada.IO_Exceptions.Use_Error => |
| 173 | + Put_Line ("Error: Cannot write to " & Path); |
| 174 | + return False; |
| 175 | + end Blank_File; |
| 176 | + |
| 177 | + --------------------------------------------------------------------------- |
| 178 | + -- Delete_File |
| 179 | + --------------------------------------------------------------------------- |
| 180 | + function Delete_File (Path : String) return Boolean is |
| 181 | + begin |
| 182 | + -- Validate path safety |
| 183 | + if not Is_Safe_Path (Path) then |
| 184 | + Put_Line ("Error: Unsafe path rejected: " & Path); |
| 185 | + return False; |
| 186 | + end if; |
| 187 | + |
| 188 | + -- Check if file exists |
| 189 | + if not Exists (Path) then |
| 190 | + Put_Line ("Warning: File not found: " & Path); |
| 191 | + return False; |
| 192 | + end if; |
| 193 | + |
| 194 | + -- Delete the file |
| 195 | + Ada.Directories.Delete_File (Path); |
| 196 | + Put_Line ("Deleted: " & Path); |
| 197 | + return True; |
| 198 | + |
| 199 | + exception |
| 200 | + when Ada.IO_Exceptions.Name_Error => |
| 201 | + Put_Line ("Error: Cannot access " & Path); |
| 202 | + return False; |
| 203 | + when Ada.IO_Exceptions.Use_Error => |
| 204 | + Put_Line ("Error: Cannot delete " & Path); |
| 205 | + return False; |
| 206 | + end Delete_File; |
| 207 | + |
| 208 | + --------------------------------------------------------------------------- |
| 209 | + -- Customize_Repo |
| 210 | + --------------------------------------------------------------------------- |
| 211 | + procedure Customize_Repo is |
| 212 | + Config : Repo_Config; |
| 213 | + AI_Context_Path : constant String := ".rhodium/ai-context.json"; |
| 214 | + Success : Boolean; |
| 215 | + begin |
| 216 | + Put_Line ("=== Repository Customization ==="); |
| 217 | + Put_Line (""); |
| 218 | + |
| 219 | + -- Gather configuration |
| 220 | + Config.Project_Name := Get_Input ("Project Name?"); |
| 221 | + Config.Author := Get_Input ("Your Name?"); |
| 222 | + Config.AI_Tool := Get_AI_Target_Choice; |
| 223 | + |
| 224 | + Put_Line (""); |
| 225 | + Put_Line ("Configuration:"); |
| 226 | + Put_Line (" Project: " & To_String (Config.Project_Name)); |
| 227 | + Put_Line (" Author: " & To_String (Config.Author)); |
| 228 | + Put_Line (" AI Tool: " & AI_Target'Image (Config.AI_Tool)); |
| 229 | + Put_Line (""); |
| 230 | + |
| 231 | + -- Update README with project info |
| 232 | + Success := Update_README (Config.Project_Name, Config.Author); |
| 233 | + if not Success then |
| 234 | + Put_Line ("Warning: README update failed"); |
| 235 | + end if; |
| 236 | + |
| 237 | + -- Handle AI context file based on selection |
| 238 | + case Config.AI_Tool is |
| 239 | + when Claude => |
| 240 | + Put_Line ("Keeping AI context for Claude"); |
| 241 | + |
| 242 | + when Copilot => |
| 243 | + Put_Line ("Keeping AI context for Copilot"); |
| 244 | + -- Could customize for Copilot-specific format here |
| 245 | + |
| 246 | + when None => |
| 247 | + -- Blank the AI context file if no AI tool selected |
| 248 | + Success := Blank_File (AI_Context_Path); |
| 249 | + if not Success then |
| 250 | + Put_Line ("Note: AI context file not found or not writable"); |
| 251 | + end if; |
| 252 | + end case; |
| 253 | + |
| 254 | + Put_Line (""); |
| 255 | + Put_Line ("Repository customization complete."); |
| 256 | + |
| 257 | + end Customize_Repo; |
| 258 | + |
| 259 | +end RSR_Adapter; |
0 commit comments