textus 0.10.5 → 0.14.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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +60 -40
  3. data/CHANGELOG.md +318 -3
  4. data/README.md +34 -27
  5. data/SPEC.md +226 -145
  6. data/docs/conventions.md +8 -8
  7. data/lib/textus/application/context.rb +4 -0
  8. data/lib/textus/application/reads/blame.rb +1 -1
  9. data/lib/textus/application/reads/deps.rb +15 -0
  10. data/lib/textus/application/reads/freshness.rb +4 -4
  11. data/lib/textus/application/reads/get.rb +9 -12
  12. data/lib/textus/application/reads/list.rb +15 -0
  13. data/lib/textus/application/reads/policy_explain.rb +2 -2
  14. data/lib/textus/application/reads/published.rb +15 -0
  15. data/lib/textus/application/reads/rdeps.rb +15 -0
  16. data/lib/textus/application/reads/schema_envelope.rb +15 -0
  17. data/lib/textus/application/reads/stale.rb +15 -0
  18. data/lib/textus/application/reads/uid.rb +15 -0
  19. data/lib/textus/application/reads/validate_all.rb +15 -0
  20. data/lib/textus/application/reads/where.rb +15 -0
  21. data/lib/textus/application/refresh/all.rb +2 -2
  22. data/lib/textus/application/refresh/orchestrator.rb +1 -1
  23. data/lib/textus/application/refresh/worker.rb +8 -8
  24. data/lib/textus/application/writes/accept.rb +26 -8
  25. data/lib/textus/application/writes/build.rb +12 -49
  26. data/lib/textus/application/writes/delete.rb +1 -1
  27. data/lib/textus/application/writes/mv.rb +144 -0
  28. data/lib/textus/application/writes/publish.rb +42 -10
  29. data/lib/textus/application/writes/put.rb +1 -1
  30. data/lib/textus/application/writes/reject.rb +37 -0
  31. data/lib/textus/builder/pipeline.rb +1 -1
  32. data/lib/textus/builder/renderer/json.rb +1 -1
  33. data/lib/textus/builder/renderer/yaml.rb +1 -1
  34. data/lib/textus/cli/group/key.rb +1 -1
  35. data/lib/textus/cli/group/refresh.rb +21 -0
  36. data/lib/textus/cli/group/rule.rb +11 -0
  37. data/lib/textus/cli/verb/accept.rb +1 -2
  38. data/lib/textus/cli/verb/audit.rb +3 -3
  39. data/lib/textus/cli/verb/blame.rb +1 -2
  40. data/lib/textus/cli/verb/build.rb +6 -2
  41. data/lib/textus/cli/verb/delete.rb +1 -2
  42. data/lib/textus/cli/verb/deps.rb +1 -1
  43. data/lib/textus/cli/verb/freshness.rb +1 -2
  44. data/lib/textus/cli/verb/get.rb +2 -3
  45. data/lib/textus/cli/verb/hook_run.rb +3 -2
  46. data/lib/textus/cli/verb/hooks.rb +1 -1
  47. data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
  48. data/lib/textus/cli/verb/list.rb +1 -1
  49. data/lib/textus/cli/verb/mv.rb +1 -1
  50. data/lib/textus/cli/verb/published.rb +1 -1
  51. data/lib/textus/cli/verb/put.rb +3 -3
  52. data/lib/textus/cli/verb/rdeps.rb +1 -1
  53. data/lib/textus/cli/verb/refresh.rb +1 -2
  54. data/lib/textus/cli/verb/reject.rb +1 -1
  55. data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +2 -3
  56. data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
  57. data/lib/textus/cli/verb/schema.rb +1 -1
  58. data/lib/textus/cli/verb/uid.rb +1 -1
  59. data/lib/textus/cli/verb/where.rb +1 -1
  60. data/lib/textus/cli/verb.rb +9 -3
  61. data/lib/textus/cli.rb +6 -6
  62. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  63. data/lib/textus/doctor/check/illegal_keys.rb +39 -16
  64. data/lib/textus/doctor/check/intake_registration.rb +4 -4
  65. data/lib/textus/doctor/check/protocol_version.rb +47 -0
  66. data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
  67. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  68. data/lib/textus/doctor.rb +6 -5
  69. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  70. data/lib/textus/domain/permission.rb +4 -4
  71. data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
  72. data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
  73. data/lib/textus/domain/policy/promotion.rb +45 -0
  74. data/lib/textus/entry/base.rb +28 -0
  75. data/lib/textus/entry/json.rb +59 -0
  76. data/lib/textus/entry/markdown.rb +46 -0
  77. data/lib/textus/entry/text.rb +35 -0
  78. data/lib/textus/entry/yaml.rb +59 -0
  79. data/lib/textus/entry.rb +16 -0
  80. data/lib/textus/envelope.rb +44 -14
  81. data/lib/textus/errors.rb +24 -5
  82. data/lib/textus/hooks/builtin.rb +5 -5
  83. data/lib/textus/hooks/dispatcher.rb +1 -1
  84. data/lib/textus/hooks/dsl.rb +3 -10
  85. data/lib/textus/hooks/loader.rb +1 -2
  86. data/lib/textus/hooks/registry.rb +22 -21
  87. data/lib/textus/infra/refresh/detached.rb +1 -1
  88. data/lib/textus/init.rb +25 -34
  89. data/lib/textus/intro.rb +65 -9
  90. data/lib/textus/manifest/entry/parser.rb +84 -0
  91. data/lib/textus/manifest/entry/validators/events.rb +21 -0
  92. data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
  93. data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
  94. data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
  95. data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
  96. data/lib/textus/manifest/entry/validators.rb +20 -0
  97. data/lib/textus/manifest/entry.rb +38 -189
  98. data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
  99. data/lib/textus/manifest/schema.rb +49 -0
  100. data/lib/textus/manifest.rb +50 -24
  101. data/lib/textus/migrate_keys.rb +1 -1
  102. data/lib/textus/operations/reads.rb +39 -0
  103. data/lib/textus/operations/refresh.rb +27 -0
  104. data/lib/textus/operations/writes.rb +21 -0
  105. data/lib/textus/operations.rb +44 -0
  106. data/lib/textus/projection.rb +9 -8
  107. data/lib/textus/refresh.rb +4 -5
  108. data/lib/textus/schema/tools.rb +8 -7
  109. data/lib/textus/store/reader.rb +1 -1
  110. data/lib/textus/store/staleness/intake_check.rb +1 -1
  111. data/lib/textus/store/validator.rb +3 -3
  112. data/lib/textus/store/writer.rb +5 -74
  113. data/lib/textus/store.rb +1 -55
  114. data/lib/textus/version.rb +2 -2
  115. data/lib/textus.rb +1 -0
  116. metadata +35 -10
  117. data/lib/textus/cli/group/policy.rb +0 -11
  118. data/lib/textus/composition.rb +0 -72
  119. data/lib/textus/proposal.rb +0 -10
  120. data/lib/textus/store/mover.rb +0 -167
