Class: Textus::Manifest

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

Defined Under Namespace

Classes: Entry, Policies

Constant Summary collapse

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:



48
49
50
51
52
53
54
55
56
# File 'lib/textus/manifest.rb', line 48

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?

  reject_legacy_entry_intake_policy!(Array(raw["entries"]))
  @entries = Array(raw["entries"]).map { |e| Manifest::Entry.new(self, e) }
  validate_declared_keys!
end

Instance Attribute Details

#entriesObject (readonly)

Returns the value of attribute entries.



13
14
15
# File 'lib/textus/manifest.rb', line 13

def entries
  @entries
end

#rawObject (readonly)

Returns the value of attribute raw.



13
14
15
# File 'lib/textus/manifest.rb', line 13

def raw
  @raw
end

#rootObject (readonly)

Returns the value of attribute root.



13
14
15
# File 'lib/textus/manifest.rb', line 13

def root
  @root
end

Class Method Details

.load(root) ⇒ Object

Raises:



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

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; edit manifest.yaml: change 'version: textus/1' to 'version: #{PROTOCOL}'"
          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



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
131
132
133
134
135
136
# File 'lib/textus/manifest.rb', line 106

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 key migrate --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

#permission_for(zone_name) ⇒ Object



23
24
25
26
27
28
29
# File 'lib/textus/manifest.rb', line 23

def permission_for(zone_name)
  Textus::Domain::Permission.new(
    zone: zone_name,
    writable_by: zone_writers(zone_name),
    readable_by: :all,
  )
end

#policiesObject



58
59
60
# File 'lib/textus/manifest.rb', line 58

def policies
  @policies ||= Textus::Manifest::Policies.parse(@raw["policies"] || [])
end

#policies_for(key) ⇒ Object



62
63
64
# File 'lib/textus/manifest.rb', line 62

def policies_for(key)
  policies.for(key)
end

#resolve(key) ⇒ Object

Returns [Manifest::Entry, resolved_path, remaining_segments]

Raises:



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/textus/manifest.rb', line 67

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 = Textus::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.



93
94
95
96
97
98
99
100
101
# File 'lib/textus/manifest.rb', line 93

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!
  Key::Distance.suggest(key, candidates, limit: 5)
rescue StandardError
  []
end

#validate_key!(key) ⇒ Object

rubocop:enable Metrics/AbcSize

Raises:



139
140
141
142
143
# File 'lib/textus/manifest.rb', line 139

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

  Key::Grammar.validate!(key)
end

#zone_writers(zone_name) ⇒ Object



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

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

#zonesObject



15
16
17
# File 'lib/textus/manifest.rb', line 15

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