import: Config is a Data with a required host
Read refuses a config that names none — missing, empty, or blank —
naming the file, so ImportTasks.host has no nil to field.

Assisted-by: GLM-5.3 via pi
change qvwkspwzunlrqvmsnppnqtzlmvqpupzo
commit 9927e51ef956deb0dc43e22d0ea925979fc17e14
author Alpha Chen <alpha@kejadlen.dev>
date
parent syvxzmtk
diff --git a/lib/pro_tacts/import/config.rb b/lib/pro_tacts/import/config.rb
index 9dbf24e..19c5012 100644
--- a/lib/pro_tacts/import/config.rb
+++ b/lib/pro_tacts/import/config.rb
@@ -2,28 +2,34 @@ require "yaml"
 
 module ProTacts
   module Import
-    # The standing data the import tasks keep between plans, as a YAML
-    # mapping: data/import/config.yml, at the root of the import
-    # collateral under data/import. Not committed — /data is ignored —
-    # because it holds this deployment's own host and choices. The keys
-    # are the tasks' to grow as they take standing data off the command
-    # line.
-    class Config
-      # A missing file is no configuration and an empty one is none
-      # either; a file whose top level is not a mapping is refused
-      # rather than half-read, naming the file.
-      #: (Pathname path) -> Hash[untyped, untyped]
+    # The standing data the import tasks keep between plans, read from
+    # data/import/config.yml, at the root of the import collateral
+    # under data/import. Not committed — /data is ignored — because it
+    # holds this deployment's own host and choices. Each key of the
+    # file is a member, grown one at a time as the tasks take standing
+    # data off the command line.
+    # @rbs skip
+    class Config < Data.define(:host)
+      # Every problem is refused rather than skipped, naming the file —
+      # Card#read's rules for a hand-edited file. The host is required
+      # rather than nil-able: the tasks that read this are the ones
+      # with a host to land on, and a config that names none fails at
+      # the read, before any plan is picked.
       def self.read(path)
-        return {} unless path.file?
+        invalid = ->(why) { raise ArgumentError, "#{path}: #{why}" }
+        invalid.("does not exist") unless path.file?
 
         document = YAML.safe_load_file(path)
-        return {} if document.nil?
+        invalid.("has no host") if document.nil?
+        invalid.("is not a mapping") unless document.is_a?(Hash)
+        unknown = document.keys - members.map(&:to_s)
+        invalid.("has #{unknown.join(", ")}, which no member takes") unless unknown.empty?
 
-        unless document.is_a?(Hash)
-          raise ArgumentError, "#{path} is not a YAML mapping"
-        end
-
-        document
+        host = document.fetch("host", nil)
+        invalid.("has no host") if host.nil?
+        invalid.("host must be text") unless host.is_a?(String)
+        invalid.("host is blank") if host.strip.empty?
+        new(host:)
       end
     end
   end
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index 90c8788..b74ff5c 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -43,6 +43,14 @@ module ProTacts
       end
     end
 
+    class Config
+      attr_reader host: String
+
+      def self.members: () -> Array[Symbol]
+      def self.new: (host: String) -> instance
+      def self.read: (Pathname path) -> instance
+    end
+
     class Finalize
       class Result
         attr_reader done: Integer
diff --git a/tasks/import.rake b/tasks/import.rake
index a5ee47f..0d56d98 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -55,16 +55,13 @@ module ImportTasks
   end
 
   # The host to land a plan on: the standing `host` under CONFIG, the
-  # one place a host is named. A bare hostname is the base URL of a
-  # server that serves HTTPS, which every deployment does; the scheme
-  # is spelled out here so the host a plan records is the one a second
-  # run is compared against.
+  # one place a host is named, and required at the read. A bare
+  # hostname is the base URL of a server that serves HTTPS, which
+  # every deployment does; the scheme is spelled out here so the host
+  # a plan records is the one a second run is compared against.
   #: () -> String
   def self.host