data/lib/textus/cli.rb CHANGED
@@ -21,12 +21,11 @@ module Textus
21
21
  "intro" => Verb::Intro,
22
22
  "key" => Group::Key,
23
23
  "list" => Verb::List,
24
- "policy" => Group::Policy,
25
24
  "published" => Verb::Published,
26
25
  "put" => Verb::Put,
27
26
  "rdeps" => Verb::Rdeps,
28
- "refresh" => Verb::Refresh,
29
- "refresh-stale" => Verb::RefreshStale,
27
+ "refresh" => Group::Refresh,
28
+ "rule" => Group::Rule,
30
29
  "schema" => Group::Schema,
31
30
  "where" => Verb::Where,
32
31
  }.freeze
@@ -90,16 +89,17 @@ module Textus
90
89
  textus get KEY
91
90
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
92
91
  textus freshness [--prefix=KEY] [--zone=Z]
93
- textus refresh-stale [--prefix=KEY] [--zone=Z]
92
+ textus refresh KEY
93
+ textus refresh stale [--prefix=KEY] [--zone=Z]
94
94
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
95
95
  textus blame KEY [--limit=N]
96
96
  textus doctor
97
97
  textus intro
98
98
 
99
- textus key {mv,uid,migrate}
99
+ textus key {mv,uid,normalize}
100
+ textus rule {list,explain}
100
101
  textus schema {show,init,diff,migrate}
101
102
  textus hook {list,run}
102
- textus policy {list,explain}
103
103
  HELP
104
104
  end
105
105
  end
@@ -11,7 +11,7 @@ module Textus
11
11
  handler = mentry.intake_handler
