okf 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/okf/registry.rb CHANGED
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "pathname"
4
5
 
5
6
  module OKF
6
7
  # A persistent, ordered registry of bundle references — the kernel behind the
7
- # multi-bundle server. It is a plain JSON file (no database) under $OKF_HOME
8
- # (default ~/.okf), so `okf registry set`/`del` and a later bare `okf server`
9
- # share one on-disk list. Part of the shell — it reads and writes a file.
8
+ # multi-bundle server. It is a plain JSON file (no database): the global one
9
+ # under $OKF_HOME (default ~/.okf), or a project-local .okf-registry.json
10
+ # discovered by walking up from cwd (see .load / .discover), which replaces the
11
+ # global one while you stand in its tree. Either way `okf registry set`/`del`
12
+ # and a later bare `okf server` share one on-disk list. Part of the shell — it
13
+ # reads and writes a file.
10
14
  #
11
15
  # registry = OKF::Registry.load
12
16
  # registry.add("docs") # persists, returns the Entry
@@ -31,9 +35,40 @@ module OKF
31
35
  # human-readable +title+ ("parent/dir").
32
36
  Entry = Struct.new(:slug, :path, :title)
33
37
 
38
+ # A named set of bundle sources: a unique +slug+ and an ordered list of
39
+ # +members+ (bundle *or* group slugs), stored normalized. A group has no path
40
+ # — it resolves, recursively, to the bundles its members name. It shares the
41
+ # slug namespace with Entry: a slug names one *or* the other, never both, so
42
+ # `@backend` is unambiguous. Only `okf search`/`okf server` consume one — the
43
+ # two verbs that already take several bundles.
44
+ #
45
+ # A plain class, not a Struct like Entry: the field we want is +members+, and
46
+ # `Struct.new(:slug, :members)` would shadow Struct#members (the field-name
47
+ # introspection) — the gotcha Lint/StructNewOverride flags. This keeps
48
+ # `group.members` as the natural accessor without the override.
49
+ class Group
50
+ attr_accessor :slug, :members
51
+
52
+ def initialize(slug, members)
53
+ @slug = slug
54
+ @members = members
55
+ end
56
+ end
57
+
34
58
  HOME_ENV = "OKF_HOME"
35
59
  DEFAULT_HOME = "~/.okf"
36
60
 
61
+ # A project-local registry: the same JSON, discovered by walking up from the
62
+ # working directory rather than read from $OKF_HOME. Its presence is the whole
63
+ # state — no stored "local mode" flag — so a bare `okf server` inside a repo
64
+ # serves that repo's bundles with no global setup.
65
+ LOCAL_FILE = ".okf-registry.json"
66
+
67
+ # The lever that forces the global registry even when a local one is on the
68
+ # path up from cwd. Set it (inline) and discovery is skipped — the escape hatch
69
+ # for a fixed-cwd caller (CI, a tool, the tests) that wants $OKF_HOME.
70
+ NO_DISCOVERY_ENV = "OKF_NO_DISCOVERY"
71
+
37
72
  # Slugs the ref grammar has already spoken for. `@all` means every registered
38
73
  # bundle, so a bundle slugged "all" could never be named — reserve it here,
39
74
  # where both slug paths pass, rather than let one register and then be
@@ -64,8 +99,36 @@ module OKF
64
99
  raise OKF::Error, "cannot expand #{base}: #{e.message}"
65
100
  end
66
101
 
