Class: OKF::Registry

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/okf/registry.rb

Overview

A persistent, ordered registry of bundle references — the kernel behind the multi-bundle server. It is a plain JSON file (no database): the global one under $OKF_HOME (default ~/.okf), or a project-local .okf-registry.json discovered by walking up from cwd (see .load / .discover), which replaces the global one while you stand in its tree. Either way okf registry set/del and a later bare okf server share one on-disk list. Part of the shell — it reads and writes a file.

registry = OKF::Registry.load
registry.add("docs")               # persists, returns the Entry
registry.default = "docs"           # moves docs to the front
registry.rename("docs", "handbook") # new slug, same path
registry.default                    # => the first Entry
registry.listing                    # => [{ slug:, title:, path:, default: }]

The first entry is the default — the bundle a bare okf server opens at /. That is position, not a stored slug: a slug would be a foreign key into this same list, and every operation would owe it referential integrity — carry it through a rename, re-point it after an add --as, clear it on a remove, and survive it dangling. Order is state the registry already keeps, so default= just moves the entry to the front and there is nothing left to maintain or to dangle.

On disk: { "bundles" => [ { "slug" => …, "path" => absolute dir, "title" => label } ] }, the first row being the default. A bare array (the original shape) still reads.

Defined Under Namespace

Classes: Entry, Group

Constant Summary collapse

HOME_ENV =
"OKF_HOME"
DEFAULT_HOME =
"~/.okf"
LOCAL_FILE =

A project-local registry: the same JSON, discovered by walking up from the working directory rather than read from $OKF_HOME. Its presence is the whole state — no stored "local mode" flag — so a bare okf server inside a repo serves that repo's bundles with no global setup.

".okf-registry.json"
NO_DISCOVERY_ENV =

The lever that forces the global registry even when a local one is on the path up from cwd. Set it (inline) and discovery is skipped — the escape hatch for a fixed-cwd caller (CI, a tool, the tests) that wants $OKF_HOME.

"OKF_NO_DISCOVERY"
RESERVED_SLUGS =

Slugs the ref grammar has already spoken for. @all means every registered bundle, so a bundle slugged "all" could never be named — reserve it here, where both slug paths pass, rather than let one register and then be unreachable.

%w[all].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path, relative_base: nil) ⇒ Registry

relative_base is the directory a local registry's relative paths anchor on (see .load). nil means an absolute-path registry — the global $OKF_HOME one, and every library caller — so its behavior is exactly what it was before relative storage existed.



185
186
187
188
189
190
191
# File 'lib/okf/registry.rb', line 185

def initialize(path, relative_base: nil)
  @path = path
  @relative_base = relative_base
  @entries = []
  @groups = []
  read
end

Instance Attribute Details

#pathObject (readonly)

Returns the value of attribute path.



179
180
181
# File 'lib/okf/registry.rb', line 179

def path
  @path
end

Class Method Details

.dedupe(base, taken) ⇒ Object

base slugified, then suffixed (-2, -3, …) until it avoids every slug in taken. Reserving is the caller's business, not this helper's: the ephemeral hub mints through here too, and it has no registry and no @refs, so a name reserved for the ref grammar would suffix it to /b/all-2/ against a /b/all/ that does not exist. #unique_slug adds the reserved names because the registry is where they mean something.



167
168
169
170
171
172
173
174
# File 'lib/okf/registry.rb', line 167

def dedupe(base, taken)
  slug = slugify(base)
  return slug unless taken.include?(slug)

  n = 2
  n += 1 while taken.include?("#{slug}-#{n}")
  "#{slug}-#{n}"
end

.discover(start) ⇒ Object

Walk up from start looking for a local registry; return its absolute path or nil. Stops at the filesystem root (parent == self), so it never loops.



120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/okf/registry.rb', line 120

def discover(start)
  dir = expand(start.to_s)
  loop do
    candidate = File.join(dir, LOCAL_FILE)
    return candidate if File.file?(candidate)

    parent = File.dirname(dir)
    break if parent == dir

    dir = parent
  end
  nil
end

.expand(base) ⇒ Object

File.expand_path raises ArgumentError on a "~nosuchuser" (or bare "~" with no HOME) — a bad argument, which the CLI must report as a usage error rather than let escape as a backtrace and an exit code that means "failing bundle".



96
97
98
99
100
# File 'lib/okf/registry.rb', line 96

def expand(base)
  File.expand_path(base)