12
12
  next if handler.nil?
13
13
 
14
- allow = store.manifest.policies_for(mentry.key).handler_allowlist
14
+ allow = store.manifest.rules_for(mentry.key).handler_allowlist
15
15
  next if allow.nil?
16
16
  next if allow.allows?(handler)
17
17
 
@@ -10,28 +10,51 @@ module Textus
10
10
  base = File.join(store.root, "zones", entry.path)
11
11
  next unless File.directory?(base)
12
12
 
13
- walk_nested(base) do |abs_path, is_dir|
14
- basename = File.basename(abs_path)
15
- stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
16
- next if stem.match?(Key::Grammar::SEGMENT)
17
-
18
- proposed = Textus::MigrateKeys.normalize(stem)
19
- out << {
20
- "code" => "key.illegal",
21
- "level" => "error",
22
- "subject" => abs_path,
23
- "path" => abs_path,
24
- "proposed_key" => proposed,
25
- "message" => "illegal key segment '#{stem}' at #{abs_path}",
26
- "fix" => "run 'textus key migrate --dry-run' then '--write' to rename to '#{proposed}'",
27
- }
28
- end
13
+ entry.index_filename ? check_index_paths(entry, base, out) : check_all_paths(base, out)
29
14
  end
30
15
  out
31
16
  end
32
17
 
33
18
  private
34
19
 
20
+ def check_all_paths(base, out)
21
+ walk_nested(base) do |abs_path, is_dir|
22
+ basename = File.basename(abs_path)
23
+ stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
24
+ next if stem.match?(Key::Grammar::SEGMENT)
25
+
26
+ out << issue(abs_path, stem)
27
+ end
28
+ end
29
+
30
+ # When the entry uses `index_filename:`, only the parent-directory
31
+ # segments leading to each index file participate in keys. Sibling
32
+ # files and unrelated subtrees are not enumerated and must not be
33
+ # flagged. Each illegal segment is reported once per path.
34
+ def check_index_paths(entry, base, out)
35
+ Dir.glob(File.join(base, "**", entry.index_filename)).each do |fp|
36
+ rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
37
+ File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
38
+ next if seg.match?(Key::Grammar::SEGMENT)
39
+
40
+ out << issue(fp, seg)
41
+ end
42
+ end
43
+ end
44
+
45
+ def issue(abs_path, stem)
46
+ proposed = Textus::MigrateKeys.normalize(stem)
47
+ {
48
+ "code" => "key.illegal",
49
+ "level" => "error",
50
+ "subject" => abs_path,
51
+ "path" => abs_path,
52
+ "proposed_key" => proposed,
53
+ "message" => "illegal key segment '#{stem}' at #{abs_path}",
54
+ "fix" => "run 'textus key normalize --dry-run' then '--write' to rename to '#{proposed}'",
55
+ }
56
+ end
57
+
35
58
  def walk_nested(root, &block)
36
59
  Dir.each_child(root) do |name|
37
60
  abs = File.join(root, name)
@@ -6,15 +6,15 @@ module Textus
6
6
 
7
7
  def call
8
8
  declared = collect_declared_handlers
9
- registered = store.registry.rpc_names(:intake).to_set
9
+ registered = store.registry.rpc_names(:resolve_intake).to_set
10
10
 
11
11
  out = (declared - registered).map do |name|
12
12
  {
13
13
  "code" => "intake.handler_missing",
14
14
  "level" => "error",
15
15
  "subject" => name.to_s,
16
- "message" => "manifest references intake handler '#{name}' but no Textus.intake(:#{name}) is registered",
17
- "fix" => "create .textus/hooks/#{name}.rb with `Textus.intake(:#{name}) { ... }`",
16
+ "message" => "manifest references intake handler '#{name}' but no Textus.on(:resolve_intake, :#{name}) is registered",
17
+ "fix" => "create .textus/hooks/#{name}.rb with `Textus.on(:resolve_intake, :#{name}) { ... }`",
18
18
  }
19
19
  end
20
20
 
@@ -23,7 +23,7 @@ module Textus
23
23
  "code" => "intake.handler_orphan",
24
24
  "level" => "warning",
25
25
  "subject" => name.to_s,
