Class: OKF::Registry
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
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 serverinside 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.
@allmeans 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
-
#path ⇒ Object
readonly
Returns the value of attribute path.
Class Method Summary collapse
-
.dedupe(base, taken) ⇒ Object
baseslugified, then suffixed (-2, -3, …) until it avoids every slug intaken. -
.discover(start) ⇒ Object
Walk up from
startlooking for a local registry; return its absolute path or nil. -
.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".
-
.load(home: nil, cwd: nil) ⇒ Object
The registry a run resolves to.
-
.normalize(base) ⇒ Object
Normalize
baseto a url-safe slug (lowercase, dashes) — "" when nothing survives. -
.path(home: nil) ⇒ Object
The registry file: $OKF_HOME/registry.json, $OKF_HOME defaulting to ~/.okf.
-
.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.
-
.slugify(base) ⇒ Object
basenormalized, with a placeholder when nothing survives — for minting a slug from a directory basename, where some name must come out.
Instance Method Summary collapse
-
#add(dir, as: nil, default: false) ⇒ Object
Register
dir(must be a readable bundle directory). -
#default ⇒ Object
The default bundle a bare
okf serverselects: the first entry still on disk. -
#default=(slug) ⇒ Object
Choose which bundle
/opens, by moving that entry to the front. - #each(&block) ⇒ Object
- #empty? ⇒ Boolean
-
#expand(slug) ⇒ Object
Resolve
slugto its ordered, path-deduped bundle Entries — a group flattens recursively, a bundle slug resolves to itself. - #get(slug) ⇒ Object
-
#group?(slug) ⇒ Boolean
The group registered under
slug(already normalized, like #get), or nil. -
#groups_listing ⇒ Object
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). -
#initialize(path, relative_base: nil) ⇒ Registry
constructor
relative_baseis the directory a local registry's relative paths anchor on (see .load). -
#listing ⇒ Object
One row per bundle for the CLI list:
diris the on-disk directory,mountthe server path,defaulttrue for the first row,missingtrue when the registered directory no longer exists on disk. -
#remove(slug) ⇒ Object
Remove the entry named by
slug(or whose path matches). -
#rename(old_slug, new_slug) ⇒ Object
Give the bundle at
old_sluga new slug (its mount path and switcher name). -
#reopen ⇒ Object
A fresh instance over the same file, anchored the same way.
-
#save ⇒ Object
Persist the current state to disk.
-
#set_group(slug, member_asks) ⇒ Object
Create the group
slug, or addmember_asksto an existing one (a union, order-preserving). - #size ⇒ Object
- #slugs ⇒ Object
-
#unset_group_members(slug, member_asks) ⇒ Object
Drop
member_asksfrom the groupslug.
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
#path ⇒ Object (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 = (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 (base) File.(base) rescue ArgumentError => e raise OKF::Error, "cannot expand #{base}: #{e.}" 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((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.
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.
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.(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 |
#default ⇒ Object
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
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.
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
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 (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.
214 215 216 |
# File 'lib/okf/registry.rb', line 214 def group?(slug) @groups.find { |group| group.slug == slug } end |
#groups_listing ⇒ Object
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 (group.slug).size rescue OKF::Error nil end { slug: group.slug, members: group.members.dup, resolved: resolved } end end |
#listing ⇒ Object
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.(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.
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 |
#reopen ⇒ Object
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 |
#save ⇒ Object
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.
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 |
#size ⇒ Object
197 198 199 |
# File 'lib/okf/registry.rb', line 197 def size @entries.size end |
#slugs ⇒ Object
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.
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 |