import: Config owns the layout and parses the host
The paths under data/import and the URI handling move in from
ImportTasks, which keeps only task glue; the host member is a parsed
http(s) URL, a bare hostname read as HTTPS.

Assisted-by: GLM-5.3 via pi
change ynzmspuwkplylyzvomuywpmnpmywzmwo
commit 66d328afc5e8f22d230df33e730fe116c19ef7a8
author Alpha Chen <alpha@kejadlen.dev>
date
parent qvwkspwz
diff --git a/docs/plans/2026-09-19-import-under-data-import.md b/docs/plans/2026-09-19-import-under-data-import.md
index 2e35bde..a0c396f 100644
--- a/docs/plans/2026-09-19-import-under-data-import.md
+++ b/docs/plans/2026-09-19-import-under-data-import.md
@@ -14,8 +14,10 @@ data/import/
 
 The config's first key is `host`: the base URL `import:execute`
 lands on, and the only place a host is named — command line included —
-so a plan cannot be aimed at another server by accident. Finalize
-takes no host at all, reading the one its plan recorded.
+so a plan cannot be aimed at another server by accident. Config
+parses it: a bare hostname is read as HTTPS, and anything but an http
+or https URL is refused at the read. Finalize takes no host at all,
+reading the one its plan recorded.
 
 Before, plans lived in `data/imports` beside nothing. Everything the
 tasks keep now sits under the one directory, plans in flight under
diff --git a/lib/pro_tacts/import/config.rb b/lib/pro_tacts/import/config.rb
index 19c5012..13a9128 100644
--- a/lib/pro_tacts/import/config.rb
+++ b/lib/pro_tacts/import/config.rb
@@ -1,21 +1,35 @@
+require "pathname"
+require "uri"
 require "yaml"
 
 module ProTacts
   module Import
-    # 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.
+    # The import tasks' world under data/import: where everything
+    # lives — the paths below — and the standing data kept between
+    # plans, read from config.yml as the members of the Data. 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)
+      # The root of the import collateral: the standing data in FILE,
+      # the plans in flight under ACTIVE, filed away under DONE once
+      # every contact is off this Mac — the two states a plan's
+      # directory names, beside the one file that outlives them.
+      ROOT = Pathname.new("data/import")
+      FILE = ROOT / "config.yml"
+      ACTIVE = ROOT / "active"
+      DONE = ROOT / "done"
+
       # 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)
+      # rather than nil-able, and parsed: a bare hostname is the base
+      # URL of a server that serves HTTPS, which every deployment
+      # does, so the scheme is spelled out — the host a plan records
+      # is the one a second run is compared against — and anything
+      # that is not an http or https URL is refused.
+      def self.read(path = FILE)
         invalid = ->(why) { raise ArgumentError, "#{path}: #{why}" }
         invalid.("does not exist") unless path.file?
 
@@ -29,7 +43,18 @@ module ProTacts
         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:)
+
+        begin
+          uri = URI.parse(host)
+          uri = URI.parse("https://#{uri}") if uri.scheme.nil?
+        rescue URI::InvalidURIError
+          invalid.("host does not read as a URL")
+        end
+        unless uri.is_a?(URI::HTTP)
+          raise ArgumentError, "#{path}: host must be an http or https URL, such as https://contacts"
+        end
+
+        new(host: uri)
       end
     end
   end
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index b74ff5c..36fe287 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -44,11 +44,16 @@ module ProTacts
     end
 
     class Config
-      attr_reader host: String
+      ROOT: Pathname
+      FILE: Pathname
+      ACTIVE: Pathname
+      DONE: Pathname
+
+      attr_reader host: URI::HTTP
 
       def self.members: () -> Array[Symbol]
-      def self.new: (host: String) -> instance
-      def self.read: (Pathname path) -> instance
+      def self.new: (host: URI::HTTP) -> instance
+      def self.read: (?Pathname path) -> instance
     end
 
     class Finalize
diff --git a/tasks/import.rake b/tasks/import.rake
index 0d56d98..f78c211 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -12,16 +12,7 @@ require "pro_tacts/import/config"
 # The plan a task carries further. A module rather than task-file methods,
 # which rake redefines noisily when a file is loaded twice.
 module ImportTasks