26
- "message" => "Textus.intake(:#{name}) is registered but no manifest entry references it",
26
+ "message" => "Textus.on(:resolve_intake, :#{name}) is registered but no manifest entry references it",
27
27
  "fix" => "remove the unused handler, or add an entry with `intake.handler: #{name}`",
28
28
  }
29
29
  end
@@ -0,0 +1,47 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Doctor
5
+ class Check
6
+ # Runs as a standalone module (Check::ProtocolVersion.run(root:)) and also
7
+ # as a class-based doctor check (ProtocolVersion.new(store).call).
8
+ class ProtocolVersion < Check
9
+ # Standalone interface: root is the project root (parent of .textus/).
10
+ def self.run(root:)
11
+ path = File.join(root, ".textus/manifest.yaml")
12
+ return [] unless File.exist?(path)
13
+
14
+ doc = YAML.safe_load_file(path, aliases: false) || {}
15
+ version = doc["version"]
16
+ return [] if version == "textus/3"
17
+
18
+ [{
19
+ "code" => "protocol_mismatch",
20
+ "severity" => "error",
21
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
22
+ "hint" => "Install textus 0.11.x to run the migrator, then upgrade to this version. See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110",
23
+ }]
24
+ end
25
+
26
+ # Doctor check interface: store.root is the .textus/ directory itself,
27
+ # so manifest.yaml lives directly inside it.
28
+ def call
29
+ path = File.join(store.root, "manifest.yaml")
30
+ return [] unless File.exist?(path)
31
+
32
+ doc = YAML.safe_load_file(path, aliases: false) || {}
33
+ version = doc["version"]
34
+ return [] if version == "textus/3"
35
+
36
+ [{
37
+ "code" => "protocol_mismatch",
38
+ "level" => "error",
39
+ "subject" => path,
40
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
41
+ "fix" => "Install textus 0.11.x to run the migrator, then upgrade to this version. See https://github.com/patrick204nqh/textus/blob/main/CHANGELOG.md#0110",
42
+ }]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,18 +1,18 @@
1
1
  module Textus
2
2
  module Doctor
3
3
  class Check
4
- # Flags entries whose key is matched by two or more policy blocks of the
4
+ # Flags entries whose key is matched by two or more rule blocks of the
5
5
  # SAME specificity in the same slot (refresh / handler_allowlist /
6
6
  # promote). Ties are non-deterministic in the parser's pick step, so
7
7
  # they're a configuration smell — surface them.
8
- class PolicyAmbiguity < Check
8
+ class RuleAmbiguity < Check
9
9
  SLOTS = %i[refresh handler_allowlist promote].freeze
10
10
 
11
11
  def call
12
12
  out = []
13
- policies = store.manifest.policies
13
+ rules = store.manifest.rules
14
14
  store.manifest.entries.each do |mentry|
15
- matches = policies.explain(mentry.key)
15
+ matches = rules.explain(mentry.key)
16
16
  next if matches.length < 2
17
17
 
18
18
  SLOTS.each { |slot| out.concat(ambiguities_for(mentry, slot, matches)) }
@@ -34,10 +34,10 @@ module Textus
34
34
  def issue_for(mentry, slot, group)
35
35
  globs = group.map(&:match).sort
