intent-record 1.0.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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +137 -0
  5. data/db/migrate/20260918000001_create_initial_schema.rb +68 -0
  6. data/exe/intent-record +5 -0
  7. data/lib/intent_record/application_record.rb +8 -0
  8. data/lib/intent_record/asset_version_normalizer.rb +43 -0
  9. data/lib/intent_record/asset_version_resolver.rb +76 -0
  10. data/lib/intent_record/cli/argv_parser.rb +78 -0
  11. data/lib/intent_record/cli/dispatch.rb +118 -0
  12. data/lib/intent_record/cli/stdin_json.rb +28 -0
  13. data/lib/intent_record/cli/streams.rb +9 -0
  14. data/lib/intent_record/cli/usage.rb +29 -0
  15. data/lib/intent_record/cli.rb +69 -0
  16. data/lib/intent_record/commands/attach.rb +39 -0
  17. data/lib/intent_record/commands/by_source.rb +52 -0
  18. data/lib/intent_record/commands/lookup.rb +22 -0
  19. data/lib/intent_record/commands/recent.rb +21 -0
  20. data/lib/intent_record/commands/record.rb +52 -0
  21. data/lib/intent_record/commands/search.rb +50 -0
  22. data/lib/intent_record/commands/show.rb +19 -0
  23. data/lib/intent_record/commands/systems.rb +16 -0
  24. data/lib/intent_record/config.rb +51 -0
  25. data/lib/intent_record/database.rb +74 -0
  26. data/lib/intent_record/formatter.rb +59 -0
  27. data/lib/intent_record/global_id.rb +22 -0
  28. data/lib/intent_record/input_validator.rb +69 -0
  29. data/lib/intent_record/like_pattern.rb +34 -0
  30. data/lib/intent_record/linkers/asset_version_linker.rb +43 -0
  31. data/lib/intent_record/linkers/asset_version_specs.rb +30 -0
  32. data/lib/intent_record/linkers/intent_linker.rb +35 -0
  33. data/lib/intent_record/linkers/stakeholder_linker.rb +61 -0
  34. data/lib/intent_record/models/asset_version.rb +12 -0
  35. data/lib/intent_record/models/intent_record.rb +29 -0
  36. data/lib/intent_record/models/intent_record_asset_version.rb +11 -0
  37. data/lib/intent_record/models/intent_record_link.rb +22 -0
  38. data/lib/intent_record/models/stakeholder_reference.rb +11 -0
  39. data/lib/intent_record/models/stakeholder_source.rb +12 -0
  40. data/lib/intent_record/models/stakeholder_system.rb +10 -0
  41. data/lib/intent_record/models/vcs_system.rb +10 -0
  42. data/lib/intent_record/seeds.rb +38 -0
  43. data/lib/intent_record/sqlite_connection_setup.rb +22 -0
  44. data/lib/intent_record/stakeholder_normalizer.rb +33 -0
  45. data/lib/intent_record/version.rb +3 -0
  46. data/lib/intent_record/web/app.rb +54 -0
  47. data/lib/intent_record/web/boot.rb +51 -0
  48. data/lib/intent_record/web/public/styles.css +50 -0
  49. data/lib/intent_record/web/query_router.rb +25 -0
  50. data/lib/intent_record/web/routes/commits.rb +16 -0
  51. data/lib/intent_record/web/routes/home.rb +21 -0
  52. data/lib/intent_record/web/routes/intents.rb +15 -0
  53. data/lib/intent_record/web/routes/search.rb +17 -0
  54. data/lib/intent_record/web/routes/sources.rb +19 -0
  55. data/lib/intent_record/web/views/_intent_cards.erb +43 -0
  56. data/lib/intent_record/web/views/commit.erb +12 -0
  57. data/lib/intent_record/web/views/error.erb +7 -0
  58. data/lib/intent_record/web/views/home.erb +14 -0
  59. data/lib/intent_record/web/views/intent.erb +5 -0
  60. data/lib/intent_record/web/views/layout.erb +28 -0
  61. data/lib/intent_record/web/views/search.erb +5 -0
  62. data/lib/intent_record/web/views/sources.erb +16 -0
  63. data/lib/intent_record.rb +28 -0
  64. metadata +194 -0
