| 1 | ! Backup system module |
| 2 | ! Handles auto-backup of modified buffers and restoration |
| 3 | |
| 4 | module backup_module |
| 5 | use iso_fortran_env, only: int32, int64 |
| 6 | implicit none |
| 7 | private |
| 8 | |
| 9 | public :: backup_create, backup_detect, backup_list, backup_restore |
| 10 | public :: backup_delete, backup_prompt_restore, backup_info_t |
| 11 | |
| 12 | integer, parameter :: MAX_PATH_LEN = 512 |
| 13 | integer, parameter :: MAX_BACKUPS = 100 |
| 14 | |
| 15 | type :: backup_info_t |
| 16 | character(len=MAX_PATH_LEN) :: original_file = "" |
| 17 | character(len=MAX_PATH_LEN) :: backup_file = "" |
| 18 | character(len=MAX_PATH_LEN) :: timestamp = "" |
| 19 | integer(int64) :: file_size = 0 |
| 20 | end type backup_info_t |
| 21 | |
| 22 | contains |
| 23 | |
| 24 | !> Create backup of a file |
| 25 | subroutine backup_create(workspace_path, file_path, success) |
| 26 | character(len=*), intent(in) :: workspace_path, file_path |
| 27 | logical, intent(out) :: success |
| 28 | character(len=MAX_PATH_LEN) :: backup_dir, backup_file, basename |
| 29 | character(len=MAX_PATH_LEN) :: src_file, timestamp_str |
| 30 | integer :: unit_src, unit_dst, ios, i |
| 31 | character(len=1024) :: line |
| 32 | |
| 33 | success = .false. |
| 34 | |
| 35 | ! Create backup directory if needed |
| 36 | backup_dir = trim(workspace_path) // "/.fac/backups" |
| 37 | call execute_command_line("mkdir -p '" // trim(backup_dir) // "'", wait=.true.) |
| 38 | |
| 39 | ! Extract basename from file_path |
| 40 | basename = file_path |
| 41 | do i = len_trim(file_path), 1, -1 |
| 42 | if (file_path(i:i) == '/') then |
| 43 | basename = file_path(i+1:) |
| 44 | exit |
| 45 | end if |
| 46 | end do |
| 47 | |
| 48 | ! Generate timestamp-based backup filename |
| 49 | call get_timestamp(timestamp_str) |
| 50 | write(backup_file, '(A,A,A,A,A)') trim(backup_dir), '/', trim(basename), '.', trim(timestamp_str) |
| 51 | |
| 52 | ! Copy file to backup |
| 53 | src_file = file_path |
| 54 | open(newunit=unit_src, file=src_file, status='old', action='read', iostat=ios) |
| 55 | if (ios /= 0) return |
| 56 | |
| 57 | open(newunit=unit_dst, file=backup_file, status='replace', action='write', iostat=ios) |
| 58 | if (ios /= 0) then |
| 59 | close(unit_src) |
| 60 | return |
| 61 | end if |
| 62 | |
| 63 | ! Copy line by line |
| 64 | do |
| 65 | read(unit_src, '(A)', iostat=ios) line |
| 66 | if (ios /= 0) exit |
| 67 | write(unit_dst, '(A)') trim(line) |
| 68 | end do |
| 69 | |
| 70 | close(unit_src) |
| 71 | close(unit_dst) |
| 72 | |
| 73 | ! Update metadata |
| 74 | call backup_update_metadata(workspace_path, file_path, backup_file, timestamp_str) |
| 75 | |
| 76 | success = .true. |
| 77 | end subroutine backup_create |
| 78 | |
| 79 | !> Detect if backups exist for workspace |
| 80 | function backup_detect(workspace_path) result(has_backups) |
| 81 | character(len=*), intent(in) :: workspace_path |
| 82 | logical :: has_backups |
| 83 | character(len=MAX_PATH_LEN) :: metadata_file |
| 84 | integer :: unit, ios |
| 85 | |
| 86 | has_backups = .false. |
| 87 | metadata_file = trim(workspace_path) // "/.fac/backups/.backup-metadata.json" |
| 88 | |
| 89 | open(newunit=unit, file=metadata_file, status='old', iostat=ios) |
| 90 | if (ios == 0) then |
| 91 | close(unit) |
| 92 | has_backups = .true. |
| 93 | end if |
| 94 | end function backup_detect |
| 95 | |
| 96 | !> List all backups for workspace |
| 97 | subroutine backup_list(workspace_path, backups, count) |
| 98 | character(len=*), intent(in) :: workspace_path |
| 99 | type(backup_info_t), allocatable, intent(out) :: backups(:) |
| 100 | integer, intent(out) :: count |
| 101 | character(len=MAX_PATH_LEN) :: metadata_file, line |
| 102 | integer :: unit, ios |
| 103 | |
| 104 | count = 0 |
| 105 | allocate(backups(MAX_BACKUPS)) |
| 106 | |
| 107 | metadata_file = trim(workspace_path) // "/.fac/backups/.backup-metadata.json" |
| 108 | |
| 109 | open(newunit=unit, file=metadata_file, status='old', iostat=ios) |
| 110 | if (ios /= 0) return |
| 111 | |
| 112 | ! Simple JSON parsing (assumes one backup per "original_file" line) |
| 113 | do |
| 114 | read(unit, '(A)', iostat=ios) line |
| 115 | if (ios /= 0) exit |
| 116 | |
| 117 | if (index(line, '"original_file":') > 0) then |
| 118 | count = count + 1 |
| 119 | if (count > MAX_BACKUPS) exit |
| 120 | call parse_backup_entry(line, backups(count)) |
| 121 | end if |
| 122 | end do |
| 123 | |
| 124 | close(unit) |
| 125 | end subroutine backup_list |
| 126 | |
| 127 | !> Restore a backup file |
| 128 | subroutine backup_restore(backup_file, original_file, success) |
| 129 | character(len=*), intent(in) :: backup_file, original_file |
| 130 | logical, intent(out) :: success |
| 131 | integer :: unit_src, unit_dst, ios |
| 132 | character(len=1024) :: line |
| 133 | |
| 134 | success = .false. |
| 135 | |
| 136 | ! Copy backup to original location using native Fortran I/O |
| 137 | ! (avoids execute_command_line issues with flang) |
| 138 | open(newunit=unit_src, file=trim(backup_file), status='old', action='read', iostat=ios) |
| 139 | if (ios /= 0) return |
| 140 | |
| 141 | open(newunit=unit_dst, file=trim(original_file), status='replace', action='write', iostat=ios) |
| 142 | if (ios /= 0) then |
| 143 | close(unit_src) |
| 144 | return |
| 145 | end if |
| 146 | |
| 147 | ! Copy line by line |
| 148 | do |
| 149 | read(unit_src, '(A)', iostat=ios) line |
| 150 | if (ios /= 0) exit |
| 151 | write(unit_dst, '(A)') trim(line) |
| 152 | end do |
| 153 | |
| 154 | close(unit_src) |
| 155 | close(unit_dst) |
| 156 | |
| 157 | ! Delete the backup after successful restore |
| 158 | call backup_delete(backup_file) |
| 159 | success = .true. |
| 160 | end subroutine backup_restore |
| 161 | |
| 162 | !> Delete a backup file |
| 163 | subroutine backup_delete(backup_file) |
| 164 | character(len=*), intent(in) :: backup_file |
| 165 | integer :: unit, ios |
| 166 | |
| 167 | ! Delete using Fortran file operations (avoids execute_command_line issues) |
| 168 | open(newunit=unit, file=trim(backup_file), status='old', iostat=ios) |
| 169 | if (ios == 0) then |
| 170 | close(unit, status='delete') |
| 171 | end if |
| 172 | end subroutine backup_delete |
| 173 | |
| 174 | !> Prompt user to restore backup (returns 'r', 'd' (delete), or 'c' (compare)) |
| 175 | function backup_prompt_restore(original_file, current_backup, total_backups, backup_timestamp) result(choice) |
| 176 | use terminal_io_module, only: terminal_write, terminal_move_cursor, terminal_clear_screen |
| 177 | use input_handler_module, only: get_key_input |
| 178 | character(len=*), intent(in) :: original_file |
| 179 | integer, intent(in), optional :: current_backup, total_backups |
| 180 | character(len=*), intent(in), optional :: backup_timestamp |
| 181 | character :: choice |
| 182 | character(len=32) :: key_input |
| 183 | character(len=512) :: prompt |
| 184 | integer :: status |
| 185 | |
| 186 | ! Clear screen and show prompt |
| 187 | call terminal_clear_screen() |
| 188 | call terminal_move_cursor(2, 1) |
| 189 | |
| 190 | ! Build prompt with progress and timestamp if available |
| 191 | if (present(current_backup) .and. present(total_backups)) then |
| 192 | if (present(backup_timestamp)) then |
| 193 | write(prompt, '(A,I0,A,I0,A,A,A,A,A)') 'Backup found [', current_backup, & |
| 194 | ' of ', total_backups, ']: ', trim(original_file), ' (from ', trim(backup_timestamp), ')' |
| 195 | else |
| 196 | write(prompt, '(A,I0,A,I0,A,A)') 'Backup found [', current_backup, & |
| 197 | ' of ', total_backups, ']: ', trim(original_file) |
| 198 | end if |
| 199 | else |
| 200 | if (present(backup_timestamp)) then |
| 201 | write(prompt, '(A,A,A,A,A)') 'Backup found for: ', trim(original_file), & |
| 202 | ' (from ', trim(backup_timestamp), ')' |
| 203 | else |
| 204 | write(prompt, '(A,A)') 'Backup found for: ', trim(original_file) |
| 205 | end if |
| 206 | end if |
| 207 | call terminal_write(trim(prompt)) |
| 208 | |
| 209 | call terminal_move_cursor(4, 1) |
| 210 | call terminal_write('[r]estore - Use backup (current file will be replaced)') |
| 211 | |
| 212 | call terminal_move_cursor(5, 1) |
| 213 | call terminal_write('[d]elete - Delete backup and keep current file') |
| 214 | |
| 215 | call terminal_move_cursor(6, 1) |
| 216 | call terminal_write('[c]ompare - Show differences between files') |
| 217 | |
| 218 | call terminal_move_cursor(8, 1) |
| 219 | call terminal_write('Choice: ') |
| 220 | |
| 221 | ! Get user input |
| 222 | do |
| 223 | call get_key_input(key_input, status) |
| 224 | if (status == 0) then |
| 225 | if (key_input == 'r' .or. key_input == 'R') then |
| 226 | choice = 'r' |
| 227 | exit |
| 228 | else if (key_input == 'd' .or. key_input == 'D') then |
| 229 | choice = 'd' ! Delete backup |
| 230 | exit |
| 231 | else if (key_input == 'c' .or. key_input == 'C') then |
| 232 | choice = 'c' ! Compare |
| 233 | exit |
| 234 | else if (key_input == 'k' .or. key_input == 'K' .or. key_input == 'i' .or. key_input == 'I') then |
| 235 | ! Accept 'k' and 'i' for backwards compatibility |
| 236 | choice = 'd' ! Map to delete |
| 237 | exit |
| 238 | else if (key_input == 'ESCAPE') then |
| 239 | choice = 'd' ! ESC = delete backup |
| 240 | exit |
| 241 | end if |
| 242 | end if |
| 243 | end do |
| 244 | end function backup_prompt_restore |
| 245 | |
| 246 | !> Get current timestamp string (Unix epoch) |
| 247 | subroutine get_timestamp(timestamp_str) |
| 248 | character(len=*), intent(out) :: timestamp_str |
| 249 | integer :: values(8) |
| 250 | integer(int64) :: epoch |
| 251 | |
| 252 | ! Get current time |
| 253 | call date_and_time(values=values) |
| 254 | |
| 255 | ! Simple epoch calculation (approximate) |
| 256 | epoch = int(values(1) - 1970, int64) * 365_int64 * 86400_int64 + & |
| 257 | int(values(2), int64) * 30_int64 * 86400_int64 + & |
| 258 | int(values(3), int64) * 86400_int64 + & |
| 259 | int(values(5), int64) * 3600_int64 + & |
| 260 | int(values(6), int64) * 60_int64 + & |
| 261 | int(values(7), int64) |
| 262 | |
| 263 | write(timestamp_str, '(I0)') epoch |
| 264 | end subroutine get_timestamp |
| 265 | |
| 266 | !> Update backup metadata JSON |
| 267 | subroutine backup_update_metadata(workspace_path, original_file, backup_file, timestamp) |
| 268 | character(len=*), intent(in) :: workspace_path, original_file, backup_file, timestamp |
| 269 | character(len=MAX_PATH_LEN) :: metadata_file |
| 270 | integer :: unit, ios |
| 271 | |
| 272 | metadata_file = trim(workspace_path) // "/.fac/backups/.backup-metadata.json" |
| 273 | |
| 274 | ! Simple append for now (not parsing existing JSON) |
| 275 | open(newunit=unit, file=metadata_file, status='old', position='append', iostat=ios) |
| 276 | if (ios /= 0) then |
| 277 | ! Create new file |
| 278 | open(newunit=unit, file=metadata_file, status='replace') |
| 279 | write(unit, '(A)') '{' |
| 280 | write(unit, '(A)') ' "backups": [' |
| 281 | end if |
| 282 | |
| 283 | ! Write backup entry (simplified JSON) |
| 284 | write(unit, '(A)') ' {' |
| 285 | write(unit, '(A)') ' "original_file": "' // trim(original_file) // '",' |
| 286 | write(unit, '(A)') ' "backup_file": "' // trim(backup_file) // '",' |
| 287 | write(unit, '(A)') ' "timestamp": "' // trim(timestamp) // '"' |
| 288 | write(unit, '(A)') ' }' |
| 289 | |
| 290 | close(unit) |
| 291 | end subroutine backup_update_metadata |
| 292 | |
| 293 | !> Parse a backup entry from JSON line (simplified) |
| 294 | subroutine parse_backup_entry(line, backup) |
| 295 | character(len=*), intent(in) :: line |
| 296 | type(backup_info_t), intent(out) :: backup |
| 297 | integer :: quote1, quote2 |
| 298 | |
| 299 | ! Extract original_file (very simple parsing) |
| 300 | quote1 = index(line, '"', .true.) |
| 301 | if (quote1 > 0) then |
| 302 | quote2 = index(line(1:quote1-1), '"', .true.) |
| 303 | if (quote2 > 0) then |
| 304 | backup%original_file = line(quote2+1:quote1-1) |
| 305 | end if |
| 306 | end if |
| 307 | end subroutine parse_backup_entry |
| 308 | |
| 309 | end module backup_module |
| 310 |