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