67
- def load(home: nil)
68
- new(path(home: home))
102
+ # The registry a run resolves to. Precedence, highest first: OKF_NO_DISCOVERY
103
+ # forces the global one; else a `.okf-registry.json` discovered on the path
104
+ # up from +cwd+ wins; else the global $OKF_HOME registry, exactly as before.
105
+ # +cwd+ nil ⇒ no discovery, so an embedding app that calls `load` with no
106
+ # arguments keeps the global-only behavior — only the CLI opts in by passing
107
+ # `cwd: Dir.pwd`. $OKF_HOME names *where the global registry lives*; it does
108
+ # not veto a nearer local one (it is commonly exported, so letting it would
109
+ # silently defeat the feature for its own audience).
110
+ def load(home: nil, cwd: nil)
111
+ looking = cwd && ENV[NO_DISCOVERY_ENV].to_s.empty?
112
+ local = looking ? discover(cwd) : nil
113
+ # A local registry anchors its relative paths on its own directory; the
114
+ # global one has no common anchor, so it stays absolute (relative_base nil).
115
+ new(local || path(home: home), relative_base: local && File.dirname(local))
116
+ end
117
+
118
+ # Walk up from +start+ looking for a local registry; return its absolute path
119
+ # or nil. Stops at the filesystem root (parent == self), so it never loops.
120
+ def discover(start)
121
+ dir = expand(start.to_s)
122
+ loop do
123
+ candidate = File.join(dir, LOCAL_FILE)
124
+ return candidate if File.file?(candidate)
125
+
126
+ parent = File.dirname(dir)
127
+ break if parent == dir
128
+
129
+ dir = parent
130
+ end
131
+ nil
69
132
  end
70
133
 
71
134
  # Normalize +base+ to a url-safe slug (lowercase, dashes) — "" when nothing
@@ -115,9 +178,15 @@ module OKF
115
178
 
116
179
  attr_reader :path
117
180
 
118
- def initialize(path)
181
+ # +relative_base+ is the directory a local registry's relative paths anchor on
182
+ # (see .load). nil means an absolute-path registry — the global $OKF_HOME one,
183
+ # and every library caller — so its behavior is exactly what it was before
184
+ # relative storage existed.
185
+ def initialize(path, relative_base: nil)
119
186
  @path = path
187
+ @relative_base = relative_base
120
188
  @entries = []
189
+ @groups = []
121
190
  read
122
191
  end
123
192
 
@@ -141,6 +210,11 @@ module OKF
141
210
  @entries.find { |entry| entry.slug == slug }
142
211
  end
143
212
 
213
+ # The group registered under +slug+ (already normalized, like #get), or nil.
214
+ def group?(slug)
215
+ @groups.find { |group| group.slug == slug }
216
+ end
217
+
144
218
  # The default bundle a bare `okf server` selects: the first entry still on
145
219
  # disk. Position decides it, but a position the hub cannot serve decides
146
220
  # nothing — it drops a vanished directory rather than serving a hole, so the
@@ -163,7 +237,12 @@ module OKF
163
237
  # allowing the move would answer `default bundle → <some other slug>` to
164
238
  # someone who named this one.
165
239
  def default=(slug)
166
- entry = get(self.class.normalize(slug))
240
+ normalized = self.class.normalize(slug)
241
+ if group?(normalized)
242
+ raise OKF::Error, "cannot default to a group: @#{normalized} names a set of bundles, and the default is one bundle"
243
+ end
244
+
245
+ entry = get(normalized)
167
246
  raise OKF::Error, "no such bundle: #{slug}" unless entry
168
247
  unless File.directory?(entry.path)
169
248
  raise OKF::Error, "cannot default to #{entry.slug}: #{entry.path} is not a directory " \
@@ -180,11 +259,15 @@ module OKF
180
259
  # silently suffixing — a rename is explicit. Position is untouched, so a
181
260
  # renamed default stays the default with no bookkeeping.
182
261
  def rename(old_slug, new_slug)
183
- entry = get(self.class.normalize(old_slug))
184
- raise OKF::Error, "no such bundle: #{old_slug}" unless entry
262
+ old = self.class.normalize(old_slug)
263
+ entry = get(old) || group?(old)
264
+ raise OKF::Error, "no such bundle or group: #{old_slug}" unless entry
185
265
 
186
266
  slug = explicit_slug(new_slug, entry)
187
267
  entry.slug = slug
