Class: Ace::Support::Markdown::Organisms::SafeFileWriter

Inherits:
Object
  • Object
show all
Defined in:
lib/ace/support/markdown/organisms/safe_file_writer.rb

Overview

Safe file writing with backup and atomic operations Prevents corruption through temp file + move pattern

Class Method Summary collapse

Class Method Details

.cleanup_backups(file_path, keep: 5) ⇒ Integer

Cleanup old backup files

Parameters:

  • file_path (String)

    Original file path

  • keep (Integer) (defaults to: 5)

    Number of recent backups to keep (default: 5)

Returns:

  • (Integer)

    Number of backups deleted



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/ace/support/markdown/organisms/safe_file_writer.rb', line 132

def self.cleanup_backups(file_path, keep: 5)
  dir = File.dirname(file_path)
  basename = File.basename(file_path)

  # Find all backup files for this file
  backup_pattern = File.join(dir, "#{basename}.backup.*")
  backups = Dir.glob(backup_pattern).sort

  # Keep only the most recent N backups
  to_delete = backups[0...-keep] || []

  deleted = 0
  to_delete.each do |backup|
    File.delete(backup)
    deleted += 1
  rescue
    # Skip files that can't be deleted
    next
  end

  deleted
end

.create_backup(file_path) ⇒ String

Create a backup of the file

Parameters:

  • file_path (String)

    File to backup

Returns:

  • (String)

    Backup file path

Raises:



92
93
94
95
96
97
98
99
100
# File 'lib/ace/support/markdown/organisms/safe_file_writer.rb', line 92

def self.create_backup(file_path)
  raise FileOperationError, "File not found: #{file_path}" unless File.exist?(file_path)

  timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%L")
  backup_path = "#{file_path}.backup.#{timestamp}"

  FileUtils.cp(file_path, backup_path)
  backup_path
end

.perform_validation(content, validator) ⇒ Object

Perform validation on content



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/ace/support/markdown/organisms/safe_file_writer.rb', line 182

def self.perform_validation(content, validator)
  errors = []

  # Basic validation - check content can be parsed
  result = Atoms::FrontmatterExtractor.extract(content)
  unless result[:valid]
    errors.concat(result[:errors])
  end

  # Custom validator
  if validator.is_a?(Proc)
    custom_errors = validator.call(content)
    errors.concat(custom_errors) if custom_errors.is_a?(Array)
  end

  errors
end

.restore_from_backup(file_path, backup_path) ⇒ Hash

Restore from backup

Parameters:

  • file_path (String)

    Target file path

  • backup_path (String)

    Backup file path

Returns:

  • (Hash)

    Result with :success, :errors



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/ace/support/markdown/organisms/safe_file_writer.rb', line 106

def self.restore_from_backup(file_path, backup_path)
  unless File.exist?(backup_path)
    return {
      success: false,
      errors: ["Backup file not found: #{backup_path}"]
    }
  end

  begin
    FileUtils.cp(backup_path, file_path)
    {
      success: true,
      errors: []
    }
  rescue => e
    {
      success: false,
      errors: ["Restore failed: #{e.message}"]
    }
  end
end

.write(file_path, content, backup: true, validate: false, validator: nil) ⇒ Hash

Write content to file safely with backup and rollback

Parameters:

  • file_path (String)

    Target file path

  • content (String)

    Content to write

  • backup (Boolean) (defaults to: true)

    Create backup before writing (default: true)

  • validate (Boolean) (defaults to: false)

    Validate content before writing (default: false)

  • validator (Proc, nil) (defaults to: nil)

    Optional validation proc

Returns:

  • (Hash)

    Result with :success, :backup_path, :errors

Raises:

  • (ArgumentError)


20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/ace/support/markdown/organisms/safe_file_writer.rb', line 20

def self.write(file_path, content, backup: true, validate: false, validator: nil)
  raise ArgumentError, "File path cannot be nil" if file_path.nil?
  raise ArgumentError, "Content cannot be nil" if content.nil?

  errors = []

  # Validate content if requested
  if validate || validator
    validation_errors = perform_validation(content, validator)
    unless validation_errors.empty?
      return {
        success: false,
        backup_path: nil,
        errors: validation_errors
      }
    end
  end

  backup_path = nil

  begin
    # Create backup if requested and file exists
    if backup && File.exist?(file_path)
      backup_path = create_backup(file_path)
    end

    # Write atomically using temp file + move
    write_atomic(file_path, content)

    {
      success: true,
      backup_path: backup_path,
      errors: []
    }
  rescue => e
    # Rollback from backup if available
    if backup_path && File.exist?(backup_path)
      begin
        FileUtils.cp(backup_path, file_path)
        errors << "Write failed, restored from backup: #{e.message}"
      rescue => rollback_error
        errors << "Write failed and rollback failed: #{e.message} | #{rollback_error.message}"
      end
    else
      errors << "Write failed: #{e.message}"
    end

    {
      success: false,
      backup_path: backup_path,
      errors: errors
    }
  end
end

.write_atomic(file_path, content) ⇒ Object

Write file atomically using temp file + move



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/ace/support/markdown/organisms/safe_file_writer.rb', line 158

def self.write_atomic(file_path, content)
  # Get directory and ensure it exists
  dir = File.dirname(file_path)
  FileUtils.mkdir_p(dir) unless Dir.exist?(dir)

  # Create temp file in same directory (same filesystem for atomic move)
  temp = Tempfile.new([File.basename(file_path, ".*"), File.extname(file_path)], dir)

  begin
    # Write content to temp file
    temp.write(content)
    temp.close

    # Atomic move (rename) - this is the critical operation
    # On most filesystems, rename is atomic
    FileUtils.mv(temp.path, file_path)
  ensure
    # Clean up temp file if it still exists
    temp.close
    temp.unlink if File.exist?(temp.path)
  end
end

.write_with_validation(file_path, content, rules: {}) ⇒ Hash

Write content with automatic validation

Parameters:

  • file_path (String)

    Target file path

  • content (String)

    Content to write

  • rules (Hash) (defaults to: {})

    Validation rules

Returns:

  • (Hash)

    Result with :success, :backup_path, :errors



80
81
82
83
84
85
86
87
# File 'lib/ace/support/markdown/organisms/safe_file_writer.rb', line 80

def self.write_with_validation(file_path, content, rules: {})
  validator = lambda do |c|
    result = Atoms::DocumentValidator.validate(c, rules: rules)
    result[:valid] ? [] : result[:errors]
  end

  write(file_path, content, backup: true, validate: true, validator: validator)
end