markdown · 20390 bytes Raw Blame History

Configuration Files Specification

Version: 1.0 Last Updated: 2025-01-05


Overview

This document specifies the format and location of fac's user configuration files. These files are stored in the user's config directory and track global state across all workspaces.

Purpose:

  • Track recently opened workspaces
  • Store user-marked favorite workspaces
  • Manage backup metadata for dirty buffers

Location: Follows XDG Base Directory Specification

  • Linux/macOS: ~/.config/fac/
  • Fallback: ~/.fac/ if XDG not available

Directory Structure

~/.config/fac/
├── favorites.json           # User-marked favorite workspaces
├── recents.json            # Auto-tracked recent workspaces
└── README.txt              # Optional: explain what this directory is

favorites.json Specification

Purpose

Store user-manually marked favorite workspaces for quick access via Fortress welcome menu.

Location

~/.config/fac/favorites.json

Schema

{
  "version": "1.0",
  "favorites": [
    {
      "path": "/absolute/path/to/workspace",
      "label": "My Project",
      "added": "2025-01-05T10:30:00Z",
      "pinned": false
    }
  ]
}

Field Descriptions

Root Object

version (string, required)
  • Schema version for compatibility
  • Format: "major.minor"
  • Current: "1.0"
favorites (array of objects, required)
  • Array of favorite workspace objects
  • Order matters (display order in Fortress)
  • Can be empty

Favorite Object

path (string, required)
  • Absolute path to workspace root
  • Must be a directory (verified on load)
  • Example: "/home/user/projects/myapp"
label (string, required)
  • User-friendly display name
  • Defaults to directory basename
  • User can customize
  • Example: "My Fortran Project"
added (string, required)
  • ISO 8601 timestamp when favorite was added
  • Format: "YYYY-MM-DDTHH:MM:SSZ"
  • Used for sorting (if needed)
  • Example: "2025-01-05T10:30:00Z"
pinned (boolean, required)
  • Whether this favorite should stay at top
  • Pinned favorites shown first (Phase 7 feature)
  • Default: false

Example favorites.json

{
  "version": "1.0",
  "favorites": [
    {
      "path": "/home/user/projects/facsimile",
      "label": "fac Editor",
      "added": "2025-01-01T08:00:00Z",
      "pinned": true
    },
    {
      "path": "/home/user/projects/fortress",
      "label": "Fortress Navigator",
      "added": "2025-01-02T09:15:00Z",
      "pinned": false
    },
    {
      "path": "/home/user/Documents/thesis",
      "label": "PhD Thesis",
      "added": "2025-01-03T14:30:00Z",
      "pinned": false
    }
  ]
}

Operations

Add Favorite

  • Trigger: Press 'f' in Fortress navigator
  • Action: Add current directory to favorites list
  • Duplicate Check: If path already exists, don't add again (show message)
  • Label: Default to basename, allow edit later (Phase 7)

Remove Favorite

  • Trigger: Press 'r' or 'x' in Fortress favorites view
  • Action: Remove selected favorite from list
  • Confirmation: Prompt "Remove favorite? [y/n]"

Reorder Favorites (Phase 7)

  • Trigger: Drag/drop or move commands
  • Action: Change order in array
  • Persistence: Save immediately

recents.json Specification

Purpose

Automatically track recently opened workspaces for quick access. Similar to "Recent Files" in most editors.

Location

~/.config/fac/recents.json

Schema

{
  "version": "1.0",
  "max_recents": 20,
  "recents": [
    {
      "path": "/absolute/path/to/workspace",
      "label": "Auto-generated label",
      "last_opened": "2025-01-05T14:45:00Z",
      "open_count": 5
    }
  ]
}

Field Descriptions

Root Object

version (string, required)
  • Schema version
  • Current: "1.0"
max_recents (integer, required)
  • Maximum number of recents to track
  • Default: 20
  • When exceeded, oldest entries are removed
  • User configurable (Phase 7)
recents (array of objects, required)
  • Array of recent workspace objects
  • Sorted by last_opened (most recent first)
  • Can be empty

Recent Object

path (string, required)
  • Absolute path to workspace root
  • Example: "/home/user/projects/myapp"
label (string, required)
  • Display name (typically basename)
  • Auto-generated from path
  • Example: "myapp"
last_opened (string, required)
  • ISO 8601 timestamp of last open
  • Format: "YYYY-MM-DDTHH:MM:SSZ"
  • Updated every time workspace is opened
  • Example: "2025-01-05T14:45:00Z"
open_count (integer, required)
  • Number of times this workspace has been opened
  • Used for statistics (Phase 7)
  • Incremented on each open
  • Default: 1

Example recents.json

{
  "version": "1.0",
  "max_recents": 20,
  "recents": [
    {
      "path": "/home/user/projects/facsimile",
      "label": "facsimile",
      "last_opened": "2025-01-05T15:30:00Z",
      "open_count": 47
    },
    {
      "path": "/home/user/projects/fortress",
      "label": "fortress",
      "last_opened": "2025-01-05T10:15:00Z",
      "open_count": 23
    },
    {
      "path": "/tmp/scratch",
      "label": "scratch",
      "last_opened": "2025-01-04T16:45:00Z",
      "open_count": 3
    }
  ]
}

Operations

Add/Update Recent

  • Trigger: Every time a workspace is opened
  • Action:
    1. If path exists in recents: update last_opened, increment open_count
    2. If path doesn't exist: add new entry
    3. Re-sort by last_opened (most recent first)
    4. If count > max_recents: remove oldest entry

Clear Recents

  • Trigger: User command (Phase 7)
  • Action: Empty recents array
  • File: Keep file, just empty the array