36
36
  {
37
- "code" => "policy.ambiguity",
37
+ "code" => "rule.ambiguity",
38
38
  "level" => "warning",
39
39
  "subject" => mentry.key,
40
- "message" => "entry '#{mentry.key}' matches #{group.length} policy blocks at the same " \
40
+ "message" => "entry '#{mentry.key}' matches #{group.length} rule blocks at the same " \
41
41
  "specificity for #{slot}: #{globs.join(", ")}",
42
42
  "fix" => "narrow one of the conflicting match: globs in .textus/manifest.yaml so a single " \
43
43
  "block wins for this key",
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class SchemaViolations < Check
5
5
  def call
6
- res = store.validate_all
6
+ res = Textus::Operations.for(store).reads.validate_all.call
7
7
  res["violations"].map do |v|
8
8
  fix = v["expected"] &&
9
9
  "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
data/lib/textus/doctor.rb CHANGED
@@ -9,6 +9,7 @@ module Textus
9
9
  DOCTOR_CHECK_TIMEOUT_SECONDS = 2
10
10
 
11
11
  CHECKS = [
12
+ Check::ProtocolVersion,
12
13
  Check::ManifestFiles,
13
14
  Check::Schemas,
14
15
  Check::SchemaParseError,
@@ -20,7 +21,7 @@ module Textus
20
21
  Check::AuditLog,
21
22
  Check::UnownedSchemaFields,
22
23
  Check::SchemaViolations,
23
- Check::PolicyAmbiguity,
24
+ Check::RuleAmbiguity,
24
25
  Check::HandlerAllowlist,
25
26
  ].freeze
26
27
 
@@ -52,9 +53,9 @@ module Textus
52
53
 
53
54
  def run_registered_checks(store)
54
55
  out = []
55
- view = Application::Context.new(store: store, role: "human")
56
- store.registry.rpc_names(:check).each do |name|
57
- callable = store.registry.rpc_callable(:check, name)
56
+ view = Application::Context.system(store)
57
+ store.registry.rpc_names(:validate).each do |name|
58
+ callable = store.registry.rpc_callable(:validate, name)
58
59
  begin
59
60
  result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
60
61
  if result.is_a?(Array)
@@ -71,7 +72,7 @@ module Textus
71
72
  rescue StandardError => e
72
73
  out << fail_issue(name, code: "doctor_check.failed",
73
74
  message: "#{e.class}: #{e.message}",
74
- fix: "fix the :check hook in .textus/hooks/")
75
+ fix: "fix the :validate hook in .textus/hooks/")
75
76
  end
76
77
  end
77
78
  out
@@ -9,7 +9,7 @@ module Textus
9
9
  def call(policy, envelope, now:)
10
10
  return Verdict.fresh if policy.ttl_seconds.nil?
11
11
 
12
- last_str = envelope.dig("_meta", "last_refreshed_at")
12
+ last_str = envelope&.meta&.dig("last_refreshed_at")
13
13
  return Verdict.stale("never refreshed") if last_str.nil?
14
14
 
15
15
  last = begin
@@ -1,14 +1,14 @@
1
1
  module Textus
2
2
  module Domain
3
- Permission = Data.define(:zone, :writable_by, :readable_by) do
3
+ Permission = Data.define(:zone, :write_policy, :read_policy) do
4
4
  def allows_write?(role)
5
- writable_by.include?(role.to_s)
5
+ write_policy.include?(role.to_s)
6
6
  end
7
7
 
8
8
  def allows_read?(role)
9
- return true if readable_by == :all
9
+ return true if [:all, ["all"]].include?(read_policy)
10
10
 
11
- readable_by.include?(role.to_s)
11
+ read_policy.include?(role.to_s)
12
12
  end
13
13
  end
14
14
  end