268
+ # A member list stores slugs, so a rename that stopped at the entry would
269
+ # orphan every group that named it — cascade the new name across them.
270
+ cascade_rename(old, slug)
188
271
  write
189
272
  entry
190
273
  end
@@ -241,11 +324,118 @@ module OKF
241
324
  target = get(slug) ||
242
325
  @entries.find { |entry| entry.path == self.class.expand(slug.to_s) } ||
243
326
  (self.class.path_shaped?(slug) ? nil : get(self.class.normalize(slug)))
244
- return nil unless target
327
+ if target
328
+ @entries.delete(target)
329
+ cascade_remove(target.slug)
330
+ write
331
+ return target
332
+ end
333
+
334
+ # Not a bundle — a group answers to its slug only (having no path, it can
335
+ # never match the path-shaped reading). Removing it, like removing a bundle,
336
+ # drops the slug from every group that named it.
337
+ group = self.class.path_shaped?(slug) ? nil : (group?(slug) || group?(self.class.normalize(slug)))
338
+ return nil unless group
245
339
 
246
- @entries.delete(target)
340
+ @groups.delete(group)
341
+ cascade_remove(group.slug)
247
342
  write
248
- target
343
+ group
344
+ end
345
+
346
+ # Create the group +slug+, or add +member_asks+ to an existing one (a union,
347
+ # order-preserving). Members are bundle *or* group slugs, given bare or as
348
+ # `@ref`; each must already name a bundle or a group, and the result must not
349
+ # reach itself (a cycle is refused before the write). Persists, returns the
350
+ # Group.
351
+ def set_group(slug, member_asks)
352
+ name = explicit_group_slug(slug)
353
+ members = normalize_members(member_asks)
354
+ raise OKF::Error, "a group needs at least one member (okf registry group #{name} <@bundle…>)" if members.empty?
355
+
356
+ members.each do |member|
357
+ next if get(member) || group?(member)
358
+
359
+ raise OKF::Error, "no such bundle or group: @#{member} (okf registry list)"
360
+ end
361
+
362
+ group = group?(name)
363
+ merged = group ? group.members.dup : []
364
+ members.each { |member| merged << member unless merged.include?(member) }
365
+ raise OKF::Error, "group cycle: @#{name} would contain itself" if reaches_self?(name, merged)
366
+
367
+ if group
368
+ group.members = merged
369
+ else
370
+ group = Group.new(name, merged)
371
+ @groups << group
372
+ end
373
+ write
374
+ group
375
+ end
376
+
377
+ # Drop +member_asks+ from the group +slug+. Removing the last member deletes
378
+ # the group — an empty group resolves to nothing, so it is not worth keeping.
379
+ # Returns [removed_members, emptied?]. Raises on an unknown group; a member
380
+ # that was not there is simply not in the returned list.
381
+ def unset_group_members(slug, member_asks)
382
+ name = self.class.normalize(slug)
383
+ group = group?(name)
384
+ raise OKF::Error, "no such group: #{slug} (okf registry list)" unless group
385
+
386
+ asks = normalize_members(member_asks)
387
+ removed = group.members & asks
388
+ group.members -= asks
389
+ emptied = group.members.empty?
390
+ @groups.delete(group) if emptied
391
+ write
392
+ [ removed, emptied ]
393
+ end
394
+
395
+ # Resolve +slug+ to its ordered, path-deduped bundle Entries — a group flattens
396
+ # recursively, a bundle slug resolves to itself. Returns leaves even when their
397
+ # directory has vanished; the caller (search/server) decides whether to skip
398
+ # one, the way `@all` tolerates a gap. Raises OKF::Error on a cycle — a
399
+ # defense-in-depth guard, since #set_group already blocks one at write time but
400
+ # the file is hand-editable.
401
+ def expand(slug)
402
+ entries = []
403
+ seen = []
404
+ resolve_into(self.class.normalize(slug), entries, seen, [])
405
+ entries
406
+ end
407
+
408
+ # Persist the current state to disk. The mutating verbs write as a side effect
409
+ # of the change; `save` is the public seam for the one caller that creates a
410
+ # registry with nothing to change yet — `okf registry init`, materializing an
411
+ # empty local file so discovery has something to find.
412
+ def save
413
+ write
414
+ end
415
+
416
+ # A fresh instance over the same file, anchored the same way. The server
417
+ # re-opens the registry per request (to show an edit made elsewhere) and
418
+ # after each write; it must keep the +relative_base+ a discovered local
419
+ # registry carries. A bare `Registry.new(path)` would drop it — so a local
420
+ # registry's in-tree paths would resolve against the wrong directory (every
421
+ # served bundle reads as "folder is gone" in the manager) and a browser
422
+ # write would flatten a newly-added in-tree bundle to an absolute path,
423
+ # silently undoing the portability the base exists for.
424
+ def reopen
425
+ self.class.new(@path, relative_base: @relative_base)
426
+ end
427
+
428
+ # One row per group for `registry list`: its members and how many bundles it
429
+ # resolves to (+resolved+ is nil when a hand-edited cycle makes it unanswerable).
430
+ def groups_listing
431
+ @groups.map do |group|
432
+ resolved = begin
433
+ expand(group.slug).size
434
+ rescue OKF::Error
435
+ nil
436
+ end
437
+ { slug: group.slug, members: group.members.dup, resolved: resolved }
438
+ end
249
439
  end