-    named = ProTacts::Import::Config.read(CONFIG).fetch("host", nil)
-    abort "no host to land on: write host: under #{CONFIG}" if named.nil?
-
-    uri = URI.parse(named)
+    uri = URI.parse(ProTacts::Import::Config.read(CONFIG).host)
     uri = URI.parse("https://#{uri}") if uri.scheme.nil?
     uri.to_s
   end
diff --git a/test/pro_tacts/import/test_config.rb b/test/pro_tacts/import/test_config.rb
index 5681d08..0f24f7c 100644
--- a/test/pro_tacts/import/test_config.rb
+++ b/test/pro_tacts/import/test_config.rb
@@ -8,21 +8,25 @@ require "pro_tacts/import/config"
 class ImportConfigTest < Minitest::Test
   Config = ProTacts::Import::Config
 
-  def test_a_missing_file_is_no_configuration
-    Dir.mktmpdir do |dir|
-      assert_equal({}, Config.read(Pathname.new(dir) / "config.yml"))
+  def test_the_mapping_is_read
+    with_config_file("host: https://contacts\n") do |path|
+      assert_equal Config.new(host: "https://contacts"), Config.read(path)
     end
   end
 
-  def test_an_empty_file_is_no_configuration
-    with_config_file("") do |path|
-      assert_equal({}, Config.read(path))
+  def test_a_missing_file_is_refused
+    Dir.mktmpdir do |dir|
+      error = assert_raises(ArgumentError) { Config.read(Pathname.new(dir) / "config.yml") }
+
+      assert_equal "#{Pathname.new(dir) / "config.yml"}: does not exist", error.message
     end
   end
 
-  def test_the_mapping_is_read
-    with_config_file("host: https://contacts\n") do |path|
-      assert_equal({"host" => "https://contacts"}, Config.read(path))
+  def test_an_empty_file_has_no_host
+    with_config_file("") do |path|
+      error = assert_raises(ArgumentError) { Config.read(path) }
+
+      assert_equal "#{path}: has no host", error.message
     end
   end
 
@@ -30,7 +34,31 @@ class ImportConfigTest < Minitest::Test
     with_config_file("- one\n- two\n") do |path|
       error = assert_raises(ArgumentError) { Config.read(path) }
 
-      assert_equal "#{path} is not a YAML mapping", error.message
+      assert_equal "#{path}: is not a mapping", error.message
+    end
+  end
+
+  def test_a_key_no_member_takes_is_refused
+    with_config_file("hots: contacts\n") do |path|
+      error = assert_raises(ArgumentError) { Config.read(path) }
+
+      assert_equal "#{path}: has hots, which no member takes", error.message
+    end
+  end
+
+  def test_a_host_that_is_not_text_is_refused
+    with_config_file("host: 9292\n") do |path|
+      error = assert_raises(ArgumentError) { Config.read(path) }
+
+      assert_equal "#{path}: host must be text", error.message
+    end
+  end
+
+  def test_a_blank_host_is_refused
+    with_config_file("host: \"\"\n") do |path|
+      error = assert_raises(ArgumentError) { Config.read(path) }
+
+      assert_equal "#{path}: host is blank", error.message
     end
   end
 
diff --git a/test/tasks/test_import.rb b/test/tasks/test_import.rb
index c9d8996..839ef07 100644
--- a/test/tasks/test_import.rb
+++ b/test/tasks/test_import.rb
@@ -60,12 +60,12 @@ class ImportTasksTest < Minitest::Test
     end
   end
 
-  def test_no_host_in_the_config_aborts_naming_it
+  def test_a_config_without_a_host_fails_the_read
     Dir.mktmpdir do |root|
       with_import_tasks(Pathname.new(root)) do
-        _, err = capture_io { assert_raises(SystemExit) { ImportTasks.host } }
+        error = assert_raises(ArgumentError) { ImportTasks.host }
 
-        assert_equal "no host to land on: write host: under data/import/config.yml\n", err
+        assert_equal "data/import/config.yml: does not exist", error.message
       end
     end
   end