rescue ArgumentError => e
  raise OKF::Error, "cannot expand #{base}: #{e.message}"
end

.load(home: nil, cwd: nil) ⇒ Object

The registry a run resolves to. Precedence, highest first: OKF_NO_DISCOVERY forces the global one; else a .okf-registry.json discovered on the path up from cwd wins; else the global $OKF_HOME registry, exactly as before. cwd nil ⇒ no discovery, so an embedding app that calls load with no arguments keeps the global-only behavior — only the CLI opts in by passing cwd: Dir.pwd. $OKF_HOME names where the global registry lives; it does not veto a nearer local one (it is commonly exported, so letting it would silently defeat the feature for its own audience).



110
111
112
113
114
115
116
# File 'lib/okf/registry.rb', line 110

def load(home: nil, cwd: nil)
  looking = cwd && ENV[NO_DISCOVERY_ENV].to_s.empty?
  local = looking ? discover(cwd) : nil
  # A local registry anchors its relative paths on its own directory; the
  # global one has no common anchor, so it stays absolute (relative_base nil).
  new(local || path(home: home), relative_base: local && File.dirname(local))
end

.normalize(base) ⇒ Object

Normalize base to a url-safe slug (lowercase, dashes) — "" when nothing survives. This is the form a lookup wants: "@***" normalizes to nothing and must stay nothing, so it fails as a bad ref instead of resolving to whatever #slugify's placeholder happens to name.



138
139
140
# File 'lib/okf/registry.rb', line 138

def normalize(base)
  base.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
end

.path(home: nil) ⇒ Object

The registry file: $OKF_HOME/registry.json, $OKF_HOME defaulting to ~/.okf. The env var is the only lever the CLI offers; home overrides it for an embedding app (and the tests), which should not have to mutate a process-global to say which registry it means. An empty home or env var counts as unset — expand_path("") would silently plant the registry in the current directory.



85
86
87
88
89
90
# File 'lib/okf/registry.rb', line 85

def path(home: nil)
  env = ENV.fetch(HOME_ENV, nil)
  home = nil if home.nil? || home.to_s.empty?
  base = home || (env.nil? || env.empty? ? DEFAULT_HOME : env)
  File.join(expand(base), "registry.json")
end

.path_shaped?(arg) ⇒ Boolean

Does this argument name a location rather than a slug? A separator settles it: #normalize maps one to a dash, so no slug can contain one. The reading matters because #remove takes either — and a path that matched no entry must not fall through to a slug lookup, where "./notes" strips to "notes" and deletes an entry pointing somewhere else entirely, reporting success. This is the line between the two readings.

Returns:

  • (Boolean)


157
158
159
# File 'lib/okf/registry.rb', line 157

def path_shaped?(arg)
  arg.to_s.include?(File::SEPARATOR)
end

.slugify(base) ⇒ Object

base normalized, with a placeholder when nothing survives — for minting a slug from a directory basename, where some name must come out. Shared with the server's ephemeral (unregistered) bundles so both slug the same way.



146
147
148
149
# File 'lib/okf/registry.rb', line 146

def slugify(base)
  slug = normalize(base)
  slug.empty? ? "bundle" : slug
end

Instance Method Details

#add(dir, as: nil, default: false) ⇒ Object

Register dir (must be a readable bundle directory). Re-registering the same path refreshes its title in place (and its slug when as is given). A basename-derived slug is deduped with a suffix; an explicit as raises on collision instead — the same "explicit is explicit" rule as #rename. default: true moves it to the front. Persists, then returns the entry.

Raises:



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/okf/registry.rb', line 293

def add(dir, as: nil, default: false)
  root = self.class.expand(dir.to_s)
  raise OKF::Error, "not a directory: #{dir}" unless File.directory?(root)

  # The label is path arithmetic; Folder.load would parse every markdown
  # file in the bundle to hand back its own basename.
  title = Bundle::Folder.label(root)
  entry = @entries.find { |candidate| candidate.path == root }
  if entry
    entry.title = title
    entry.slug = explicit_slug(as, entry) if as
  else
    slug = as ? explicit_slug(as, nil) : unique_slug(File.basename(root), nil)
    entry = Entry.new(slug, root, title)
    @entries << entry
  end
  if default
    @entries.delete(entry)
    @entries.unshift(entry)
  end
  write
  entry
end

#defaultObject