250
440
 
251
441
  private
@@ -256,8 +446,15 @@ module OKF
256
446
  # and a suffix is expected; #explicit_slug refuses instead, because there the
257
447
  # name is the user's.
258
448
  def unique_slug(base, skip)
259
- taken = @entries.reject { |entry| entry.equal?(skip) }.map(&:slug)
260
- self.class.dedupe(base, taken + RESERVED_SLUGS)
449
+ self.class.dedupe(base, taken_slugs(skip) + RESERVED_SLUGS)
450
+ end
451
+
452
+ # Every slug spoken for, bundle *and* group, except +skip+ (the entry or group
453
+ # being re-slugged in place). The unified namespace: a slug names one thing, so
454
+ # a collision check has to see both lists.
455
+ def taken_slugs(skip)
456
+ @entries.reject { |entry| entry.equal?(skip) }.map(&:slug) +
457
+ @groups.reject { |group| group.equal?(skip) }.map(&:slug)
261
458
  end
262
459
 
263
460
  # An explicitly requested slug (--as, rename): normalized, and a collision
@@ -276,21 +473,110 @@ module OKF
276
473
  raise OKF::Error, "not a usable slug: #{slug} is reserved (@#{slug} names every registered bundle)"
277
474
  end
278
475
 
279
- taken = @entries.reject { |entry| entry.equal?(skip) }.map(&:slug)
280
476
  # Refusing is the "never substitute a name you chose" rule, but a refusal
281
477
  # with no way forward is a dead end: the slug is spoken for by another
282
- # entry, so say which move frees it.
283
- raise OKF::Error, "slug already taken: #{slug} (rename or remove that entry first)" if taken.include?(slug)
478
+ # entry or group, so say which move frees it.
479
+ raise OKF::Error, "slug already taken: #{slug} (rename or remove that entry first)" if taken_slugs(skip).include?(slug)
284
480
 
285
481
  slug
286
482
  end
287
483
 
