Module: Textus::MigrateKeys

Defined in:
lib/textus/migrate_keys.rb

Overview

Run-once helper that renames files/directories whose basenames don’t conform to the strict key grammar (§3 of plan-1.2). Only walks nested: true manifest entries — leaf entries with illegal declared keys are caught by Manifest load and must be fixed by hand.

Constant Summary collapse

SEGMENT =
/\A[a-z0-9][a-z0-9-]*\z/

Class Method Summary collapse

Class Method Details

.apply!(store, renames) ⇒ Object


Apply




114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/textus/migrate_keys.rb', line 114

def apply!(store, renames)
  audit = Store::AuditLog.new(store.root)
  renames.each do |r|
    # Bottom-up order means a child's ancestors haven't moved yet, so
    # `from`/`to` are valid as-recorded. The audit `key` reflects the
    # eventual full key once every rename in this batch has applied.
    from = r[:from]
    to = r[:to]
    File.rename(from, to)
    new_key = compute_new_key(r, renames)
    audit.append(
      role: "script",
      verb: "migrate-keys",
      key: new_key,
      etag_before: nil,
      etag_after: nil,
      extras: { "from" => from, "to" => to },
    )
  end
end

.build_plan(store) ⇒ Object

Returns { renames: […], collisions: […] } Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir } Each collision: { target:, sources: […] }



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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/textus/migrate_keys.rb', line 36

def build_plan(store) # rubocop:disable Metrics/AbcSize
  renames = []
  target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]

  store.manifest.entries.each do |entry|
    next unless entry.nested

    base = File.join(store.root, "zones", entry.path)
    next unless File.directory?(base)

    # Walk depth-first. Order matters when computing the "new key"
    # for files inside a renamed directory: we record renames bottom-up,
    # so children are renamed before their parents on apply.
    walk(base) do |abs_path, is_dir|
      next if abs_path == base

      basename = File.basename(abs_path)
      stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
      next if stem.match?(SEGMENT)

      new_stem = normalize(stem)
      # Skip if normalization yields the same stem (e.g. already-legal
      # under a different lens). In practice match?(SEGMENT) catches that
      # above; this is a safety net.
      next if new_stem == stem

      new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
      target = File.join(File.dirname(abs_path), new_basename)
      target_buckets[target] << abs_path

      renames << {
        from: abs_path,
        to: target,
        kind: is_dir ? :dir : :file,
        entry: entry,
        base: base,
      }
    end
  end

  collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
                             .map { |t, srcs| { target: t, sources: srcs.sort } }

  # Drop colliding entries from renames (we won't apply any of them)
  colliding_targets = collisions.to_set { |c| c[:target] }
  renames.reject! { |r| colliding_targets.include?(r[:to]) }

  # Sort renames bottom-up (deepest path first) so children move before parents.
  renames.sort_by! { |r| -r[:from].count("/") }

  { renames: renames, collisions: collisions }
end

.compute_new_key(rename, renames) ⇒ Object

New full key after applying all renames up through this one.



146
147
148
149
150
151
152
153
154
155
156
# File 'lib/textus/migrate_keys.rb', line 146

def compute_new_key(rename, renames)
  base = rename[:base]
  entry = rename[:entry]
  new_to = resolve_current_path(rename[:to], renames)

  rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
  stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
  stripped ||= rel
  segs = stripped.split("/").reject(&:empty?)
  (entry.key.split(".") + segs).join(".")
end

.envelope_collision(col) ⇒ Object



171
172
173
# File 'lib/textus/migrate_keys.rb', line 171

def envelope_collision(col)
  { "target" => col[:target], "sources" => col[:sources] }
end

.envelope_rename(r) ⇒ Object


Envelope helpers




162
163
164
165
166
167
168
169
# File 'lib/textus/migrate_keys.rb', line 162

def envelope_rename(r)
  {
    "from" => r[:from],
    "to" => r[:to],
    "old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
    "new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
  }
end

.normalize(s) ⇒ Object

Deterministic transform per plan §3.



103
104
105
106
107
108
# File 'lib/textus/migrate_keys.rb', line 103

def normalize(s)
  s = s.downcase
  s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
  s = s.gsub(/-+/, "-")
  s.sub(/\A-+/, "").sub(/-+\z/, "")
end

.path_to_key(path, base, entry, kind) ⇒ Object



175
176
177
178
179
180
181
182
183
184
185
# File 'lib/textus/migrate_keys.rb', line 175

def path_to_key(path, base, entry, kind)
  rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
  stripped =
    if kind == :dir
      rel
    else
      rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
    end
  segs = stripped.split("/").reject(&:empty?)
  (entry.key.split(".") + segs).join(".")
end

.resolve_current_path(path, renames) ⇒ Object

If an ancestor of ‘path` was renamed earlier in this batch, rewrite the path.



136
137
138
139
140
141
142
143
# File 'lib/textus/migrate_keys.rb', line 136

def resolve_current_path(path, renames)
  out = path
  renames.each do |r|
    prefix = r[:from] + "/"
    out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
  end
  out
end

.run(store, write: false) ⇒ Object

Returns the envelope hash described in plan-1.2 §3.



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# File 'lib/textus/migrate_keys.rb', line 12

def run(store, write: false)
  plan = build_plan(store)
  collisions = plan[:collisions]
  renames = plan[:renames]

  ok = collisions.empty?
  apply!(store, renames) if write && ok

  {
    "protocol" => Textus::PROTOCOL,
    "mode" => write ? "write" : "dry-run",
    "renames" => renames.map { |r| envelope_rename(r) },
    "collisions" => collisions.map { |c| envelope_collision(c) },
    "ok" => ok,
  }
end

.walk(root, &block) ⇒ Object

Yields [absolute_path, is_dir] for every entry under root. Depth-first.



90
91
92
93
94
95
96
97
98
99
100
# File 'lib/textus/migrate_keys.rb', line 90

def walk(root, &block)
  Dir.each_child(root) do |name|
    abs = File.join(root, name)
    if File.directory?(abs)
      walk(abs, &block)
      yield abs, true
    else
      yield abs, false
    end
  end
end