@@ -0,0 +1,31 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ module Predicates
5
+ class HumanAccept
6
+ attr_reader :reason
7
+
8
+ def name
9
+ "human_accept"
10
+ end
11
+
12
+ # The role is passed via `store` (an Application::Context-like object
13
+ # with a `role` reader) or through the entry metadata. In practice,
14
+ # Accept already enforces role == "human" before reaching the
15
+ # promotion gate, so this predicate trivially passes. It documents
16
+ # intent and future-proofs multi-actor accept flows.
17
+ def call(store:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
18
+ role = store.respond_to?(:role) ? store.role.to_s : nil
19
+ # If we cannot determine the role (e.g. store doesn't expose it),
20
+ # we trust that Accept has already checked — allow through.
21
+ return true if role.nil?
22
+
23
+ ok = (role == "human")
24
+ @reason = "current role is '#{role}', expected 'human'" unless ok
25
+ ok
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ module Predicates
5
+ class SchemaValid
6
+ attr_reader :reason
7
+
8
+ def name
9
+ "schema_valid"
10
+ end
11
+
12
+ def call(entry:, store:) # rubocop:disable Metrics/PerceivedComplexity
13
+ return true if entry.nil? || store.nil?
14
+
15
+ target_key = entry.meta&.dig("proposal", "target_key")
16
+ return true unless target_key
17
+
18
+ mentry, = store.manifest.resolve(target_key)
19
+ schema_ref = mentry&.schema
20
+ return true unless schema_ref
21
+
22
+ schema = store.schema_for(schema_ref)
23
+ return true unless schema
24
+
25
+ frontmatter = entry.meta&.dig("frontmatter") || {}
26
+ begin
27
+ schema.validate!(frontmatter)
28
+ rescue Textus::SchemaViolation => e
29
+ @reason = e.message.dup
30
+ d = e.details
31
+ if d.is_a?(Hash)
32
+ if d["missing"]
33
+ @reason = "missing required fields: #{Array(d["missing"]).join(", ")}"
34
+ elsif d["field"]
35
+ @reason = "field '#{d["field"]}': #{d["reason"]}"
36
+ end
37
+ end
38
+ return false
39
+ end
40
+
41
+ true
42
+ rescue StandardError => e
43
+ @reason = "schema validation error: #{e.message}"
44
+ false
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ # Promotion evaluates a list of named predicates against a pending-proposal
5
+ # entry and returns a Result indicating whether all requirements are met.
6
+ class Promotion
7
+ Result = Struct.new(:ok?, :reasons, keyword_init: true)
8
+
9
+ REGISTRY = {
10
+ "schema_valid" => -> { Predicates::SchemaValid.new },
11
+ "human_accept" => -> { Predicates::HumanAccept.new },
12
+ }.freeze
13
+
14
+ def self.from_names(names)
15
+ predicates = Array(names).map do |n|
16
+ ctor = REGISTRY[n.to_s] or raise Textus::UsageError.new(
17
+ "unknown promotion predicate: '#{n}' (known: #{REGISTRY.keys.join(", ")})",
18
+ )
19
+ ctor.call
20
+ end
21
+ new(predicates: predicates)
22
+ end
23
+
24
+ attr_reader :predicates
25
+
26
+ def initialize(predicates:)
27
+ @predicates = predicates
28
+ end
29
+
30
+ def predicate_names
31
+ @predicates.map(&:name)
32
+ end
33
+
34
+ def evaluate(entry:, store:)
35
+ reasons = []
36
+ @predicates.each do |pred|
37
+ ok = pred.call(entry: entry, store: store)
38
+ reasons << "#{pred.name}: #{pred.reason || "predicate failed"}" unless ok
39
+ end
40
+ Result.new(ok?: reasons.empty?, reasons: reasons)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -25,6 +25,34 @@ module Textus
25
25
  def self.validate_against(schema, parsed)
26
26
  schema.validate!(parsed["_meta"] || {})
27
27
  end
28
+
29
+ def self.nested_glob
30
+ raise NotImplementedError.new("#{name}.nested_glob not implemented")
31
+ end
32
+
33
+ def self.validate_path_extension(_path, _nested)
34
+ raise NotImplementedError.new("#{name}.validate_path_extension not implemented")
35
+ end
36
+
37
+ def self.inject_uid(_meta, _content, _existing_uid)
38
+ raise NotImplementedError.new("#{name}.inject_uid not implemented")
39
+ end
40
+
41
+ def self.enforce_name_match!(_path, _meta)
42
+ raise NotImplementedError.new("#{name}.enforce_name_match! not implemented")
43
+ end
44
+
45
+ def self.serialize_for_put(meta:, body:, content:, path:)
46
+ _ = meta
47
+ _ = body
48
+ _ = content
49
+ _ = path
50
+ raise NotImplementedError.new("#{name}.serialize_for_put not implemented")
51
+ end
52
+
53
+ def self.rewrite_name(_path, _basename)
54
+ raise NotImplementedError.new("#{name}.rewrite_name not implemented")
55
+ end
28
56
  end
29
57
  end
30
58
  end
@@ -42,6 +42,65 @@ module Textus
42
42
  end
43
43
 
44
44
  def self.extensions = [".json"]
45
+
46
+ def self.nested_glob = "**/*.json"
47
+
48
+ def self.serialize_for_put(meta:, body:, content:, path:)
49
+ raise UsageError.new("put for json requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
50
+
51
+ if content.nil?
52
+ begin
53
+ parsed = parse(body.to_s, path: path)
54
+ rescue BadFrontmatter => e
55
+ raise BadContent.new(path, "bad_content: #{e.message}")
56
+ end
57
+ [body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
58
+ else
59
+ bytes = serialize(meta: meta, body: "", content: content)
60
+ [bytes, meta, bytes, content]
61
+ end
62
+ end
63
+
64
+ # Mutating filesystem op; returns true if a write happened.
65
+ def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
66
+ raw = File.binread(path)
67
+ parsed = parse(raw, path: path)
68
+ meta = parsed["_meta"]
69
+ return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
70
+
71
+ new_meta = meta.merge("name" => basename)
72
+ File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
73
+ true
74
+ end
75
+
76
+ def self.enforce_name_match!(path, meta)
77
+ return unless meta.is_a?(Hash) && meta["name"]
78
+
79
+ ext = extensions.first
80
+ basename = File.basename(path, ext)
81
+ return if meta["name"] == basename
82
+
83
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
84
+ end
85
+
86
+ def self.inject_uid(meta, content, existing_uid)
87
+ m = meta.is_a?(Hash) ? meta.dup : {}
88
+ m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
89
+ [m, content]
90
+ end
91
+
92
+ def self.validate_path_extension(path, nested)
93
+ ext = File.extname(path)
94
+ if nested
95
+ return if ext == ""
96
+
97
+ raise UsageError.new("nested json path must not have an extension")
98
+ end
99
+
100
+ return if ext == ".json"
101
+
102
+ raise UsageError.new("json format requires '.json' path (got #{ext.inspect})")
103
+ end
45
104
  end
46
105
  end
47
106
  end
@@ -34,6 +34,52 @@ module Textus
34
34
  end
35
35
 
36
36
  def self.extensions = [".md"]
37
+
38
+ def self.nested_glob = "**/*.md"
39
+
40
+ def self.serialize_for_put(meta:, body:, content:, path:)
41
+ _ = path
42
+ _ = content
43
+ bytes = serialize(meta: meta || {}, body: body.to_s)
44
+ [bytes, meta, body.to_s, nil]
45
+ end
46
+
47
+ # Mutating filesystem op; returns true if a write happened (boolean is
48
+ # informational, not a predicate). Rubocop's predicate-name heuristic
49
+ # disabled here on purpose.
50
+ def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
51
+ raw = File.binread(path)
52
+ parsed = parse(raw, path: path)
53
+ meta = parsed["_meta"] || {}
54
+ return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
55
+
56
+ new_meta = meta.merge("name" => basename)
57
+ File.binwrite(path, serialize(meta: new_meta, body: parsed["body"]))
58
+ true
59
+ end
60
+
61
+ def self.enforce_name_match!(path, meta)
62
+ return unless meta.is_a?(Hash) && meta["name"]
63
+
64
+ ext = extensions.first
65
+ basename = File.basename(path, ext)
66
+ return if meta["name"] == basename
67
+
68
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
69
+ end
70
+
71
+ def self.inject_uid(meta, content, existing_uid)
72
+ m = meta.is_a?(Hash) ? meta.dup : {}
73
+ m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
74
+ [m, content]
75
+ end
76
+
77
+ def self.validate_path_extension(path, _nested)
78
+ ext = File.extname(path)
79
+ return if ["", ".md"].include?(ext)
80
+
81
+ raise UsageError.new("markdown format requires '.md' path (got #{ext.inspect})")
82
+ end
37
83
  end
38
84
  end
39
85
  end
@@ -18,6 +18,41 @@ module Textus
18
18
  end
19
19
 
20
20
  def self.extensions = [".txt"]
21
+
22
+ def self.nested_glob = "**/*.txt"
23
+
24
+ def self.inject_uid(meta, content, _existing_uid)
25
+ [meta, content]
26
+ end
27
+
28
+ def self.enforce_name_match!(_path, _meta)
29
+ # text has no meta home; no-op
30
+ end
31
+
32
+ def self.serialize_for_put(meta:, body:, content:, path:)
33
+ _ = path
34
+ _ = content
35
+ bytes = serialize(meta: meta || {}, body: body.to_s)
36
+ [bytes, meta, body.to_s, nil]
37
+ end
38
+
39
+ # No-op; text has no meta. Returns false (never writes).
40
+ def self.rewrite_name(_path, _basename) # rubocop:disable Naming/PredicateMethod
41
+ false
42
+ end
43
+
44
+ def self.validate_path_extension(path, nested)
45
+ ext = File.extname(path)
46
+ if nested
47
+ return if ext == ""
48
+
49
+ raise UsageError.new("nested text path must not have an extension")
50
+ end
51
+
52
+ return if [".txt", ""].include?(ext)
53
+
54
+ raise UsageError.new("text format requires '.txt' or no extension (got #{ext.inspect})")
55
+ end
21
56
  end
22
57
  end
23
58
  end