484
+ # The group slug for #set_group: usable, not reserved, and not a bundle's. A
485
+ # group re-using its own slug is the update path (so a group collision is not
486
+ # checked here); a bundle's slug is a hard collision.
487
+ def explicit_group_slug(base)
488
+ slug = self.class.normalize(base)
489
+ raise OKF::Error, "not a usable slug: #{base} (letters and digits, please)" if slug.empty?
490
+
491
+ if RESERVED_SLUGS.include?(slug)
492
+ raise OKF::Error, "not a usable slug: #{slug} is reserved (@#{slug} names every registered bundle)"
493
+ end
494
+ if get(slug)
495
+ raise OKF::Error, "slug already taken: #{slug} names a bundle (rename or remove that entry first)"
496
+ end
497
+
498
+ slug
499
+ end
500
+
501
+ # Member asks (bare or `@ref`) normalized to bundle/group slugs, empties dropped.
502
+ # #normalize maps a leading @ to nothing, so "@alpha" and "alpha" both arrive as
503
+ # "alpha".
504
+ def normalize_members(asks)
505
+ asks.map { |ask| self.class.normalize(ask) }.reject(&:empty?)
506
+ end
507
+
508
+ # Would a group named +start+ with +members+ reach itself? Walks the member
509
+ # graph (members that are groups expand), returning true on any path back to
510
+ # +start+ — the direct self-reference and the indirect cycle both. Other groups
511
+ # are already acyclic, so only edges out of +start+ can newly close a loop.
512
+ def reaches_self?(start, members)
513
+ stack = members.dup
514
+ seen = []
515
+ until stack.empty?
516
+ member = stack.pop
517
+ return true if member == start
518
+ next if seen.include?(member)
519
+
520
+ seen << member
521
+ nested = group?(member)
522
+ stack.concat(nested.members) if nested
523
+ end
524
+ false
525
+ end
526
+
527
+ # Depth-first expansion of +slug+ into bundle entries, deduped by path and
528
+ # cycle-guarded by the +chain+ of groups already open above it.
529
+ def resolve_into(slug, entries, seen_paths, chain)
530
+ if chain.include?(slug)
531
+ raise OKF::Error, "group cycle: #{(chain + [ slug ]).map { |name| "@#{name}" }.join(" → ")}"
532
+ end
533
+
534
+ group = group?(slug)
535
+ if group
536
+ group.members.each { |member| resolve_into(member, entries, seen_paths, chain + [ slug ]) }
537
+ return
538
+ end
539
+
540
+ entry = get(slug)
541
+ return unless entry # a dangling member (hand-edited) resolves to nothing
542
+ return if seen_paths.include?(entry.path)
543
+
544
+ seen_paths << entry.path
545
+ entries << entry
546
+ end
547
+
548
+ # Drop +slug+ from every group's members, and any group thereby emptied — which
549
+ # itself becomes a slug to drop, so a chain of one-member groups unwinds cleanly.
550
+ def cascade_remove(slug)
551
+ dropping = [ slug ]
552
+ until dropping.empty?
553
+ gone = dropping.shift
554
+ @groups.each { |group| group.members.delete(gone) }
555
+ @groups.select { |group| group.members.empty? }.each do |empty|
556
+ @groups.delete(empty)
557
+ dropping << empty.slug
558
+ end
559
+ end
560
+ end
561
+
562
+ # Rewrite +from+ to +to+ across every group's members, deduping when the new
563
+ # name already sits beside the old one.
564
+ def cascade_rename(from, to)
565
+ @groups.each do |group|
566
+ next unless group.members.include?(from)
567
+
568
+ group.members = group.members.map { |member| member == from ? to : member }.uniq
569
+ end
570
+ end
571
+
288
572
  def read
289
573
  return unless File.exist?(@path)
290
574
 
291
575
  data = JSON.parse(File.read(@path, encoding: "UTF-8"))
292
576
  rows = data.is_a?(Hash) ? Array(data["bundles"]) : Array(data) # bare array: the original shape
293
577
  @entries = rows.map { |row| entry_from(row) }
578
+ group_rows = data.is_a?(Hash) ? Array(data["groups"]) : [] # a groups-less file has none
579
+ @groups = group_rows.map { |row| group_from(row) }.reject { |group| group.members.empty? }
294
580
  normalize_slugs
295
581
  rescue JSON::ParserError => e
296
582
  malformed("#{e.message} (fix or delete the file)")
@@ -306,7 +592,46 @@ module OKF
306
592
  unless row.is_a?(Hash) && row["slug"].is_a?(String) && row["path"].is_a?(String) && !row["path"].empty?
