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.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, Link

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.json"
LEGACY_LOCAL_FILE =

What that file used to be called. It is still discovered, because the file is committed — retiring the name outright would break every repository carrying one, to save eight characters. So the short name became canonical and this one keeps resolving; okf registry says so once, where a reader can act on it, and nothing else says anything.

".okf-registry.json"
LOCAL_FILES =

Both names, in the order a single directory prefers them. Discovery reads this list per directory rather than sweeping the whole path for one name and then the other — otherwise a legacy file at the repo root would beat a .okf.json two levels down, and "the nearest one wins" would quietly mean something else.

[ LOCAL_FILE, LEGACY_LOCAL_FILE ].freeze
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, follow_links: true) ⇒ 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.



214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/okf/registry.rb', line 214

def initialize(path, relative_base: nil, follow_links: true)
  @path = path
  @relative_base = relative_base
  @follow_links = follow_links
  @entries = []
  @groups = []
  @links = []
  @link_groups = []
  @link_state = {}
  @link_of = {}
  read
end

Instance Attribute Details

#pathObject (readonly)

Returns the value of attribute path.



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

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.



196
197
198
199
200
201
202
203
# File 'lib/okf/registry.rb', line 196

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.



149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/okf/registry.rb', line 149

def discover(start)
  dir = expand(start.to_s)
  loop do
    found = LOCAL_FILES.map { |name| File.join(dir, name) }.find { |candidate| File.file?(candidate) }
    return found if found

    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".



121
122
123
124
125
# File 'lib/okf/registry.rb', line 121

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.json (or the legacy .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).



136
137
138
139
140
141
142
143
144
145
# File 'lib/okf/registry.rb', line 136

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).
  # Links are the *global* registry's alone: a discovered local one parses
  # and preserves them but does not resolve them, so depth is one by
  # construction rather than by a limit anyone has to enforce.
  new(local || path(home: home), relative_base: local && File.dirname(local), follow_links: local.nil?)
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.



167
168
169
# File 'lib/okf/registry.rb', line 167

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.



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

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)


186
187
188
# File 'lib/okf/registry.rb', line 186

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.



175
176
177
178
# File 'lib/okf/registry.rb', line 175

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:



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/okf/registry.rb', line 331

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 }
  refuse_linked(entry, "register") if entry
  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.



260
261
262
# File 'lib/okf/registry.rb', line 260

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:



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/okf/registry.rb', line 273

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

  refuse_linked(entry, "default to")
  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



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

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

#empty?Boolean

Returns:

  • (Boolean)


235
236
237
# File 'lib/okf/registry.rb', line 235

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.



447
448
449
450
451
452
# File 'lib/okf/registry.rb', line 447

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

#get(slug) ⇒ Object



243
244
245
# File 'lib/okf/registry.rb', line 243

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)


248
249
250
# File 'lib/okf/registry.rb', line 248

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

#groups_listingObject

One row per group — this registry's own first, then the ones that arrived through a link, each tagged with the link it came from (nil for a group this registry owns). Members, and how many bundles it resolves to (+resolved+ is nil when a hand-edited cycle makes it unanswerable).

One list, not two. #group? resolves a linked group, so the method that enumerates groups has to name it: a second listing for the linked half is how a caller comes to answer about a smaller set than the same object can resolve — the drift a sibling reading this method would inherit silently. A caller that wants only the editable ones filters on link.



571
572
573
574
575
576
577
578
579
580
# File 'lib/okf/registry.rb', line 571

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

#import(asks, from:, as: nil) ⇒ Object

Copy bundles and groups out of another registry file into this one, under the slugs they carry there. asks names bundles or groups in the source (bare, or as @ref); a group brings everything it reaches and is recreated here. as renames the single thing asked for. Persists once, and returns { bundles: [ Entry… ], groups: [ Group… ] }.

Import is the opposite trade from #link, and the pair is the point: a link holds a live pointer, so the other file keeps owning what it lends and can take it back; an import copies the reference and owns it from then on — the source can be deleted, moved or rewritten and nothing here notices. That is why the two disagree on a collision. A linked name was never chosen here, so #link_slug invents around it; an imported name lands in this file under this registry's own rules, so a collision is refused exactly as #rename's is. The gem may invent a name; it may not substitute one you chose, and naming a slug on the command line is choosing it.