@@ -0,0 +1,69 @@
1
+ require "json"
2
+ require_relative "cli/streams"
3
+ require_relative "cli/argv_parser"
4
+ require_relative "cli/dispatch"
5
+
6
+ module IntentRecord
7
+ # Entry point for the `intent-record` executable. JSON in, JSON out, exit 0 on success.
8
+ class CLI
9
+ CONFIG_DIR_FLAG = "--config-dir".freeze
10
+
11
+ def self.run(argv)
12
+ exit new(argv, config: Config.default, streams: Streams.default).run
13
+ end
14
+
15
+ def initialize(argv, config:, streams:)
16
+ @argv = argv.dup
17
+ @config = config
18
+ @streams = streams
19
+ end
20
+
21
+ def run
22
+ take_config_dir!
23
+ dispatch(@argv.shift)
24
+ rescue Error => e
25
+ emit_error(e.message)
26
+ end
27
+
28
+ private
29
+
30
+ # Lifted before the command is taken, so it may be written on either side of
31
+ # it. A misspelling is left in argv, where it becomes an unknown command or an
32
+ # unexpected argument: an option this CLI does not recognise is an error,
33
+ # which an environment variable nobody set can never be.
34
+ def take_config_dir!
35
+ parser = ArgvParser.new(@argv)
36
+ dir = parser.take_flag(CONFIG_DIR_FLAG)
37
+ @argv = parser.remaining
38
+ @config = Config.new(config_dir: dir) if dir
39
+ end
40
+
41
+ def dispatch(command)
42
+ return usage if command.nil? || (@argv + [command]).intersect?(%w[--help -h])
43
+ return version if %w[--version version].include?(command)
44
+
45
+ connect!
46
+ Dispatch.new(@argv, streams: @streams, config: @config).call(command)
47
+ end
48
+
49
+ def connect!
50
+ @config.load!
51
+ Database.connect!(@config.db_path)
52
+ end
53
+
54
+ def usage
55
+ @streams.stderr.puts USAGE
56
+ 0
57
+ end
58
+
59
+ def version
60
+ @streams.stdout.puts VERSION
61
+ 0
62
+ end
63
+
64
+ def emit_error(message)
65
+ @streams.stdout.puts JSON.generate({ "error" => message })
66
+ 1
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,39 @@
1
+ require_relative "../formatter"
2
+ require_relative "../models/intent_record"
3
+ require_relative "../linkers/asset_version_linker"
4
+ require_relative "../linkers/stakeholder_linker"
5
+ require_relative "../linkers/intent_linker"
6
+
7
+ module IntentRecord
8
+ module Commands
9
+ # Links more commits, stakeholder sources or related intents to an existing intent record.
10
+ class Attach
11
+ LINK_KEYS = %w[commits asset_versions stakeholder_references related_intent_ids].freeze
12
+
13
+ def initialize(intent_id:)
14
+ @intent_id = intent_id
15
+ end
16
+
17
+ def call(input)
18
+ record = Models::IntentRecord.find_by(global_id: @intent_id)
19
+ raise NotFoundError, "Intent record not found: #{@intent_id}" unless record
20
+ raise ValidationError, "Nothing to attach: provide one of #{LINK_KEYS.join(", ")}" if nothing_to_attach?(input)
21
+
22
+ ActiveRecord::Base.transaction { link_all(record, input) }
23
+ Formatter.full(record.reload)
24
+ end
25
+
26
+ private
27
+
28
+ def nothing_to_attach?(input)
29
+ LINK_KEYS.none? { |key| !input[key].nil? && input[key] != [] }
30
+ end
31
+
32
+ def link_all(record, input)
33
+ Linkers::AssetVersionLinker.call(record, input)
34
+ Linkers::StakeholderLinker.call(record, input)
35
+ Linkers::IntentLinker.call(record, input)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "../formatter"
2
+ require_relative "../like_pattern"
3
+ require_relative "../stakeholder_normalizer"
4
+ require_relative "../models/stakeholder_source"
5
+
6
+ module IntentRecord
7
+ module Commands
8
+ # Answers "what did we build for this ticket, and why": every intent linked to a
9
+ # stakeholder source (by exact uri or substring) plus the distinct commits across them.
10
+ class BySource
11
+ def initialize(uri:, contains: false)
12
+ @uri = uri.strip
13
+ @contains = contains
14
+ end
15
+
16
+ def call
17
+ sources = matching_sources.includes(:stakeholder_system).to_a
18
+ intents = intents_for(sources)
19
+ {
20
+ "sources" => sources.map { |s| Formatter.source(s) },
21
+ "asset_versions" => intents.flat_map { |i| i["asset_versions"] }.uniq,
22
+ "intents" => intents
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def matching_sources
29
+ return sources.where(uri: StakeholderNormalizer.uri(@uri)) unless @contains
30
+
31
+ sources.where(LikePattern.contains("uri"), LikePattern.contains_bind(@uri))
32
+ end
33
+
34
+ # In the order the sources were first recorded. An unordered query is
35
+ # answered in whatever order the plan produces, and that is a list a person
36
+ # reads.
37
+ def sources
38
+ Models::StakeholderSource.order(:id)
39
+ end
40
+
41
+ def intents_for(sources)
42
+ Formatter.preloaded(matching_intents(sources)).map { |r| Formatter.full(r) }
43
+ end
44
+
45
+ def matching_intents(sources)
46
+ Models::IntentRecord.joins(:stakeholder_references)
47
+ .where(stakeholder_references: { stakeholder_source_id: sources.map(&:id) })
48
+ .distinct.order(:created_at)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,22 @@
1
+ require_relative "../asset_version_resolver"
2
+ require_relative "../formatter"
3
+
4
+ module IntentRecord
5
+ module Commands
6
+ # Everything recorded against one commit (or other asset version).
7
+ class Lookup
8
+ def initialize(external_id:, vcs: nil)
9
+ @external_id = external_id
10
+ @vcs = vcs
11
+ end
12
+
13
+ def call
14
+ version = AssetVersionResolver.new(external_id: @external_id, vcs: @vcs).call
15
+ {
16
+ "asset_version" => Formatter.asset_version(version),
17
+ "intents" => Formatter.preloaded(version.intent_records.order(:created_at)).map { |r| Formatter.full(r) }
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ require_relative "../formatter"
2
+ require_relative "../models/intent_record"
3
+
4
+ module IntentRecord
5
+ module Commands
6
+ class Recent
7
+ DEFAULT_LIMIT = 20
8
+
9
+ def initialize(limit: DEFAULT_LIMIT)
10
+ @limit = limit
11
+ end
12
+
13
+ def call
14
+ raise ValidationError, "--limit must be at least 1" if @limit < 1
15
+
16
+ records = Formatter.preloaded(Models::IntentRecord.order(created_at: :desc, id: :desc).limit(@limit))
17
+ { "intents" => records.map { |r| Formatter.full(r) } }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "../input_validator"
2
+ require_relative "../formatter"
3
+ require_relative "../global_id"
4
+ require_relative "../models/intent_record"
5
+ require_relative "../linkers/asset_version_linker"
6
+ require_relative "../linkers/stakeholder_linker"
7
+ require_relative "../linkers/intent_linker"
8
+
9
+ module IntentRecord
10
+ module Commands
11
+ # Creates an intent record and links it to commits, stakeholder sources and related intents.
12
+ class Record
13
+ def call(input)
14
+ attributes = validated(input)
15
+
16
+ ActiveRecord::Base.transaction do
17
+ record = create!(attributes)
18
+ link_all(record, input)
19
+ Formatter.full(record)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ # Nothing here reaches the database, so bad input is refused before the
26
+ # store is asked anything, and these rules can be exercised without one.
27
+ def validated(input)
28
+ { summary: summary(input),
29
+ body: InputValidator.required_string!(input, "body"),
30
+ author: InputValidator.optional_string!(input, "author"),
31
+ created_at: Time.now.utc }
32
+ end
33
+
34
+ # Minting the id asks the store whether it is taken, which is why it waits
35
+ # until the input has been accepted.
36
+ def create!(attributes)
37
+ Models::IntentRecord.create!(attributes.merge(global_id: GlobalId.unique_for(Models::IntentRecord)))
38
+ end
39
+
40
+ def summary(input)
41
+ InputValidator.required_string!(input, "summary", max_length: Models::IntentRecord::SUMMARY_MAX_LENGTH)
42
+ .gsub(/\s+/, " ")
43
+ end
44
+
45
+ def link_all(record, input)
46
+ Linkers::AssetVersionLinker.call(record, input)
47
+ Linkers::StakeholderLinker.call(record, input)
48
+ Linkers::IntentLinker.call(record, input)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,50 @@
1
+ require_relative "../formatter"
2
+ require_relative "../like_pattern"
3
+ require_relative "../models/intent_record"
4
+
5
+ module IntentRecord
6
+ module Commands
7
+ # Case-insensitive substring search over summary, body, and linked stakeholder uris and titles,
8
+ # so a ticket key such as ACME-42 finds the intents built for it.
9
+ class Search
10
+ LIMIT = 200
11
+ FIELDS = %w[intent_records.summary intent_records.body stakeholder_sources.uri stakeholder_sources.title].freeze
12
+
13
+ def initialize(terms:, match: "any")
14
+ @terms = terms.map(&:strip).reject(&:empty?)
15
+ @match = match
16
+ end
17
+
18
+ def call
19
+ raise ValidationError, "At least one search term is required" if @terms.empty?
20
+ raise ValidationError, "--match must be any or all" unless %w[any all].include?(@match)
21
+
22
+ { "intents" => matching_records.map { |r| Formatter.full(r) } }
23
+ end
24
+
25
+ private
26
+
27
+ def matching_records
28
+ Formatter.preloaded(
29
+ Models::IntentRecord.left_joins(:stakeholder_sources)
30
+ .group("intent_records.id")
31
+ .having(having_sql, *having_binds)
32
+ .order(created_at: :desc).limit(LIMIT)
33
+ )
34
+ end
35
+
36
+ def having_sql
37
+ joiner = @match == "all" ? " AND " : " OR "
38
+ @terms.map { "SUM(CASE WHEN #{term_clause} THEN 1 ELSE 0 END) > 0" }.join(joiner)
39
+ end
40
+
41
+ def term_clause
42
+ FIELDS.map { |f| LikePattern.contains(f) }.join(" OR ")
43
+ end
44
+
45
+ def having_binds
46
+ @terms.flat_map { |t| [LikePattern.contains_bind(t)] * FIELDS.size }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "../formatter"
2
+ require_relative "../models/intent_record"
3
+
4
+ module IntentRecord
5
+ module Commands
6
+ class Show
7
+ def initialize(intent_id:)
8
+ @intent_id = intent_id
9
+ end
10
+
11
+ def call
12
+ record = Formatter.preloaded(Models::IntentRecord).find_by(global_id: @intent_id)
13
+ raise NotFoundError, "Intent record not found: #{@intent_id}" unless record
14
+
15
+ Formatter.full(record)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ require_relative "../models/vcs_system"
2
+ require_relative "../models/stakeholder_system"
3
+
4
+ module IntentRecord
5
+ module Commands
6
+ # Vocabulary discovery: which vcs and stakeholder system names are known.
7
+ class Systems
8
+ def call
9
+ {
10
+ "vcs_systems" => Models::VcsSystem.order(:name).pluck(:name),
11
+ "stakeholder_systems" => Models::StakeholderSystem.order(:name).pluck(:name)
12
+ }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,51 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+
4
+ module IntentRecord
5
+ # Locates the config directory and the SQLite database path.
6
+ # Bootstraps ~/.intent-record/config.yml on first use.
7
+ class Config
8
+ DEFAULT_CONFIG_DIR = File.expand_path("~/.intent-record")
9
+ DB_FILENAME = "intent-record.db".freeze
10
+
11
+ attr_reader :config_dir, :db_path
12
+
13
+ # The one caller that means "wherever this person keeps their records" says so
14
+ # by name. Everywhere else has to name a directory, so no caller can arrive at
15
+ # somebody's own store by losing an argument.
16
+ def self.default
17
+ new(config_dir: ENV.fetch("INTENT_RECORD_CONFIG_DIR", DEFAULT_CONFIG_DIR))
18
+ end
19
+
20
+ def initialize(config_dir:)
21
+ raise ArgumentError, "config_dir is required; use Config.default for this user's own store" if config_dir.nil?
22
+
23
+ @config_dir = config_dir
24
+ end
25
+
26
+ def load!
27
+ bootstrap! unless File.exist?(config_file_path)
28
+ read_config!
29
+ self
30
+ end
31
+
32
+ private
33
+
34
+ def bootstrap!
35
+ FileUtils.mkdir_p(@config_dir)
36
+ File.write(config_file_path, YAML.dump("db_path" => File.join(@config_dir, DB_FILENAME)))
37
+ end
38
+
39
+ def config_file_path
40
+ File.join(@config_dir, "config.yml")
41
+ end
42
+
43
+ def read_config!
44
+ data = YAML.load_file(config_file_path)
45
+ valid = data.is_a?(Hash) && !data["db_path"].to_s.strip.empty?
46
+ raise ConfigError, "db_path is not configured in #{config_file_path}" unless valid
47
+
48
+ @db_path = data["db_path"]
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,74 @@
1
+ require "active_record"
2
+ require "fileutils"
3
+ require "sqlite3"
4
+ require_relative "seeds"
5
+ require_relative "sqlite_connection_setup"
6
+
7
+ module IntentRecord
8
+ module Database
9
+ MIGRATIONS_PATH = File.expand_path("../../db/migrate", __dir__)
10
+
11
+ # mkdir_p raises an Errno when the parent cannot be created, but once the
12
+ # directory exists sqlite reports an unwritable file or directory through
13
+ # ActiveRecord instead, so both families have to become the same DatabaseError.
14
+ UNWRITABLE_CAUSES = [SQLite3::CantOpenException, SQLite3::ReadOnlyException].freeze
15
+
16
+ def self.connect!(db_path)
17
+ translating_unwritable(db_path) do
18
+ FileUtils.mkdir_p(File.dirname(db_path))
19
+ establish!(db_path)
20
+ while_setting_up(db_path) { migrate_and_seed! }
21
+ end
22
+ end
23
+
24
+ def self.translating_unwritable(db_path)
25
+ yield
26
+ rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
27
+ raise DatabaseError, unwritable(db_path, e)
28
+ rescue ActiveRecord::StatementInvalid => e
29
+ raise unless UNWRITABLE_CAUSES.any? { |klass| e.cause.is_a?(klass) }
30
+
31
+ raise DatabaseError, unwritable(db_path, e.cause)
32
+ end
33
+
34
+ def self.establish!(db_path)
35
+ ActiveRecord::Base.logger = nil
36
+ # The pragma sets sqlite's own busy timeout, which is not the same thing as
37
+ # the adapter's: without :timeout ActiveRecord installs no busy handler and
38
+ # a write that meets a concurrent one fails rather than waiting.
39
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: db_path,
40
+ timeout: SqliteConnectionSetup::BUSY_TIMEOUT_MS)
41
+ end
42
+
43
+ def self.migrate_and_seed!
44
+ run_migrations!
45
+ Seeds.apply!
46
+ end
47
+
48
+ def self.unwritable(db_path, error)
49
+ "Database path is not writable: #{db_path} (#{error.message})"
50
+ end
51
+
52
+ def self.disconnect!
53
+ ActiveRecord::Base.remove_connection
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ # Migrating and seeding both read and then write, so two processes reaching a
59
+ # new store together each find the tables missing and each create them. Their
60
+ # individual statements succeed, so sqlite's locking has nothing to object to
61
+ # until one of them hits "table already exists". Serialise the whole setup.
62
+ def self.while_setting_up(db_path)
63
+ File.open("#{db_path}.setup", File::RDWR | File::CREAT) do |lock|
64
+ lock.flock(File::LOCK_EX)
65
+ yield
66
+ end
67
+ end
68
+
69
+ def self.run_migrations!
70
+ ActiveRecord::Migration.verbose = false
71
+ ActiveRecord::MigrationContext.new(MIGRATIONS_PATH).migrate
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,59 @@
1
+ module IntentRecord
2
+ # Turns models into the JSON shapes the CLI emits. Internal ids never leak.
3
+ #
4
+ # `full` reads four associations per record and does not load them itself, so a
5
+ # caller formatting more than one record hands it a scope through `preloaded`.
6
+ # Loading them here instead would query once per record however the caller
7
+ # asked, which is most of the work in a list of two hundred.
8
+ module Formatter
9
+ PRELOADS = [{ asset_versions: :vcs_system },
10
+ { stakeholder_sources: :stakeholder_system },
11
+ { outgoing_links: :target },
12
+ { incoming_links: :source }].freeze
13
+
14
+ module_function
15
+
16
+ def preloaded(scope)
17
+ scope.preload(*PRELOADS)
18
+ end
19
+
20
+ def summary(record)
21
+ {
22
+ "intent_id" => record.global_id,
23
+ "summary" => record.summary,
24
+ "author" => record.author,
25
+ "created_at" => record.created_at.utc.iso8601
26
+ }
27
+ end
28
+
29
+ def full(record)
30
+ summary(record).merge("body" => record.body).merge(external_links(record)).merge(intent_links(record))
31
+ end
32
+
33
+ def external_links(record)
34
+ {
35
+ "asset_versions" => record.asset_versions.map { |v| asset_version(v) },
36
+ "stakeholder_references" => record.stakeholder_sources.map { |s| source(s) }
37
+ }
38
+ end
39
+
40
+ def intent_links(record)
41
+ {
42
+ "related_intents" => record.outgoing_links.map { |l| summary(l.target) },
43
+ "related_by_intents" => record.incoming_links.map { |l| summary(l.source) }
44
+ }
45
+ end
46
+
47
+ def asset_version(version)
48
+ { "vcs" => version.vcs_system.name, "external_id" => version.external_id }
49
+ end
50
+
51
+ def source(source)
52
+ { "system" => source.stakeholder_system.name, "uri" => source.uri, "title" => source.title }
53
+ end
54
+
55
+ # Steps of `full`. Only `full`, `preloaded`, `asset_version` and `source` are
56
+ # asked for from outside.
57
+ private_class_method :summary, :external_links, :intent_links
58
+ end
59
+ end
@@ -0,0 +1,22 @@
1
+ module IntentRecord
2
+ module GlobalId
3
+ BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".freeze
4
+ PATTERN = /\A[1-9A-HJ-NP-Za-km-z]{7}\z/
5
+ LENGTH = 7
6
+
7
+ def self.generate
8
+ Array.new(LENGTH) { BASE58_ALPHABET[rand(58)] }.join
9
+ end
10
+
11
+ def self.valid?(id)
12
+ id.is_a?(String) && PATTERN.match?(id)
13
+ end
14
+
15
+ def self.unique_for(model)
16
+ loop do
17
+ candidate = generate
18
+ return candidate unless model.exists?(global_id: candidate)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,69 @@
1
+ module IntentRecord
2
+ # Shape checks for the JSON an agent sends on stdin. Raises ValidationError with a field-named message.
3
+ module InputValidator
4
+ module_function
5
+
6
+ def required_string!(input, key, max_length: nil)
7
+ value = non_blank_string!(input, key)
8
+ max_length ? within_length!(value, key, max_length) : value
9
+ end
10
+
11
+ def optional_string!(input, key)
12
+ value = input[key]
13
+ return nil if value.nil?
14
+ raise ValidationError, "#{key} must be a string" unless value.is_a?(String)
15
+
16
+ value = value.strip
17
+ value.empty? ? nil : value
18
+ end
19
+
20
+ def array!(input, key)
21
+ value = input[key]
22
+ return [] if value.nil?
23
+ raise ValidationError, "#{key} must be an array" unless value.is_a?(Array)
24
+
25
+ value
26
+ end
27
+
28
+ # Judged by its stripped form, so returned stripped too. Returning the padded
29
+ # value would hand a caller something this module has already treated as the
30
+ # same as a shorter string, and a caller that looks an id up verbatim would
31
+ # then miss a record that exists.
32
+ def non_blank_strings!(values, label)
33
+ values.map do |v|
34
+ raise ValidationError, "#{label} must be a non-empty string" unless v.is_a?(String) && !v.strip.empty?
35
+
36
+ v.strip
37
+ end
38
+ end
39
+
40
+ def hashes_with!(values, label, *keys)
41
+ values.each do |v|
42
+ raise ValidationError, "#{label} entries must be objects" unless v.is_a?(Hash)
43
+
44
+ keys.each { |k| required_string!(v, k) }
45
+ end
46
+ values
47
+ end
48
+
49
+ def non_blank_string!(input, key)
50
+ value = input[key]
51
+ raise ValidationError, "#{key} is required" if value.nil?
52
+ raise ValidationError, "#{key} must be a string" unless value.is_a?(String)
53
+
54
+ value = value.strip
55
+ raise ValidationError, "#{key} cannot be blank" if value.empty?
56
+
57
+ value
58
+ end
59
+
60
+ def within_length!(value, key, max_length)
61
+ raise ValidationError, "#{key} must be #{max_length} characters or fewer" if value.length > max_length
62
+
63
+ value
64
+ end
65
+
66
+ # Steps of required_string!, not part of what this module offers callers.
67
+ private_class_method :non_blank_string!, :within_length!
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ require "active_record"
2
+
3
+ module IntentRecord
4
+ # Builds case-insensitive, literal-safe LIKE fragments. SQLite only honours the escape
5
+ # character when an ESCAPE clause is present, so every LIKE goes through here.
6
+ module LikePattern
7
+ ESCAPE = "ESCAPE '\\'".freeze
8
+
9
+ module_function
10
+
11
+ def contains(column)
12
+ "ULOWER(#{column}) LIKE ? #{ESCAPE}"
13
+ end
14
+
15
+ def contains_bind(term)
16
+ "%#{escape(term.downcase)}%"
17
+ end
18
+
19
+ def prefix(column)
20
+ "#{column} LIKE ? #{ESCAPE}"
21
+ end
22
+
23
+ def prefix_bind(term)
24
+ "#{escape(term)}%"
25
+ end
26
+
27
+ def escape(term)
28
+ ActiveRecord::Base.sanitize_sql_like(term)
29
+ end
30
+
31
+ # A step of the two binds, not part of what this module offers callers.
32
+ private_class_method :escape
33
+ end
34
+ end