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

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root, raw) ⇒ Manifest

Returns a new instance of Manifest.

Raises:



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

def initialize(root, raw)
  @root = root
  @raw = raw
  raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?

  @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:



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/textus/manifest.rb', line 28

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)
  unless raw["version"] == PROTOCOL
    msg = if raw["version"] == "textus/1"
            "manifest is textus/1; run 'textus migrate v2' to upgrade. See SPEC §15."
          else
            "unsupported manifest version #{raw["version"].inspect}"
          end
    raise BadFrontmatter.new(manifest_path, msg)
  end

  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



94
95
96
97
98
99
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
# File 'lib/textus/manifest.rb', line 94

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:



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/textus/manifest.rb', line 55

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.



81
82
83
84
85
86
87
88
89
# File 'lib/textus/manifest.rb', line 81

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:



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/textus/manifest.rb', line 138

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:



128
129
130
131
132
133
134
135
136
# File 'lib/textus/manifest.rb', line 128

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



24
25
26
# File 'lib/textus/manifest.rb', line 24

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

#zonesObject



20
21
22
# File 'lib/textus/manifest.rb', line 20

def zones
  @zones ||= Array(@raw["zones"]).to_h { |z| [z["name"], Array(z["writable_by"])] }
end