-  # All the import collateral under data/import: the standing
-  # configuration in config.yml beside the plans, in flight under
-  # active/ and filed away under done/ once every contact is off this
-  # Mac — the two states a plan's directory names.
-  ACTIVE = Pathname.new("data/import/active")
-  DONE = Pathname.new("data/import/done")
-
-  # Where the import tasks keep their standing data between plans:
-  # data/import/config.yml (ProTacts::Import::Config reads it).
-  CONFIG = Pathname.new("data/import/config.yml")
+  Config = ProTacts::Import::Config
 
   # What each carrying task waits on — a contact still to carry through
   # that step — named so a status read can say what those tasks would
@@ -32,38 +23,26 @@ module ImportTasks
   # The plan PLAN names, or the oldest one still waiting for this step:
   # plans are carried in the order they were built, so the next one to
   # take further is the oldest that has not been. A PLAN that is not a
-  # directory is read as a plan's name under ACTIVE, the way the
-  # directories there are named.
+  # directory is read as a plan's name under Config::ACTIVE, the way
+  # the directories there are named.
   def self.plan(waiting, step)
     named = ENV.fetch("PLAN", nil)&.then { Pathname.new(it) }
-    named = ACTIVE / named if named && !named.directory?
+    named = Config::ACTIVE / named if named && !named.directory?
     if named
       abort "#{named} holds no plan.yml" unless (named / "plan.yml").file?
       return ProTacts::Import::Plan.read(named)
     end
 
-    ProTacts::Import::Plan.all(ACTIVE).find(&waiting) ||
-      abort("no plan in #{ACTIVE} is waiting #{step}: run rake import:macos:plan, or name one in PLAN")
+    ProTacts::Import::Plan.all(Config::ACTIVE).find(&waiting) ||
+      abort("no plan in #{Config::ACTIVE} is waiting #{step}: run rake import:macos:plan, or name one in PLAN")
   end
 
-  # A finished plan's final state: moved under DONE, nothing left to
-  # carry. The finalize task files one away as its last step; the
-  # status read sweeps any a dead run left behind.
+  # A finished plan's final state: moved under Config::DONE, nothing
+  # left to carry. The finalize task files one away as its last step;
+  # the status read sweeps any a dead run left behind.
   def self.file_away(plan)
-    FileUtils.mkdir_p(DONE)
-    FileUtils.mv(plan.dir, DONE / plan.dir.basename)
-  end
-
-  # The host to land a plan on: the standing `host` under CONFIG, the
-  # 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
-    uri = URI.parse(ProTacts::Import::Config.read(CONFIG).host)
-    uri = URI.parse("https://#{uri}") if uri.scheme.nil?
-    uri.to_s
+    FileUtils.mkdir_p(Config::DONE)
+    FileUtils.mv(plan.dir, Config::DONE / plan.dir.basename)
   end
 end
 
@@ -74,7 +53,7 @@ namespace :import do
       require "pro_tacts/import/macos"
 
       created_at = Time.now.utc
-      dir = ImportTasks::ACTIVE / "macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}"
+      dir = ImportTasks::Config::ACTIVE / "macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}"
       limit = ENV.fetch("LIMIT", nil)&.then { Integer(it) }
 
       records = ProTacts::Import::Macos.read(limit:)
@@ -123,13 +102,13 @@ namespace :import do
     # A finished plan files away; this sweeps any a dead run left in
     # active/, so what is listed is what still has work — which is also
     # why nothing here is ever finished: the sweep took it.
-    ProTacts::Import::Plan.all(ImportTasks::ACTIVE).each do |plan|
+    ProTacts::Import::Plan.all(ImportTasks::Config::ACTIVE).each do |plan|
       ImportTasks.file_away(plan) if plan.done?
     end
 
-    plans = ProTacts::Import::Plan.all(ImportTasks::ACTIVE)
+    plans = ProTacts::Import::Plan.all(ImportTasks::Config::ACTIVE)
     if plans.empty?
-      puts "no plans in #{ImportTasks::ACTIVE}; run rake import:macos:plan"
+      puts "no plans in #{ImportTasks::Config::ACTIVE}; run rake import:macos:plan"
       next
     end
 
@@ -157,14 +136,13 @@ namespace :import do
     require "pro_tacts/import/plan"
 
     plan = ImportTasks.plan(ImportTasks::TO_LAND, "to land")
-    host = ImportTasks.host
-    uri = URI.parse(host)
-    puts "landing #{plan.dir} on #{host}"
+    uri = ImportTasks::Config.read.host
+    puts "landing #{plan.dir} on #{uri}"
 
     Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