Remove Recent

  • Trigger: Press 'r' or 'x' in Fortress recents view
  • Action: Remove selected entry
  • No confirmation: Recents are auto-tracked, safe to remove

Backup Metadata Specification

Purpose

Track backup files for dirty buffers when user quits without saving.

Location

<workspace_root>/.fac/backups/.backup-metadata.json

Schema

{
  "version": "1.0",
  "backups": [
    {
      "original_file": "src/main.f90",
      "backup_file": "src/main.f90.bak",
      "timestamp": "2025-01-05T10:30:00Z",
      "reason": "quit_without_save"
    }
  ]
}

Field Descriptions

Root Object

version (string, required)
  • Schema version
  • Current: "1.0"
backups (array of objects, required)
  • Array of backup entries
  • Can be empty
  • Cleaned up after restore or ignore

Backup Object

original_file (string, required)
  • Relative path to original file (within workspace)
  • Example: "src/main.f90"
backup_file (string, required)
  • Relative path to backup file
  • Typically {original_file}.bak
  • Example: "src/main.f90.bak"
timestamp (string, required)
  • ISO 8601 timestamp when backup was created
  • Format: "YYYY-MM-DDTHH:MM:SSZ"
  • Example: "2025-01-05T10:30:00Z"
reason (string, required)
  • Why backup was created
  • Values:
    • "quit_without_save" - User quit with dirty buffer
    • "crash" - Editor crashed (Phase 7)
    • "switch_workspace" - Switched workspace with dirty buffer (Phase 6)

Example .backup-metadata.json

{
  "version": "1.0",
  "backups": [
    {
      "original_file": "src/main.f90",
      "backup_file": "src/main.f90.bak",
      "timestamp": "2025-01-05T10:30:00Z",
      "reason": "quit_without_save"
    },
    {
      "original_file": "tests/test.f90",
      "backup_file": "tests/test.f90.bak",
      "timestamp": "2025-01-05T10:30:00Z",
      "reason": "quit_without_save"
    }
  ]
}

Operations