Nothing is applied until everything has been checked. Half an import is a registry the user has to unpick by hand, reported as a success — so every ask is resolved and refused against the current state first, and one #write publishes the lot. Imported rows append, so the default stays where it was.

Raises:



509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
# File 'lib/okf/registry.rb', line 509

def import(asks, from:, as: nil)
  source = open_source(from)
  # #normalize_members drops what normalizes to nothing, which is right for a
  # group's members and wrong here: dropping one ask and importing the rest is
  # the silent partial all-or-nothing exists to rule out, and the report would
  # count the ones that landed as a success.
  unusable = asks.find { |ask| self.class.normalize(ask).empty? }
  raise OKF::Error, "not a usable slug: #{unusable} (letters and digits, please)" if unusable

  names = normalize_members(asks)
  raise OKF::Error, "nothing to import (name a bundle or a group from #{source.path})" if names.empty?

  plan = []
  names.each { |name| plan_import(source, name, nil, plan, []) }
  rename_import(plan, names.first, as) if as
  plan.each { |step| refuse_import(step, source.path) }
  apply_import(plan)
end

Point this registry at another registry file under slug: its bundles resolve through the pointer from now on, under their own slugs unless one is already taken here. Re-linking a slug re-points it. Persists, returns the Link.

The target must exist now — a link is an explicit ask, and one typed at a path that is not a registry file is a typo worth catching at the keyboard rather than a silent empty section later. (A target that vanishes afterwards is a different case, and is tolerated: see #links_listing.)

Raises:



463
464
465
466
467
468
469
470
471
472
473
474
# File 'lib/okf/registry.rb', line 463

def link(slug, target)
  name = explicit_link_slug(slug)
  registry = self.class.expand(target.to_s)
  raise OKF::Error, "not a registry file: #{target}" unless File.file?(registry)
  raise OKF::Error, "a registry cannot link itself: #{registry}" if registry == self.class.expand(@path)

  existing = @links.find { |candidate| candidate.slug == name }
  existing ? existing.registry = registry : @links << Link.new(name, registry)
  write
  refresh_links
  @links.find { |candidate| candidate.slug == name }
end

One row per link for registry list: the file it points at, how many bundles it contributed, and why it contributed none when it did. A target that is gone or unreadable is reported, never raised — one bad pointer must not take down the registry that holds it, the same tolerance a vanished bundle directory gets.



533
534
535
536
537
538
539
# File 'lib/okf/registry.rb', line 533

def links_listing
  @links.map do |link|
    state = @link_state[link.slug] || { bundles: 0, missing: false, unreadable: false }
    { slug: link.slug, registry: link.registry, bundles: state[:bundles],
      missing: state[:missing], unreadable: state[:unreadable] }
  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.



317
318
319
320
321
322
323
324
# File 'lib/okf/registry.rb', line 317

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),
      link: entry.link, origin: entry.origin }
  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.



359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/okf/registry.rb', line 359

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)))
  refuse_linked(target, "remove") if target
  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

  refuse_linked(group, "remove")
  @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:



297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/okf/registry.rb', line 297

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

  refuse_linked(entry, "rename")
  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.



557
558
559
# File 'lib/okf/registry.rb', line 557

def reopen
  self.class.new(@path, relative_base: @relative_base, follow_links: @follow_links)
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.



545
546
547
# File 'lib/okf/registry.rb', line 545

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:



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# File 'lib/okf/registry.rb', line 392

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|
    found = get(member) || group?(member)
    raise OKF::Error, "no such bundle or group: @#{member} (okf registry list)" unless found

    # A group stores slugs, and a linked slug lives only while its link
    # resolves — holding one would dangle the group the moment the link goes,
    # the same foreign key the default rule refused.
    refuse_linked(found, "group")
  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



231
232
233
# File 'lib/okf/registry.rb', line 231

def size
  @entries.size
end

#slugsObject



239
240
241
# File 'lib/okf/registry.rb', line 239

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

Drop the link slug and every bundle that arrived through it. Returns the removed Link, or nil when nothing matched.



478
479
480
481
482
483
484
485
486
487
# File 'lib/okf/registry.rb', line 478

def unlink(slug)
  name = self.class.normalize(slug)
  link = @links.find { |candidate| candidate.slug == name }
  return nil unless link

  @links.delete(link)
  write
  refresh_links
  link
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:



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

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

  refuse_linked(group, "ungroup")
  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