-      ProTacts::Import::Execute.call(plan, host:, client: ProTacts::Import::HttpClient.new(http))
+      ProTacts::Import::Execute.call(plan, host: uri.to_s, client: ProTacts::Import::HttpClient.new(http))
     end
     groups = plan.contacts.flat_map { plan.card(it.id).groups }.uniq
-    puts "#{plan.contacts.size} contacts in #{groups.join(", ")} on #{host}"
+    puts "#{plan.contacts.size} contacts in #{groups.join(", ")} on #{uri}"
   end
 end
diff --git a/test/pro_tacts/import/test_config.rb b/test/pro_tacts/import/test_config.rb
index 0f24f7c..60e35ca 100644
--- a/test/pro_tacts/import/test_config.rb
+++ b/test/pro_tacts/import/test_config.rb
@@ -2,6 +2,7 @@ require_relative "../../test_helper"
 
 require "pathname"
 require "tmpdir"
+require "uri"
 
 require "pro_tacts/import/config"
 
@@ -10,15 +11,33 @@ class ImportConfigTest < Minitest::Test
 
   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)
+      assert_equal Config.new(host: URI.parse("https://contacts")), Config.read(path)
+    end
+  end
+
+  def test_a_bare_hostname_is_read_as_https
+    with_config_file("host: contacts\n") do |path|
+      assert_equal URI.parse("https://contacts"), Config.read(path).host
+    end
+  end
+
+  def test_read_names_the_config_under_the_data_directory_by_default
+    Dir.mktmpdir do |dir|
+      Dir.chdir(dir) do
+        error = assert_raises(ArgumentError) { Config.read }
+
+        assert_equal "data/import/config.yml: does not exist", error.message
+      end
     end
   end
 
   def test_a_missing_file_is_refused
     Dir.mktmpdir do |dir|
-      error = assert_raises(ArgumentError) { Config.read(Pathname.new(dir) / "config.yml") }
+      path = Pathname.new(dir) / "config.yml"
+
+      error = assert_raises(ArgumentError) { Config.read(path) }
 
-      assert_equal "#{Pathname.new(dir) / "config.yml"}: does not exist", error.message
+      assert_equal "#{path}: does not exist", error.message
     end
   end
 
@@ -62,6 +81,22 @@ class ImportConfigTest < Minitest::Test
     end
   end
 
+  def test_a_host_that_does_not_read_as_a_url_is_refused
+    with_config_file("host: exa mple\n") do |path|
+      error = assert_raises(ArgumentError) { Config.read(path) }
+
+      assert_equal "#{path}: host does not read as a URL", error.message
+    end
+  end
+
+  def test_a_host_that_is_not_http_or_https_is_refused
+    with_config_file("host: ftp://example.com\n") do |path|
+      error = assert_raises(ArgumentError) { Config.read(path) }
+
+      assert_equal "#{path}: host must be an http or https URL, such as https://contacts", error.message
+    end
+  end
+
   private
 
   #: (String content) { (Pathname) -> void } -> void
diff --git a/test/tasks/test_import.rb b/test/tasks/test_import.rb
index 839ef07..51e426e 100644
--- a/test/tasks/test_import.rb
+++ b/test/tasks/test_import.rb
@@ -49,34 +49,8 @@ class ImportTasksTest < Minitest::Test
     end
   end
 
-  def test_the_host_comes_from_the_config_and_a_bare_hostname_gains_a_scheme
-    Dir.mktmpdir do |root|
-      root = Pathname.new(root)
-      write_config(root, "host: contacts\n")
-
-      with_import_tasks(root) do
-        assert_equal "https://contacts", ImportTasks.host
-      end
-    end
-  end
-
-  def test_a_config_without_a_host_fails_the_read
-    Dir.mktmpdir do |root|
-      with_import_tasks(Pathname.new(root)) do
-        error = assert_raises(ArgumentError) { ImportTasks.host }
-
-        assert_equal "data/import/config.yml: does not exist", error.message
-      end
-    end
-  end
-
   private
 
-  def write_config(root, content)
-    (root / "data" / "import").mkpath
-    (root / "data" / "import" / "config.yml").write(content)
-  end
-
   def run_status(root)
     with_import_tasks(root) { capture_io { Rake.application["import:status"].invoke } }
   end