The default bundle a bare okf server selects: the first entry still on disk. Position decides it, but a position the hub cannot serve decides nothing — it drops a vanished directory rather than serving a hole, so the default has to skip the same ones or registry list would star a bundle / never opens. Falling back to the first entry when every one has vanished keeps a bare @ failing with "points to , which is not a directory" instead of the much worse "not a registered bundle". nil only when nothing is registered.



226
227
228
# File 'lib/okf/registry.rb', line 226

def default
  @entries.find { |entry| File.directory?(entry.path) } || @entries.first
end

#default=(slug) ⇒ Object

Choose which bundle / opens, by moving that entry to the front. Persists; raises on an unknown slug. The ask is normalized the way registration normalized it, so the name the user typed at --as is the name that resolves here.

A directory that is gone is refused, exactly as #add refuses to register one: both are explicit asks, and #default skips a vanished entry, so allowing the move would answer default bundle → <some other slug> to someone who named this one.

Raises:



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/okf/registry.rb', line 239

def default=(slug)
  normalized = self.class.normalize(slug)
  if group?(normalized)
    raise OKF::Error, "cannot default to a group: @#{normalized} names a set of bundles, and the default is one bundle"
  end

  entry = get(normalized)
  raise OKF::Error, "no such bundle: #{slug}" unless entry
  unless File.directory?(entry.path)
    raise OKF::Error, "cannot default to #{entry.slug}: #{entry.path} is not a directory " \
                      "(okf registry del #{entry.slug}, or restore it)"
  end

  @entries.delete(entry)
  @entries.unshift(entry)
  write
end

#each(&block) ⇒ Object



193
194
195
# File 'lib/okf/registry.rb', line 193

def each(&block)
  @entries.each(&block)
end

#empty?Boolean

Returns:

  • (Boolean)


201
202
203
# File 'lib/okf/registry.rb', line 201

def empty?
  @entries.empty?
end

#expand(slug) ⇒ Object

Resolve slug to its ordered, path-deduped bundle Entries — a group flattens recursively, a bundle slug resolves to itself. Returns leaves even when their directory has vanished; the caller (search/server) decides whether to skip one, the way @all tolerates a gap. Raises OKF::Error on a cycle — a defense-in-depth guard, since #set_group already blocks one at write time but the file is hand-editable.



401
402
403
404
405
406
# File 'lib/okf/registry.rb', line 401

def expand(slug)
  entries = []
  seen = []
  resolve_into(self.class.normalize(slug), entries, seen, [])
  entries
end

#get(slug) ⇒ Object



209
210
211
# File 'lib/okf/registry.rb', line 209

def get(slug)
  @entries.find { |entry| entry.slug == slug }
end

#group?(slug) ⇒ Boolean

