Class: Textus::Manifest

Inherits:
Object
  • Object
show all
Defined in:
lib/textus/manifest.rb

Constant Summary collapse

KEY_SEGMENT =

New stricter grammar: lowercase + digits + internal hyphens. No underscores.

/\A[a-z0-9][a-z0-9-]*\z/
MAX_SEGMENTS =
8
MAX_SEGMENT_LEN =
64
EXT_TO_FORMAT =
{
  ".md" => "markdown",
  ".json" => "json",
  ".yaml" => "yaml",
  ".yml" => "yaml",
  ".txt" => "text",
}.freeze
LEGACY_ZONES =
{
  "fixed" => ["human"],
  "state" => %w[human ai script],
  "derived" => ["build"],
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root, raw) ⇒ Manifest

Returns a new instance of Manifest.



53
54
55
56
57
58
# File 'lib/textus/manifest.rb', line 53

def initialize(root, raw)
  @root = root
  @raw = raw
  @entries = Array(raw["entries"]).map { |e| ManifestEntry.new(self, e) }
  validate_declared_keys!
end

Instance Attribute Details

#entriesObject (readonly)

Returns the value of attribute entries.



18
19
20
# File 'lib/textus/manifest.rb', line 18

def entries
  @entries
end

#rawObject (readonly)

Returns the value of attribute raw.



18
19
20
# File 'lib/textus/manifest.rb', line 18

def raw
  @raw
end

#rootObject (readonly)

Returns the value of attribute root.



18
19
20
# File 'lib/textus/manifest.rb', line 18

def root
  @root
end

Class Method Details

.load(root) ⇒ Object

Raises:



43
44
45
46
47
48
49
50
51
# File 'lib/textus/manifest.rb', line 43

def self.load(root)
  manifest_path = File.join(root, "manifest.yaml")
  raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)

  raw = YAML.safe_load_file(manifest_path, aliases: false)
  raise BadFrontmatter.new(manifest_path, "unsupported manifest version #{raw["version"].inspect}") unless raw["version"] == PROTOCOL

  new(root, raw)
end

Instance Method Details

#enumerate(prefix: nil) ⇒ Object

Enumerate all entry files reachable through the manifest. Returns

{ key:, path:, manifest_entry: }, …

rubocop:disable Metrics/AbcSize



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/textus/manifest.rb', line 100

def enumerate(prefix: nil)
  out = []
  @entries.each do |entry|
    if entry.nested
      base = File.join(@root, "zones", entry.path)
      next unless File.directory?(base)

      glob_pattern = nested_glob(entry.format)
      Dir.glob(File.join(base, glob_pattern)).each do |fp|
        rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
        stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
        segs = stripped.split("/").reject(&:empty?)
        next if segs.empty?

        illegal = segs.find { |s| !valid_segment?(s) }
        if illegal
          warn("textus: skipping illegal key segment '#{illegal}' at #{fp} — run 'textus migrate-keys --dry-run'")
          next
        end

        full_key = (entry.key.split(".") + segs).join(".")
        out << { key: full_key, path: fp, manifest_entry: entry }
      end
    else
      fp = resolve_leaf_path(entry)
      out << { key: entry.key, path: fp, manifest_entry: entry } if File.exist?(fp)
    end
  end
  out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
  out.sort_by { |row| row[:key] }
end

#resolve(key) ⇒ Object

Returns [ManifestEntry, resolved_path, remaining_segments]

Raises:



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/textus/manifest.rb', line 61

def resolve(key)
  validate_key!(key)
  segments = key.split(".")
  # longest-prefix match
  candidates = @entries
               .map { |e| [e, e.key.split(".")] }
               .select { |(_, esegs)| esegs == segments[0, esegs.length] }
               .sort_by { |(_, esegs)| -esegs.length }
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?

  entry, esegs = candidates.first
  remaining = segments[esegs.length..]
  if remaining.empty?
    path = resolve_leaf_path(entry)
    [entry, path, []]
  else
    raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless entry.nested

    primary_ext = Entry.for_format(entry.format).extensions.first
    path = File.join(@root, "zones", entry.path, *remaining) + primary_ext
    [entry, path, remaining]
  end
end

#suggestions_for(key) ⇒ Object

Returns up to 5 dotted keys from the manifest that look similar to the requested key, ranked by shared-prefix length then Levenshtein distance.



87
88
89
90
91
92
93
94
95
# File 'lib/textus/manifest.rb', line 87

def suggestions_for(key)
  candidates = enumerate.map { |r| r[:key] }
  # Include declared (non-nested) entry keys even if file is missing.
  candidates.concat(@entries.reject(&:nested).map(&:key))
  candidates.uniq!
  KeyDistance.suggest(key, candidates, limit: 5)
rescue StandardError
  []
end

#validate_key!(key) ⇒ Object

Raises:



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/textus/manifest.rb', line 144

def validate_key!(key)
  raise UsageError.new("empty key") if key.nil? || key.empty?

  segs = key.split(".")
  raise UsageError.new("key '#{key}' has #{segs.length} segments (max #{MAX_SEGMENTS})") if segs.length > MAX_SEGMENTS

  segs.each do |seg|
    if seg.empty?
      raise UsageError.new("empty segment in key '#{key}'")
    elsif seg.length > MAX_SEGMENT_LEN
      raise UsageError.new("segment '#{seg}' in key '#{key}' exceeds #{MAX_SEGMENT_LEN} chars")
    elsif !seg.match?(KEY_SEGMENT)
      raise UsageError.new(
        "invalid key segment '#{seg}' in '#{key}': must match [a-z0-9][a-z0-9-]* " \
        "(lowercase, digits, hyphens; no underscores or uppercase)",
      )
    end
  end
end

#validate_keys!Object

Validates all declared entry keys; raises UsageError listing all offenders.

Raises:



134
135
136
137
138
139
140
141
142
# File 'lib/textus/manifest.rb', line 134

def validate_keys!
  offenders = []
  @entries.each do |entry|
    validate_key!(entry.key)
  rescue UsageError => e
    offenders << e.message
  end
  raise UsageError.new("invalid manifest keys: #{offenders.join("; ")}") unless offenders.empty?
end

#zone_writers(zone_name) ⇒ Object



39
40
41
# File 'lib/textus/manifest.rb', line 39

def zone_writers(zone_name)
  zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
end

#zonesObject



26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/textus/manifest.rb', line 26

def zones
  @zones ||= begin
    declared = Array(@raw["zones"])
    if declared.empty?
      LEGACY_ZONES.transform_values(&:dup)
    else
      declared.to_h do |z|
        [z["name"], Array(z["writable_by"])]
      end
    end
  end
end