307
593
  malformed('every entry needs a "slug" and a "path" (fix or delete the file)')
308
594
  end
309
- Entry.new(row["slug"], row["path"], row["title"] || File.basename(row["path"]))
595
+ path = resolve_stored(row["path"])
596
+ Entry.new(row["slug"], path, row["title"] || File.basename(path))
597
+ end
598
+
599
+ # Resolve a stored path to an absolute one: a relative path is anchored on the
600
+ # local registry's directory, an absolute one (and every path in the global
601
+ # registry) is returned untouched. So entry.path is *always* absolute in
602
+ # memory, and every consumer — File.directory?, the listing, the server mount —
603
+ # goes on seeing the absolute paths it always did.
604
+ def resolve_stored(raw)
605
+ return raw if @relative_base.nil? || raw.start_with?("/")
606
+
607
+ File.expand_path(raw, @relative_base)
608
+ end
609
+
610
+ # The on-disk form of an absolute path. In a local registry a bundle inside the
611
+ # registry's own tree is stored *relative* to it, so the file travels with the
612
+ # repo (a checkout elsewhere, a container mounting it) and still resolves; a
613
+ # bundle outside the tree stays absolute, since a relative path that climbs out
614
+ # cannot be re-anchored anywhere useful, and being honest about that beats a
615
+ # `../../..` that breaks on the first move. The global registry (no base) always
616
+ # stores absolute — its behavior is unchanged.
617
+ def store_form(abs)
618
+ return abs if @relative_base.nil?
619
+
620
+ # Both are absolute — entry.path always is, and @relative_base is a dirname of
621
+ # one — so relative_path_from cannot fail to relate them on a POSIX tree.
622
+ rel = Pathname.new(abs).relative_path_from(Pathname.new(@relative_base)).to_s
623
+ rel.start_with?("..") ? abs : rel
624
+ end
625
+
626
+ # One row to a Group, shape-checked like #entry_from. Members are normalized on
627
+ # the way in, the same asymmetry-fix #normalize_slugs applies to slugs: a
628
+ # hand-typed "@My Docs" member becomes "my-docs" so it can resolve.
629
+ def group_from(row)
630
+ unless row.is_a?(Hash) && row["slug"].is_a?(String) && row["members"].is_a?(Array)
631
+ malformed('every group needs a "slug" and a "members" array (fix or delete the file)')
632
+ end
633
+ members = row["members"].map { |member| self.class.normalize(member.to_s) }.reject(&:empty?)
634
+ Group.new(row["slug"], members)
310
635
  end
311
636
 
312
637
  # Slugs enter this list three ways — minted from a basename, asked for with
@@ -330,6 +655,11 @@ module OKF
330
655
 
331
656
  entry.slug = unique_slug(entry.slug, entry)
332
657
  end
658
+ @groups.each do |group|
659
+ next if usable_slug?(group.slug)
660
+
661
+ group.slug = unique_slug(group.slug, group)
662
+ end
333
663
  end
334
664
 
335
665
  # A stored slug that registration would have handed back untouched:
@@ -347,8 +677,9 @@ module OKF
347
677
  # Two racing writers stay last-writer-wins; the registry is a per-user file.
348
678
  def write
349
679
  FileUtils.mkdir_p(File.dirname(@path))
350
- rows = @entries.map { |entry| { "slug" => entry.slug, "path" => entry.path, "title" => entry.title } }
351
- payload = { "bundles" => rows }
680
+ rows = @entries.map { |entry| { "slug" => entry.slug, "path" => store_form(entry.path), "title" => entry.title } }
681
+ groups = @groups.map { |group| { "slug" => group.slug, "members" => group.members } }
682
+ payload = { "bundles" => rows, "groups" => groups }
352
683
  tmp = "#{@path}.tmp-#{Process.pid}"
353
684
  begin
354
685
  File.write(tmp, JSON.pretty_generate(payload) + "\n")