The group registered under slug (already normalized, like #get), or nil.

Returns:

  • (Boolean)


214
215
216
# File 'lib/okf/registry.rb', line 214

def group?(slug)
  @groups.find { |group| group.slug == slug }
end

#groups_listingObject

One row per group for registry list: its members and how many bundles it resolves to (+resolved+ is nil when a hand-edited cycle makes it unanswerable).



430
431
432
433
434
435
436
437
438
439
# File 'lib/okf/registry.rb', line 430

def groups_listing
  @groups.map do |group|
    resolved = begin
      expand(group.slug).size
    rescue OKF::Error
      nil
    end
    { slug: group.slug, members: group.members.dup, resolved: resolved }
  end
end

#listingObject

One row per bundle for the CLI list: dir is the on-disk directory, mount the server path, default true for the first row, missing true when the registered directory no longer exists on disk. default stays in the row even though it is now derivable from position — a consumer reading the JSON should not have to know the rule to find the bundle / opens.



280
281
282
283
284
285
286
# File 'lib/okf/registry.rb', line 280

def listing
  chosen = default
  @entries.map do |entry|
    { slug: entry.slug, title: entry.title, dir: entry.path, mount: "/b/#{entry.slug}/",
      default: entry.equal?(chosen), missing: !File.directory?(entry.path) }
  end
end

#remove(slug) ⇒ Object

Remove the entry named by slug (or whose path matches). Returns the removed entry, or nil when nothing matched. Removing the default needs no cleanup — the next entry is first, and so is the default. Persists on change.



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/okf/registry.rb', line 320

def remove(slug)
  # Slug-or-dir, so the normalized reading comes *last*: "./docs" must mean
  # the directory while one is registered under that path, and only fall
  # back to naming the "docs" slug when no path matches.
  target = get(slug) ||
           @entries.find { |entry| entry.path == self.class.expand(slug.to_s) } ||
           (self.class.path_shaped?(slug) ? nil : get(self.class.normalize(slug)))
  if target
    @entries.delete(target)
    cascade_remove(target.slug)
    write
    return target
  end

  # Not a bundle — a group answers to its slug only (having no path, it can
  # never match the path-shaped reading). Removing it, like removing a bundle,
  # drops the slug from every group that named it.
  group = self.class.path_shaped?(slug) ? nil : (group?(slug) || group?(self.class.normalize(slug)))
  return nil unless group

  @groups.delete(group)
  cascade_remove(group.slug)
  write
  group
end

#rename(old_slug, new_slug) ⇒ Object

Give the bundle at old_slug a new slug (its mount path and switcher name). The new name is slugified; a collision with another entry raises rather than silently suffixing — a rename is explicit. Position is untouched, so a renamed default stays the default with no bookkeeping.

Raises:



261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/okf/registry.rb', line 261

def rename(old_slug, new_slug)
  old = self.class.normalize(old_slug)
  entry = get(old) || group?(old)
  raise OKF::Error, "no such bundle or group: #{old_slug}" unless entry

  slug = explicit_slug(new_slug, entry)
  entry.slug = slug
  # A member list stores slugs, so a rename that stopped at the entry would
  # orphan every group that named it — cascade the new name across them.
  cascade_rename(old, slug)
  write
  entry
end

#reopenObject

A fresh instance over the same file, anchored the same way. The server re-opens the registry per request (to show an edit made elsewhere) and after each write; it must keep the relative_base a discovered local registry carries. A bare Registry.new(path) would drop it — so a local registry's in-tree paths would resolve against the wrong directory (every served bundle reads as "folder is gone" in the manager) and a browser write would flatten a newly-added in-tree bundle to an absolute path, silently undoing the portability the base exists for.



424
425
426
# File 'lib/okf/registry.rb', line 424

def reopen
  self.class.new(@path, relative_base: @relative_base)
end

#saveObject

Persist the current state to disk. The mutating verbs write as a side effect of the change; save is the public seam for the one caller that creates a registry with nothing to change yet — okf registry init, materializing an empty local file so discovery has something to find.



412
413
414
# File 'lib/okf/registry.rb', line 412

def save
  write
end

#set_group(slug, member_asks) ⇒ Object

Create the group slug, or add member_asks to an existing one (a union, order-preserving). Members are bundle or group slugs, given bare or as @ref; each must already name a bundle or a group, and the result must not reach itself (a cycle is refused before the write). Persists, returns the Group.

Raises:



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'lib/okf/registry.rb', line 351

def set_group(slug, member_asks)
  name = explicit_group_slug(slug)
  members = normalize_members(member_asks)
  raise OKF::Error, "a group needs at least one member (okf registry group #{name} <@bundle…>)" if members.empty?

  members.each do |member|
    next if get(member) || group?(member)

    raise OKF::Error, "no such bundle or group: @#{member} (okf registry list)"
  end

  group = group?(name)
  merged = group ? group.members.dup : []
  members.each { |member| merged << member unless merged.include?(member) }
  raise OKF::Error, "group cycle: @#{name} would contain itself" if reaches_self?(name, merged)

  if group
    group.members = merged
  else
    group = Group.new(name, merged)
    @groups << group
  end
  write
  group
end

#sizeObject



197
198
199
# File 'lib/okf/registry.rb', line 197

def size
  @entries.size
end

#slugsObject



205
206
207
# File 'lib/okf/registry.rb', line 205

def slugs
  @entries.map(&:slug)
end

#unset_group_members(slug, member_asks) ⇒ Object

Drop member_asks from the group slug. Removing the last member deletes the group — an empty group resolves to nothing, so it is not worth keeping. Returns [removed_members, emptied?]. Raises on an unknown group; a member that was not there is simply not in the returned list.

Raises:



381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/okf/registry.rb', line 381

def unset_group_members(slug, member_asks)
  name = self.class.normalize(slug)
  group = group?(name)
  raise OKF::Error, "no such group: #{slug} (okf registry list)" unless group

  asks = normalize_members(member_asks)
  removed = group.members & asks
  group.members -= asks
  emptied = group.members.empty?
  @groups.delete(group) if emptied
  write
  [ removed, emptied ]
end