Create Backup

  • Trigger: Quit with dirty buffers, user chooses 'n' (don't save)
  • Action:
    1. For each dirty buffer, copy to {filename}.bak
    2. Add entry to .backup-metadata.json
    3. Write metadata atomically

Detect Backups

  • Trigger: Open workspace
  • Action:
    1. Check if .fac/backups/.backup-metadata.json exists
    2. If exists and has entries, prompt user

Restore Backup

  • Trigger: User selects 'r' (restore) in backup prompt
  • Action:
    1. Copy backup file to original location (overwrite)
    2. Remove backup file
    3. Remove entry from metadata
    4. Load file in editor

Ignore Backup

  • Trigger: User selects 'i' (ignore) in backup prompt
  • Action:
    1. Keep backup file (don't delete)
    2. Remove entry from metadata
    3. Load original file in editor

Show Diff

  • Trigger: User selects 'd' (diff) in backup prompt
  • Action:
    1. Run diff command (or internal diff)
    2. Display in pager or split view
    3. Return to prompt (user can then choose r/i)

Cleanup

  • Trigger: After restore or ignore all backups
  • Action:
    1. If metadata.backups array is empty, delete .backup-metadata.json
    2. If .fac/backups/ directory is empty, delete directory

File Operations

Creating Config Directory

On first run:

  1. Check if ~/.config/fac/ exists
  2. If not, create directory with 0755 permissions
  3. Create empty favorites.json and recents.json
  4. Optionally create README.txt explaining purpose

Fortran Example:

subroutine ensure_config_directory()
    character(len=:), allocatable :: config_path
    logical :: exists

    config_path = get_config_directory()  ! Returns ~/.config/fac/
    inquire(file=config_path, exist=exists)

    if (.not. exists) then
        call create_directory(config_path)
        call create_empty_config_files(config_path)
    end if
end subroutine

Atomic Writes

Critical: Always use atomic writes for config files to avoid corruption.

Pattern:

  1. Write to temporary file: favorites.json.tmp
  2. Verify write succeeded (check iostat)
  3. Rename temp to final: rename(tmp, final)
  4. Rename is atomic on POSIX systems

Fortran Example:

subroutine save_favorites(favorites)
    type(favorites_t), intent(in) :: favorites
    character(len=:), allocatable :: path, tmp_path
    integer :: iostat

    path = get_config_directory() // 'favorites.json'
    tmp_path = path // '.tmp'

    ! Write to temp file
    call write_json_to_file(tmp_path, favorites, iostat)

    if (iostat == 0) then
        ! Rename temp to final (atomic)
        call rename_file(tmp_path, path)
    else
        ! Handle error
        call delete_file(tmp_path)
    end if
end subroutine

Reading Config Files

Strategy: Graceful fallback on errors

subroutine load_favorites(favorites)
    type(favorites_t), intent(out) :: favorites
    character(len=:), allocatable :: path
    logical :: exists
    integer :: iostat

    path = get_config_directory() // 'favorites.json'
    inquire(file=path, exist=exists)

    if (.not. exists) then
        ! Create empty favorites
        call initialize_empty_favorites(favorites)
        call save_favorites(favorites)
        return
    end if

    call parse_json_file(path, favorites, iostat)

    if (iostat /= 0) then
        ! Corrupted file: backup and recreate
        call backup_corrupt_file(path)
        call initialize_empty_favorites(favorites)
        call save_favorites(favorites)
    end if
end subroutine

Error Handling

File Not Found

  • Action: Create with default/empty content
  • Log: No error message (expected on first run)

Permission Denied

  • Action: Warn user, run in read-only mode
  • Message: "Cannot write to config directory. Favorites/recents will not persist."

Corrupted JSON

  • Action: Backup corrupt file, create new empty config
  • Backup: Rename to {file}.corrupted.{timestamp}
  • Log: Warning message with backup location

Disk Full

  • Action: Warn user, skip update
  • Message: "Cannot save config: disk full"

Invalid Data

  • Action: Skip invalid entries, log warning
  • Example: Path doesn't exist → skip that favorite
  • Message: "Skipped invalid favorite: /nonexistent/path"

Validation Rules

On Load

favorites.json

  1. Verify version is supported
  2. For each favorite:
    • Verify path is absolute
    • Verify path exists (if not, skip with warning)
    • Verify added is valid ISO 8601
    • Clamp pinned to boolean

recents.json

  1. Verify version is supported
  2. Verify max_recents is positive integer (default 20 if invalid)
  3. For each recent:
    • Verify path is absolute
    • If path doesn't exist, remove entry (cleaned up automatically)
    • Verify last_opened is valid ISO 8601
    • Verify open_count is non-negative integer

.backup-metadata.json

  1. Verify version is supported
  2. For each backup:
    • Verify backup_file exists (if not, skip entry)
    • Verify timestamp is valid ISO 8601
    • Verify reason is valid enum value

On Save

Path Normalization

  • Always store absolute paths (no relative paths)
  • Resolve symlinks before storing
  • Remove trailing slashes

Timestamp Format

  • Always use ISO 8601: "YYYY-MM-DDTHH:MM:SSZ"
  • Always use UTC timezone (Z suffix)

Array Limits

  • favorites: No hard limit (but UX degrades after ~50)
  • recents: Enforce max_recents limit

Configuration Directory Detection

XDG Base Directory Specification

Linux/macOS:

  1. Check $XDG_CONFIG_HOME environment variable
  2. If set: use $XDG_CONFIG_HOME/fac/
  3. If not set: use ~/.config/fac/

Fallback:

  • If ~/.config/ doesn't exist and can't be created: use ~/.fac/

Windows (Future):

  • Use %APPDATA%\fac\ (e.g., C:\Users\User\AppData\Roaming\fac\)

Fortran Example:

function get_config_directory() result(path)
    character(len=:), allocatable :: path
    character(len=1024) :: xdg_config_home, home
    integer :: status

    ! Try XDG_CONFIG_HOME
    call get_environment_variable('XDG_CONFIG_HOME', xdg_config_home, status=status)
    if (status == 0 .and. len_trim(xdg_config_home) > 0) then
        path = trim(xdg_config_home) // '/fac/'
        return
    end if

    ! Try ~/.config/fac/
    call get_environment_variable('HOME', home, status=status)
    if (status == 0) then
        path = trim(home) // '/.config/fac/'
        return
    end if

    ! Fallback to ~/.fac/
    path = trim(home) // '/.fac/'
end function

Migration Strategy

Version 1.0 → 2.0 (Hypothetical)

If schema changes in future:

subroutine load_favorites(favorites)
    type(favorites_t), intent(out) :: favorites
    character(len=64) :: version
    integer :: iostat

    ! Parse JSON and get version
    call parse_json_version(path, version, iostat)

    if (version == '1.0') then
        call load_favorites_v1(favorites)
    else if (version == '2.0') then
        call load_favorites_v2(favorites)
    else
        ! Unknown version: try to load as latest, or fail gracefully
        call load_favorites_v2(favorites)
    end if
end subroutine

subroutine migrate_favorites_v1_to_v2(favorites_v1, favorites_v2)
    ! Convert old format to new format
end subroutine

Backward Compatibility:

  • Keep support for old versions for at least 2 major releases
  • Automatically migrate on load, save in new format

Security Considerations

Path Validation

  • Verify paths are absolute: Reject relative paths
  • Prevent path traversal: No .. components in stored paths
  • Resolve symlinks: Store canonical paths
  • Don't follow untrusted symlinks: Check ownership

File Permissions

  • Config directory: 0755 (rwxr-xr-x)
  • Config files: 0644 (rw-r--r--)
  • Backup files: 0644 (rw-r--r--)
  • Respect umask: Use system default permissions

Arbitrary Code Execution

  • No eval: Don't execute any code from config files
  • JSON only: Only parse JSON, no shell commands
  • Sanitize paths: Validate before using in file operations

Performance Considerations

Lazy Loading

  • Don't load favorites/recents until needed (Fortress welcome menu)
  • Cache in memory during session
  • Only write on changes

Update Frequency

  • recents.json: Update once per workspace open (not every save)
  • favorites.json: Update only when user adds/removes favorite
  • Batch updates: If multiple changes, write once

File Size Limits

  • favorites.json: Reasonable limit ~1000 entries (~100KB)
  • recents.json: Hard limit max_recents (default 20)
  • backup-metadata.json: Limit ~100 backups per workspace (~10KB)

Testing Strategy

Test Cases

favorites.json

  1. Create empty favorites file on first run
  2. Add favorite, verify saved correctly
  3. Remove favorite, verify saved correctly
  4. Load favorites on startup
  5. Handle corrupted JSON (backup and recreate)
  6. Handle nonexistent paths (skip with warning)
  7. Handle duplicate paths (don't add duplicate)

recents.json

  1. Create empty recents on first run
  2. Open workspace, verify added to recents
  3. Open same workspace again, verify last_opened updated
  4. Open max_recents + 1 workspaces, verify oldest removed
  5. Load recents on startup
  6. Handle corrupted JSON (backup and recreate)
  7. Verify sorting (most recent first)

backup-metadata.json

  1. Quit with dirty buffer, verify backup created
  2. Open workspace, detect backup, prompt user
  3. Restore backup, verify file restored
  4. Ignore backup, verify metadata cleaned up
  5. Show diff, verify diff displayed
  6. Handle missing backup file (skip entry)
  7. Cleanup empty metadata file

Edge Cases

  • Config directory doesn't exist (create it)
  • Config directory not writable (read-only mode)
  • Disk full during save (handle gracefully)
  • Home directory not set (fallback to /tmp?)
  • JSON with BOM (byte order mark) - handle or reject
  • JSON with comments (non-standard) - reject

Implementation Notes

JSON Parsing

Option 1: Use external library (e.g., json-fortran)

  • Pros: Robust, well-tested, full JSON support
  • Cons: External dependency

Option 2: Write simple parser for our specific format

  • Pros: No dependencies, full control
  • Cons: More work, potential bugs

Recommendation: Use json-fortran (fpm package)

Alternative: If no dependencies allowed, write minimal parser for our specific schema (we only need basic key-value parsing, not full JSON)

Timestamp Generation

Fortran 2003+:

use iso_fortran_env, only: int64

function get_iso8601_timestamp() result(timestamp)
    character(len=24) :: timestamp
    integer :: values(8)

    call date_and_time(values=values)

    write(timestamp, '(I4.4,"-",I2.2,"-",I2.2,"T",I2.2,":",I2.2,":",I2.2,"Z")') &
        values(1), values(2), values(3), values(5), values(6), values(7)
end function

Path Operations

Use existing fac modules:

  • src/fortress/filesystem/path_utils_module.f90 - Path manipulation
  • src/fortress/filesystem/directory_module.f90 - Directory operations

Or POSIX C bindings:

interface
    function c_realpath(path, resolved) bind(C, name="realpath")
        use iso_c_binding
        type(c_ptr), value :: path
        type(c_ptr), value :: resolved
        type(c_ptr) :: c_realpath
    end function
end interface

  • WORKSPACE_VISION.md - Overall design vision
  • workspace_spec.md - workspace.json format
  • fortress_integration.md - Fortress integration details
  • WORKSPACE_ROADMAP.md - Implementation phases

Appendix: JSON Examples

Empty Configuration (First Run)

favorites.json:

{
  "version": "1.0",
  "favorites": []
}

recents.json:

{
  "version": "1.0",
  "max_recents": 20,
  "recents": []
}

Populated Configuration

favorites.json:

{
  "version": "1.0",
  "favorites": [
    {
      "path": "/home/user/projects/facsimile",
      "label": "fac Editor",
      "added": "2025-01-01T08:00:00Z",
      "pinned": true
    },
    {
      "path": "/home/user/projects/fortress",
      "label": "Fortress",
      "added": "2025-01-02T09:15:00Z",
      "pinned": false
    }
  ]
}

recents.json:

{
  "version": "1.0",
  "max_recents": 20,
  "recents": [
    {
      "path": "/home/user/projects/facsimile",
      "label": "facsimile",
      "last_opened": "2025-01-05T15:30:00Z",
      "open_count": 47
    }
  ]
}

End of Specification

View source
1 # Configuration Files Specification
2
3 **Version**: 1.0
4 **Last Updated**: 2025-01-05
5
6 ---
7
8 ## Overview
9
10 This document specifies the format and location of fac's user configuration files. These files are stored in the user's config directory and track global state across all workspaces.
11
12 **Purpose**:
13 - Track recently opened workspaces
14 - Store user-marked favorite workspaces
15 - Manage backup metadata for dirty buffers
16
17 **Location**: Follows XDG Base Directory Specification
18 - **Linux/macOS**: `~/.config/fac/`
19 - **Fallback**: `~/.fac/` if XDG not available
20
21 ---
22
23 ## Directory Structure
24
25 ```
26 ~/.config/fac/
27 ├── favorites.json # User-marked favorite workspaces
28 ├── recents.json # Auto-tracked recent workspaces
29 └── README.txt # Optional: explain what this directory is
30 ```
31
32 ---
33
34 ## favorites.json Specification
35
36 ### Purpose
37 Store user-manually marked favorite workspaces for quick access via Fortress welcome menu.
38
39 ### Location
40 `~/.config/fac/favorites.json`
41
42 ### Schema
43
44 ```json
45 {
46 "version": "1.0",
47 "favorites": [
48 {
49 "path": "/absolute/path/to/workspace",
50 "label": "My Project",
51 "added": "2025-01-05T10:30:00Z",
52 "pinned": false
53 }
54 ]
55 }
56 ```
57
58 ### Field Descriptions
59
60 #### Root Object
61
62 ##### `version` (string, required)
63 - Schema version for compatibility
64 - Format: "major.minor"
65 - Current: "1.0"
66
67 ##### `favorites` (array of objects, required)
68 - Array of favorite workspace objects
69 - Order matters (display order in Fortress)
70 - Can be empty
71
72 #### Favorite Object
73
74 ##### `path` (string, required)
75 - Absolute path to workspace root
76 - Must be a directory (verified on load)
77 - Example: "/home/user/projects/myapp"
78
79 ##### `label` (string, required)
80 - User-friendly display name
81 - Defaults to directory basename
82 - User can customize
83 - Example: "My Fortran Project"
84
85 ##### `added` (string, required)
86 - ISO 8601 timestamp when favorite was added
87 - Format: "YYYY-MM-DDTHH:MM:SSZ"
88 - Used for sorting (if needed)
89 - Example: "2025-01-05T10:30:00Z"
90
91 ##### `pinned` (boolean, required)
92 - Whether this favorite should stay at top
93 - Pinned favorites shown first (Phase 7 feature)
94 - Default: false
95
96 ### Example favorites.json
97
98 ```json
99 {
100 "version": "1.0",
101 "favorites": [
102 {
103 "path": "/home/user/projects/facsimile",
104 "label": "fac Editor",
105 "added": "2025-01-01T08:00:00Z",
106 "pinned": true
107 },
108 {
109 "path": "/home/user/projects/fortress",
110 "label": "Fortress Navigator",
111 "added": "2025-01-02T09:15:00Z",
112 "pinned": false
113 },
114 {
115 "path": "/home/user/Documents/thesis",
116 "label": "PhD Thesis",
117 "added": "2025-01-03T14:30:00Z",
118 "pinned": false
119 }
120 ]
121 }
122 ```
123
124 ### Operations
125
126 #### Add Favorite
127 - **Trigger**: Press 'f' in Fortress navigator
128 - **Action**: Add current directory to favorites list
129 - **Duplicate Check**: If path already exists, don't add again (show message)
130 - **Label**: Default to basename, allow edit later (Phase 7)
131
132 #### Remove Favorite
133 - **Trigger**: Press 'r' or 'x' in Fortress favorites view
134 - **Action**: Remove selected favorite from list
135 - **Confirmation**: Prompt "Remove favorite? [y/n]"
136
137 #### Reorder Favorites (Phase 7)
138 - **Trigger**: Drag/drop or move commands
139 - **Action**: Change order in array
140 - **Persistence**: Save immediately
141
142 ---
143
144 ## recents.json Specification
145
146 ### Purpose
147 Automatically track recently opened workspaces for quick access. Similar to "Recent Files" in most editors.
148
149 ### Location
150 `~/.config/fac/recents.json`
151
152 ### Schema
153
154 ```json
155 {
156 "version": "1.0",
157 "max_recents": 20,
158 "recents": [
159 {
160 "path": "/absolute/path/to/workspace",
161 "label": "Auto-generated label",
162 "last_opened": "2025-01-05T14:45:00Z",
163 "open_count": 5
164 }
165 ]
166 }
167 ```
168
169 ### Field Descriptions
170
171 #### Root Object
172
173 ##### `version` (string, required)
174 - Schema version
175 - Current: "1.0"
176
177 ##### `max_recents` (integer, required)
178 - Maximum number of recents to track
179 - Default: 20
180 - When exceeded, oldest entries are removed
181 - User configurable (Phase 7)
182
183 ##### `recents` (array of objects, required)
184 - Array of recent workspace objects
185 - Sorted by `last_opened` (most recent first)
186 - Can be empty
187
188 #### Recent Object
189
190 ##### `path` (string, required)
191 - Absolute path to workspace root
192 - Example: "/home/user/projects/myapp"
193
194 ##### `label` (string, required)
195 - Display name (typically basename)
196 - Auto-generated from path
197 - Example: "myapp"
198
199 ##### `last_opened` (string, required)
200 - ISO 8601 timestamp of last open
201 - Format: "YYYY-MM-DDTHH:MM:SSZ"
202 - Updated every time workspace is opened
203 - Example: "2025-01-05T14:45:00Z"
204
205 ##### `open_count` (integer, required)
206 - Number of times this workspace has been opened
207 - Used for statistics (Phase 7)
208 - Incremented on each open
209 - Default: 1
210
211 ### Example recents.json
212
213 ```json
214 {
215 "version": "1.0",
216 "max_recents": 20,
217 "recents": [
218 {
219 "path": "/home/user/projects/facsimile",
220 "label": "facsimile",
221 "last_opened": "2025-01-05T15:30:00Z",
222 "open_count": 47
223 },
224 {
225 "path": "/home/user/projects/fortress",
226 "label": "fortress",
227 "last_opened": "2025-01-05T10:15:00Z",
228 "open_count": 23
229 },
230 {
231 "path": "/tmp/scratch",
232 "label": "scratch",
233 "last_opened": "2025-01-04T16:45:00Z",
234 "open_count": 3
235 }
236 ]
237 }
238 ```
239
240 ### Operations
241
242 #### Add/Update Recent
243 - **Trigger**: Every time a workspace is opened
244 - **Action**:
245 1. If path exists in recents: update `last_opened`, increment `open_count`
246 2. If path doesn't exist: add new entry
247 3. Re-sort by `last_opened` (most recent first)
248 4. If count > `max_recents`: remove oldest entry
249
250 #### Clear Recents
251 - **Trigger**: User command (Phase 7)
252 - **Action**: Empty recents array
253 - **File**: Keep file, just empty the array
254
255 #### Remove Recent
256 - **Trigger**: Press 'r' or 'x' in Fortress recents view
257 - **Action**: Remove selected entry
258 - **No confirmation**: Recents are auto-tracked, safe to remove
259
260 ---
261
262 ## Backup Metadata Specification
263
264 ### Purpose
265 Track backup files for dirty buffers when user quits without saving.
266
267 ### Location
268 `<workspace_root>/.fac/backups/.backup-metadata.json`
269
270 ### Schema
271
272 ```json
273 {
274 "version": "1.0",
275 "backups": [
276 {
277 "original_file": "src/main.f90",
278 "backup_file": "src/main.f90.bak",
279 "timestamp": "2025-01-05T10:30:00Z",
280 "reason": "quit_without_save"
281 }
282 ]
283 }
284 ```
285
286 ### Field Descriptions
287
288 #### Root Object
289
290 ##### `version` (string, required)
291 - Schema version
292 - Current: "1.0"
293
294 ##### `backups` (array of objects, required)
295 - Array of backup entries
296 - Can be empty
297 - Cleaned up after restore or ignore
298
299 #### Backup Object
300
301 ##### `original_file` (string, required)
302 - Relative path to original file (within workspace)
303 - Example: "src/main.f90"
304
305 ##### `backup_file` (string, required)
306 - Relative path to backup file
307 - Typically `{original_file}.bak`
308 - Example: "src/main.f90.bak"
309
310 ##### `timestamp` (string, required)
311 - ISO 8601 timestamp when backup was created
312 - Format: "YYYY-MM-DDTHH:MM:SSZ"
313 - Example: "2025-01-05T10:30:00Z"
314
315 ##### `reason` (string, required)
316 - Why backup was created
317 - Values:
318 - `"quit_without_save"` - User quit with dirty buffer
319 - `"crash"` - Editor crashed (Phase 7)
320 - `"switch_workspace"` - Switched workspace with dirty buffer (Phase 6)
321
322 ### Example .backup-metadata.json
323
324 ```json
325 {
326 "version": "1.0",
327 "backups": [
328 {
329 "original_file": "src/main.f90",
330 "backup_file": "src/main.f90.bak",
331 "timestamp": "2025-01-05T10:30:00Z",
332 "reason": "quit_without_save"
333 },
334 {
335 "original_file": "tests/test.f90",
336 "backup_file": "tests/test.f90.bak",
337 "timestamp": "2025-01-05T10:30:00Z",
338 "reason": "quit_without_save"
339 }
340 ]
341 }
342 ```
343
344 ### Operations
345
346 #### Create Backup
347 - **Trigger**: Quit with dirty buffers, user chooses 'n' (don't save)
348 - **Action**:
349 1. For each dirty buffer, copy to `{filename}.bak`
350 2. Add entry to `.backup-metadata.json`
351 3. Write metadata atomically
352
353 #### Detect Backups
354 - **Trigger**: Open workspace
355 - **Action**:
356 1. Check if `.fac/backups/.backup-metadata.json` exists
357 2. If exists and has entries, prompt user
358
359 #### Restore Backup
360 - **Trigger**: User selects 'r' (restore) in backup prompt
361 - **Action**:
362 1. Copy backup file to original location (overwrite)
363 2. Remove backup file
364 3. Remove entry from metadata
365 4. Load file in editor
366
367 #### Ignore Backup
368 - **Trigger**: User selects 'i' (ignore) in backup prompt
369 - **Action**:
370 1. Keep backup file (don't delete)
371 2. Remove entry from metadata
372 3. Load original file in editor
373
374 #### Show Diff
375 - **Trigger**: User selects 'd' (diff) in backup prompt
376 - **Action**:
377 1. Run `diff` command (or internal diff)
378 2. Display in pager or split view
379 3. Return to prompt (user can then choose r/i)
380
381 #### Cleanup
382 - **Trigger**: After restore or ignore all backups
383 - **Action**:
384 1. If metadata.backups array is empty, delete `.backup-metadata.json`
385 2. If `.fac/backups/` directory is empty, delete directory
386
387 ---
388
389 ## File Operations
390
391 ### Creating Config Directory
392
393 **On first run**:
394 1. Check if `~/.config/fac/` exists
395 2. If not, create directory with 0755 permissions
396 3. Create empty `favorites.json` and `recents.json`
397 4. Optionally create `README.txt` explaining purpose
398
399 **Fortran Example**:
400 ```fortran
401 subroutine ensure_config_directory()
402 character(len=:), allocatable :: config_path
403 logical :: exists
404
405 config_path = get_config_directory() ! Returns ~/.config/fac/
406 inquire(file=config_path, exist=exists)
407
408 if (.not. exists) then
409 call create_directory(config_path)
410 call create_empty_config_files(config_path)
411 end if
412 end subroutine
413 ```
414
415 ### Atomic Writes
416
417 **Critical**: Always use atomic writes for config files to avoid corruption.
418
419 **Pattern**:
420 1. Write to temporary file: `favorites.json.tmp`
421 2. Verify write succeeded (check iostat)
422 3. Rename temp to final: `rename(tmp, final)`
423 4. Rename is atomic on POSIX systems
424
425 **Fortran Example**:
426 ```fortran
427 subroutine save_favorites(favorites)
428 type(favorites_t), intent(in) :: favorites
429 character(len=:), allocatable :: path, tmp_path
430 integer :: iostat
431
432 path = get_config_directory() // 'favorites.json'
433 tmp_path = path // '.tmp'
434
435 ! Write to temp file
436 call write_json_to_file(tmp_path, favorites, iostat)
437
438 if (iostat == 0) then
439 ! Rename temp to final (atomic)
440 call rename_file(tmp_path, path)
441 else
442 ! Handle error
443 call delete_file(tmp_path)
444 end if
445 end subroutine
446 ```
447
448 ### Reading Config Files
449
450 **Strategy**: Graceful fallback on errors
451
452 ```fortran
453 subroutine load_favorites(favorites)
454 type(favorites_t), intent(out) :: favorites
455 character(len=:), allocatable :: path
456 logical :: exists
457 integer :: iostat
458
459 path = get_config_directory() // 'favorites.json'
460 inquire(file=path, exist=exists)
461
462 if (.not. exists) then
463 ! Create empty favorites
464 call initialize_empty_favorites(favorites)
465 call save_favorites(favorites)
466 return
467 end if
468
469 call parse_json_file(path, favorites, iostat)
470
471 if (iostat /= 0) then
472 ! Corrupted file: backup and recreate
473 call backup_corrupt_file(path)
474 call initialize_empty_favorites(favorites)
475 call save_favorites(favorites)
476 end if
477 end subroutine
478 ```
479
480 ---
481
482 ## Error Handling
483
484 ### File Not Found
485 - **Action**: Create with default/empty content
486 - **Log**: No error message (expected on first run)
487
488 ### Permission Denied
489 - **Action**: Warn user, run in read-only mode
490 - **Message**: "Cannot write to config directory. Favorites/recents will not persist."
491
492 ### Corrupted JSON
493 - **Action**: Backup corrupt file, create new empty config
494 - **Backup**: Rename to `{file}.corrupted.{timestamp}`
495 - **Log**: Warning message with backup location
496
497 ### Disk Full
498 - **Action**: Warn user, skip update
499 - **Message**: "Cannot save config: disk full"
500
501 ### Invalid Data
502 - **Action**: Skip invalid entries, log warning
503 - **Example**: Path doesn't exist → skip that favorite
504 - **Message**: "Skipped invalid favorite: /nonexistent/path"
505
506 ---
507
508 ## Validation Rules
509
510 ### On Load
511
512 #### favorites.json
513 1. Verify `version` is supported
514 2. For each favorite:
515 - Verify `path` is absolute
516 - Verify `path` exists (if not, skip with warning)
517 - Verify `added` is valid ISO 8601
518 - Clamp `pinned` to boolean
519
520 #### recents.json
521 1. Verify `version` is supported
522 2. Verify `max_recents` is positive integer (default 20 if invalid)
523 3. For each recent:
524 - Verify `path` is absolute
525 - If `path` doesn't exist, remove entry (cleaned up automatically)
526 - Verify `last_opened` is valid ISO 8601
527 - Verify `open_count` is non-negative integer
528
529 #### .backup-metadata.json
530 1. Verify `version` is supported
531 2. For each backup:
532 - Verify `backup_file` exists (if not, skip entry)
533 - Verify `timestamp` is valid ISO 8601
534 - Verify `reason` is valid enum value
535
536 ### On Save
537
538 #### Path Normalization
539 - Always store absolute paths (no relative paths)
540 - Resolve symlinks before storing
541 - Remove trailing slashes
542
543 #### Timestamp Format
544 - Always use ISO 8601: "YYYY-MM-DDTHH:MM:SSZ"
545 - Always use UTC timezone (Z suffix)
546
547 #### Array Limits
548 - `favorites`: No hard limit (but UX degrades after ~50)
549 - `recents`: Enforce `max_recents` limit
550
551 ---
552
553 ## Configuration Directory Detection
554
555 ### XDG Base Directory Specification
556
557 **Linux/macOS**:
558 1. Check `$XDG_CONFIG_HOME` environment variable
559 2. If set: use `$XDG_CONFIG_HOME/fac/`
560 3. If not set: use `~/.config/fac/`
561
562 **Fallback**:
563 - If `~/.config/` doesn't exist and can't be created: use `~/.fac/`
564
565 **Windows (Future)**:
566 - Use `%APPDATA%\fac\` (e.g., `C:\Users\User\AppData\Roaming\fac\`)
567
568 **Fortran Example**:
569 ```fortran
570 function get_config_directory() result(path)
571 character(len=:), allocatable :: path
572 character(len=1024) :: xdg_config_home, home
573 integer :: status
574
575 ! Try XDG_CONFIG_HOME
576 call get_environment_variable('XDG_CONFIG_HOME', xdg_config_home, status=status)
577 if (status == 0 .and. len_trim(xdg_config_home) > 0) then
578 path = trim(xdg_config_home) // '/fac/'
579 return
580 end if
581
582 ! Try ~/.config/fac/
583 call get_environment_variable('HOME', home, status=status)
584 if (status == 0) then
585 path = trim(home) // '/.config/fac/'
586 return
587 end if
588
589 ! Fallback to ~/.fac/
590 path = trim(home) // '/.fac/'
591 end function
592 ```
593
594 ---
595
596 ## Migration Strategy
597
598 ### Version 1.0 → 2.0 (Hypothetical)
599
600 If schema changes in future:
601
602 ```fortran
603 subroutine load_favorites(favorites)
604 type(favorites_t), intent(out) :: favorites
605 character(len=64) :: version
606 integer :: iostat
607
608 ! Parse JSON and get version
609 call parse_json_version(path, version, iostat)
610
611 if (version == '1.0') then
612 call load_favorites_v1(favorites)
613 else if (version == '2.0') then
614 call load_favorites_v2(favorites)
615 else
616 ! Unknown version: try to load as latest, or fail gracefully
617 call load_favorites_v2(favorites)
618 end if
619 end subroutine
620
621 subroutine migrate_favorites_v1_to_v2(favorites_v1, favorites_v2)
622 ! Convert old format to new format
623 end subroutine
624 ```
625
626 **Backward Compatibility**:
627 - Keep support for old versions for at least 2 major releases
628 - Automatically migrate on load, save in new format
629
630 ---
631
632 ## Security Considerations
633
634 ### Path Validation
635 - **Verify paths are absolute**: Reject relative paths
636 - **Prevent path traversal**: No `..` components in stored paths
637 - **Resolve symlinks**: Store canonical paths
638 - **Don't follow untrusted symlinks**: Check ownership
639
640 ### File Permissions
641 - **Config directory**: 0755 (rwxr-xr-x)
642 - **Config files**: 0644 (rw-r--r--)
643 - **Backup files**: 0644 (rw-r--r--)
644 - **Respect umask**: Use system default permissions
645
646 ### Arbitrary Code Execution
647 - **No eval**: Don't execute any code from config files
648 - **JSON only**: Only parse JSON, no shell commands
649 - **Sanitize paths**: Validate before using in file operations
650
651 ---
652
653 ## Performance Considerations
654
655 ### Lazy Loading
656 - Don't load favorites/recents until needed (Fortress welcome menu)
657 - Cache in memory during session
658 - Only write on changes
659
660 ### Update Frequency
661 - **recents.json**: Update once per workspace open (not every save)
662 - **favorites.json**: Update only when user adds/removes favorite
663 - **Batch updates**: If multiple changes, write once
664
665 ### File Size Limits
666 - **favorites.json**: Reasonable limit ~1000 entries (~100KB)
667 - **recents.json**: Hard limit `max_recents` (default 20)
668 - **backup-metadata.json**: Limit ~100 backups per workspace (~10KB)
669
670 ---
671
672 ## Testing Strategy
673
674 ### Test Cases
675
676 #### favorites.json
677 1. Create empty favorites file on first run
678 2. Add favorite, verify saved correctly
679 3. Remove favorite, verify saved correctly
680 4. Load favorites on startup
681 5. Handle corrupted JSON (backup and recreate)
682 6. Handle nonexistent paths (skip with warning)
683 7. Handle duplicate paths (don't add duplicate)
684
685 #### recents.json
686 1. Create empty recents on first run
687 2. Open workspace, verify added to recents
688 3. Open same workspace again, verify `last_opened` updated
689 4. Open `max_recents + 1` workspaces, verify oldest removed
690 5. Load recents on startup
691 6. Handle corrupted JSON (backup and recreate)
692 7. Verify sorting (most recent first)
693
694 #### backup-metadata.json
695 1. Quit with dirty buffer, verify backup created
696 2. Open workspace, detect backup, prompt user
697 3. Restore backup, verify file restored
698 4. Ignore backup, verify metadata cleaned up
699 5. Show diff, verify diff displayed
700 6. Handle missing backup file (skip entry)
701 7. Cleanup empty metadata file
702
703 ### Edge Cases
704 - Config directory doesn't exist (create it)
705 - Config directory not writable (read-only mode)
706 - Disk full during save (handle gracefully)
707 - Home directory not set (fallback to /tmp?)
708 - JSON with BOM (byte order mark) - handle or reject
709 - JSON with comments (non-standard) - reject
710
711 ---
712
713 ## Implementation Notes
714
715 ### JSON Parsing
716
717 **Option 1**: Use external library (e.g., json-fortran)
718 - **Pros**: Robust, well-tested, full JSON support
719 - **Cons**: External dependency
720
721 **Option 2**: Write simple parser for our specific format
722 - **Pros**: No dependencies, full control
723 - **Cons**: More work, potential bugs
724
725 **Recommendation**: Use json-fortran (fpm package)
726
727 **Alternative**: If no dependencies allowed, write minimal parser for our specific schema (we only need basic key-value parsing, not full JSON)
728
729 ### Timestamp Generation
730
731 **Fortran 2003+**:
732 ```fortran
733 use iso_fortran_env, only: int64
734
735 function get_iso8601_timestamp() result(timestamp)
736 character(len=24) :: timestamp
737 integer :: values(8)
738
739 call date_and_time(values=values)
740
741 write(timestamp, '(I4.4,"-",I2.2,"-",I2.2,"T",I2.2,":",I2.2,":",I2.2,"Z")') &
742 values(1), values(2), values(3), values(5), values(6), values(7)
743 end function
744 ```
745
746 ### Path Operations
747
748 Use existing fac modules:
749 - `src/fortress/filesystem/path_utils_module.f90` - Path manipulation
750 - `src/fortress/filesystem/directory_module.f90` - Directory operations
751
752 Or POSIX C bindings:
753 ```fortran
754 interface
755 function c_realpath(path, resolved) bind(C, name="realpath")
756 use iso_c_binding
757 type(c_ptr), value :: path
758 type(c_ptr), value :: resolved
759 type(c_ptr) :: c_realpath
760 end function
761 end interface
762 ```
763
764 ---
765
766 ## Related Documents
767
768 - `WORKSPACE_VISION.md` - Overall design vision
769 - `workspace_spec.md` - workspace.json format
770 - `fortress_integration.md` - Fortress integration details
771 - `WORKSPACE_ROADMAP.md` - Implementation phases
772
773 ---
774
775 ## Appendix: JSON Examples
776
777 ### Empty Configuration (First Run)
778
779 **favorites.json**:
780 ```json
781 {
782 "version": "1.0",
783 "favorites": []
784 }
785 ```
786
787 **recents.json**:
788 ```json
789 {
790 "version": "1.0",
791 "max_recents": 20,
792 "recents": []
793 }
794 ```
795
796 ### Populated Configuration
797
798 **favorites.json**:
799 ```json
800 {
801 "version": "1.0",
802 "favorites": [
803 {
804 "path": "/home/user/projects/facsimile",
805 "label": "fac Editor",
806 "added": "2025-01-01T08:00:00Z",
807 "pinned": true
808 },
809 {
810 "path": "/home/user/projects/fortress",
811 "label": "Fortress",
812 "added": "2025-01-02T09:15:00Z",
813 "pinned": false
814 }
815 ]
816 }
817 ```
818
819 **recents.json**:
820 ```json
821 {
822 "version": "1.0",
823 "max_recents": 20,
824 "recents": [
825 {
826 "path": "/home/user/projects/facsimile",
827 "label": "facsimile",
828 "last_opened": "2025-01-05T15:30:00Z",
829 "open_count": 47
830 }
831 ]
832 }
833 ```
834
835 ---
836
837 **End of Specification**