Drop the contact {} wrapper from contact files
One file is one contact, so the wrapper was ceremony in a format whose
point is hand-editable plaintext. Duplicate name nodes now raise instead
of silently preferring the first, taking over the structural check the
wrapper used to provide.
Assisted-by: GLM-5.3 via pi
diff --git a/README.md b/README.md
index fed7ef5..262b469 100644
--- a/README.md
+++ b/README.md
@@ -22,11 +22,9 @@ Read-only and serving real data. Contacts live as KDL files under
file, the filename doubling as the contact ID and the vCard UID:
```kdl
-contact {
- name "John Smith"
- phone "+1-555-1234" type="mobile"
- email "john@example.com"
-}
+name "John Smith"
+phone "+1-555-1234" type="mobile"
+email "john@example.com"
```
macOS Contacts displays them over Tailscale serve as of 2026-08-14, so
diff --git a/docs/plans/2026-01-12-carddav-architecture.md b/docs/plans/2026-01-12-carddav-architecture.md
index 569b775..ee42ae0 100644
--- a/docs/plans/2026-01-12-carddav-architecture.md
+++ b/docs/plans/2026-01-12-carddav-architecture.md
@@ -89,12 +89,13 @@ data/
### Contact File Format (KDL)
+One bare KDL document per file — no `contact {}` wrapper, since the
+file itself is the contact:
+
```kdl
-contact {
- name "John Smith"
- phone "+1-555-1234" type="mobile"
- email "john@example.com"
-}
+name "John Smith"
+phone "+1-555-1234" type="mobile"
+email "john@example.com"
```
### Contact IDs
diff --git a/docs/plans/2026-01-12-carddav-reference.md b/docs/plans/2026-01-12-carddav-reference.md
index a58f7b1..0c62918 100644
--- a/docs/plans/2026-01-12-carddav-reference.md
+++ b/docs/plans/2026-01-12-carddav-reference.md
@@ -236,19 +236,19 @@ END:VCARD
### KDL Input
+One bare KDL document per file — no `contact {}` wrapper:
+
```kdl
-contact {
- name "John Smith"
- phone "+1-555-1234" type="mobile"
- phone "+1-555-5678" type="work"
- email "john@example.com" type="home"
- address type="home" {
- street "123 Main St"
- city "Springfield"
- state "IL"
- zip "62701"
- country "USA"
- }
+name "John Smith"
+phone "+1-555-1234" type="mobile"
+phone "+1-555-5678" type="work"
+email "john@example.com" type="home"
+address type="home" {
+ street "123 Main St"
+ city "Springfield"
+ state "IL"
+ zip "62701"
+ country "USA"
}
```
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index c8882e3..1b6cfe7 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -7,8 +7,8 @@ require "pro_tacts/vcard"
module ProTacts
# A contact parsed from one KDL file under the contacts directory; the
- # filename is the id, which maps to the vCard UID (see
- # docs/plans/2026-01-12-carddav-architecture.md).
+ # file is a bare KDL document, and the filename is the id, which maps
+ # to the vCard UID (see docs/plans/2026-01-12-carddav-architecture.md).
class Contact < Data.define(:id, :vcard)
# Ids end up in paths and arrive from client-supplied hrefs, so a
# filename outside this charset is skipped at load; everything
@@ -48,12 +48,7 @@ module ProTacts
raise ArgumentError, "invalid contact id: #{id}"
end
- nodes = KDL.parse(path.read).nodes
- unless nodes.length == 1 && nodes.first.name == "contact"
- raise ArgumentError, "expected exactly one contact node"
- end
-
- new(id:, vcard: VCard.render(nodes.first, uid: id))
+ new(id:, vcard: VCard.render(KDL.parse(path.read), uid: id))
end
end
end
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 6636f71..4b6ce0f 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -2,7 +2,8 @@
require "kdl"
module ProTacts
- # Translates a parsed `contact` KDL node into a vCard 3.0 (RFC 2426).
+ # Translates a parsed contact file — a bare KDL document — into a
+ # vCard 3.0 (RFC 2426).
#
# The UID is passed separately because it lives in the filename rather
# than the file (see docs/plans/2026-01-12-carddav-architecture.md).
@@ -34,23 +35,24 @@ module ProTacts
module_function
- def render(contact, uid:)
- validate_children(contact, CONTACT_FIELDS, "contact")
- validate_properties(contact)
+ def render(document, uid:)
+ nodes = document.nodes
+ validate_children(nodes, CONTACT_FIELDS, "contact")
- name = contact.children.find { it.name == "name" }
- raise ArgumentError, "contact requires a name" unless name
+ names = nodes.select { it.name == "name" }
+ raise ArgumentError, "contact requires a name" if names.empty?
+ raise ArgumentError, "contact takes a single name" if names.length > 1
- n, fn = name_fields(name)
+ n, fn = name_fields(names.first)
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
"N:#{components(n)}",
"FN:#{escape(fn)}",
- *typed_property_lines(contact, "phone", "TEL"),
- *typed_property_lines(contact, "email", "EMAIL"),
- *address_lines(contact),
+ *typed_property_lines(nodes, "phone", "TEL"),
+ *typed_property_lines(nodes, "email", "EMAIL"),
+ *address_lines(nodes),
"UID:#{escape(uid)}",
"END:VCARD",
"", # trailing newline
@@ -98,8 +100,8 @@ module ProTacts
end
end
- def typed_property_lines(contact, kdl_name, vcard_name)
- contact.children.select { it.name == kdl_name }.map { |node|
+ def typed_property_lines(nodes, kdl_name, vcard_name)
+ nodes.select { it.name == kdl_name }.map { |node|
validate_properties(node)
type = node.properties["type"]&.value
prefix = type ? "#{vcard_name};TYPE=#{type}" : vcard_name
@@ -110,8 +112,8 @@ module ProTacts
# ADR's seven components in order: pobox, extended address, street,
# locality, region, postal code, country. The first two have no KDL
# counterpart and stay empty.
- def address_lines(contact)
- contact.children.select { it.name == "address" }.map { |node|
+ def address_lines(nodes)
+ nodes.select { it.name == "address" }.map { |node|
validate_children(node, ADDRESS_PARTS, "address")
validate_properties(node)
parts = node.children
@@ -165,8 +167,8 @@ module ProTacts
argument.value.to_s
end
- def validate_children(node, known, context)
- unknown = node.children.reject { known.include?(it.name) }
+ def validate_children(nodes, known, context)
+ unknown = nodes.reject { known.include?(it.name) }
return if unknown.empty?
raise ArgumentError, "unknown key in #{context}: #{unknown.first.name}"
diff --git a/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl b/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
index e1a0409..c040be4 100644
--- a/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
+++ b/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
@@ -1,3 +1 @@
-contact {
- name "Test Contact"
-}
+name "Test Contact"
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index c8392fd..b30b72e 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -14,8 +14,8 @@ class ContactTest < Minitest::Test
def test_all_parses_every_contact
with_contacts({
- "znorth.kdl" => "contact { name \"Zed\" }",
- "aiden.kdl" => "contact { name \"Aiden\" }",
+ "znorth.kdl" => "name \"Zed\"",
+ "aiden.kdl" => "name \"Aiden\"",
}) do |contacts|
ids = contacts.map { it.id }
@@ -25,7 +25,7 @@ class ContactTest < Minitest::Test
end
def test_the_uid_comes_from_the_filename
- with_contacts({"kqmtnwpxlrvszoyp.kdl" => "contact { name \"Aiden\" }"}) do |contacts|
+ with_contacts({"kqmtnwpxlrvszoyp.kdl" => "name \"Aiden\""}) do |contacts|
assert_includes contacts.first.vcard, "UID:kqmtnwpxlrvszoyp"
end
end
@@ -58,7 +58,7 @@ class ContactTest < Minitest::Test
def test_dotfiles_are_ignored
with_contacts({
".DS_Store" => "junk",
- "aiden.kdl" => "contact { name \"Aiden\" }",
+ "aiden.kdl" => "name \"Aiden\"",
}) do |contacts|
assert_equal %w[aiden], contacts.map { it.id }
end
@@ -66,8 +66,8 @@ class ContactTest < Minitest::Test
def test_files_whose_id_cannot_be_fetched_are_skipped
with_contacts({
- "John Smith.kdl" => "contact { name \"John\" }",
- "aiden.kdl" => "contact { name \"Aiden\" }",
+ "John Smith.kdl" => "name \"John\"",
+ "aiden.kdl" => "name \"Aiden\"",
}) do |contacts|
assert_equal %w[aiden], contacts.map { it.id }
end
@@ -76,26 +76,26 @@ class ContactTest < Minitest::Test
def test_unparseable_files_are_skipped
with_contacts({
"broken.kdl" => "contact {",
- "aiden.kdl" => "contact { name \"Aiden\" }",
+ "aiden.kdl" => "name \"Aiden\"",
}) do |contacts|
assert_equal %w[aiden], contacts.map { it.id }
end
end
- def test_files_without_exactly_one_contact_node_are_skipped
+ def test_files_whose_keys_are_not_contact_fields_are_skipped
with_contacts({
+ "person.kdl" => "person { name \"A\" }",
"empty.kdl" => "",
- "two.kdl" => "contact { name \"A\" }\ncontact { name \"B\" }",
- "other.kdl" => "person { name \"A\" }",
+ "aiden.kdl" => "name \"Aiden\"",
}) do |contacts|
- assert_empty contacts
+ assert_equal %w[aiden], contacts.map { it.id }
end
end
def test_files_whose_card_cannot_render_are_skipped
with_contacts({
- "nameless.kdl" => "contact { phone \"+1-555-1234\" }",
- "aiden.kdl" => "contact { name \"Aiden\" }",
+ "nameless.kdl" => "phone \"+1-555-1234\"",
+ "aiden.kdl" => "name \"Aiden\"",
}) do |contacts|
assert_equal %w[aiden], contacts.map { it.id }
end
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 756c239..ec90f56 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -10,25 +10,23 @@ class VCardTest < Minitest::Test
include Hegel::Syntax::Methods
def render(kdl, uid: "test-uid")
- ProTacts::VCard.render(KDL.parse(kdl).nodes.first, uid:)
+ ProTacts::VCard.render(KDL.parse(kdl), uid:)
end
## Unit tests
def test_simple_contact
vcard = render(<<~KDL)
- contact {
- name "John Smith"
- phone "+1-555-1234" type="mobile"
- email "john@example.com" type="home"
- address type="home" {
- street "123 Main St"
- city "Springfield"
- state "IL"
- zip "62701"
- country "USA"
- }
- }
+ name "John Smith"
+ phone "+1-555-1234" type="mobile"
+ email "john@example.com" type="home"
+ address type="home" {
+ street "123 Main St"
+ city "Springfield"
+ state "IL"
+ zip "62701"
+ country "USA"
+ }
KDL
assert_equal <<~VCARD.gsub("\n", "\r\n"), vcard
@@ -46,9 +44,7 @@ class VCardTest < Minitest::Test
def test_single_token_name_gets_empty_given
vcard = render(<<~KDL)
- contact {
- name "Cher"
- }
+ name "Cher"
KDL
assert_includes vcard, "N:Cher;;;;"
@@ -56,12 +52,10 @@ class VCardTest < Minitest::Test
def test_components_only_name
vcard = render(<<~KDL)
- contact {
- name {
- family "van Beethoven"
- given "Ludwig"
- }
- }
+ name {
+ family "van Beethoven"
+ given "Ludwig"
+ }
KDL
assert_includes vcard, "N:van Beethoven;Ludwig;;;"
@@ -70,11 +64,9 @@ class VCardTest < Minitest::Test
def test_one_component_leaves_the_rest_empty
vcard = render(<<~KDL)
- contact {
- name {
- family "Bach"
- }
- }
+ name {
+ family "Bach"
+ }
KDL
assert_includes vcard, "N:Bach;;;;"
@@ -83,15 +75,13 @@ class VCardTest < Minitest::Test
def test_fn_joins_components_in_display_order
vcard = render(<<~KDL)
- contact {
- name {
- prefix "Dr."
- given "John"
- additional "Jacob"
- family "Smith"
- suffix "Jr."
- }
- }
+ name {
+ prefix "Dr."
+ given "John"
+ additional "Jacob"
+ family "Smith"
+ suffix "Jr."
+ }
KDL
assert_includes vcard, "FN:Dr. John Jacob Smith Jr."
@@ -99,9 +89,7 @@ class VCardTest < Minitest::Test
def test_values_are_escaped
vcard = render(<<~KDL)
- contact {
- name "semi;colon, comma back\\\\slash"
- }
+ name "semi;colon, comma back\\\\slash"
KDL
assert_includes vcard, "FN:semi\\;colon\\, comma back\\\\slash"
@@ -109,9 +97,7 @@ class VCardTest < Minitest::Test
def test_newlines_escape_as_literal_n
vcard = render(<<~KDL)
- contact {
- name "two\\nlines"
- }
+ name "two\\nlines"
KDL
assert_includes vcard, "FN:two\\nlines"
@@ -119,9 +105,7 @@ class VCardTest < Minitest::Test
def test_long_lines_fold_and_unfold_intact
vcard = render(<<~KDL)
- contact {
- name "#{"x" * 30}#{("é" * 60)}"
- }
+ name "#{"x" * 30}#{("é" * 60)}"
KDL
physical = vcard.split("\r\n")
@@ -136,13 +120,11 @@ class VCardTest < Minitest::Test
def test_properties_without_type
vcard = render(<<~KDL)
- contact {
- name "John"
- phone "+1-555-1234"
- address {
- street "123 Main St"
- }
- }
+ name "John"
+ phone "+1-555-1234"
+ address {
+ street "123 Main St"
+ }
KDL
assert_includes vcard, "TEL:+1-555-1234"
@@ -151,13 +133,11 @@ class VCardTest < Minitest::Test
def test_order_is_preserved
vcard = render(<<~KDL)
- contact {
- name "John"
- phone "+1-555-1"
- phone "+1-555-2"
- email "a@example.com"
- phone "+1-555-3"
- }
+ name "John"
+ phone "+1-555-1"
+ phone "+1-555-2"
+ email "a@example.com"
+ phone "+1-555-3"
KDL
lines = vcard.split("\r\n")
@@ -168,23 +148,30 @@ class VCardTest < Minitest::Test
def test_missing_name_raises
error = assert_raises(ArgumentError) do
render(<<~KDL)
- contact {
- phone "+1-555-1234"
- }
+ phone "+1-555-1234"
KDL
end
assert_equal "contact requires a name", error.message
end
+ def test_duplicate_names_raise
+ error = assert_raises(ArgumentError) do
+ render(<<~KDL)
+ name "One"
+ name "Two"
+ KDL
+ end
+
+ assert_equal "contact takes a single name", error.message
+ end
+
def test_name_with_both_display_and_components_raises
error = assert_raises(ArgumentError) do
render(<<~KDL)
- contact {
- name "Ludwig van Beethoven" {
- family "van Beethoven"
- }
- }
+ name "Ludwig van Beethoven" {
+ family "van Beethoven"
+ }
KDL
end
@@ -194,9 +181,7 @@ class VCardTest < Minitest::Test
def test_name_with_neither_display_nor_components_raises
error = assert_raises(ArgumentError) do
render(<<~KDL)
- contact {
- name
- }
+ name
KDL
end
@@ -206,11 +191,9 @@ class VCardTest < Minitest::Test
def test_all_empty_components_raise
error = assert_raises(ArgumentError) do
render(<<~KDL)
- contact {
- name {
- family ""
- }
- }
+ name {
+ family ""
+ }
KDL
end
@@ -220,10 +203,8 @@ class VCardTest < Minitest::Test
def test_property_without_value_raises
error = assert_raises(ArgumentError) do
render(<<~KDL)
- contact {
- name "John"
- phone type="mobile"
- }
+ name "John"
+ phone type="mobile"
KDL
end
@@ -233,10 +214,8 @@ class VCardTest < Minitest::Test
def test_unknown_contact_key_raises
error = assert_raises(ArgumentError) do
render(<<~KDL)
- contact {
- name "John"
- emial "john@example.com"
- }
+ name "John"
+ emial "john@example.com"
KDL
end
@@ -246,12 +225,10 @@ class VCardTest < Minitest::Test
def test_unknown_name_component_raises
error = assert_raises(ArgumentError) do
render(<<~KDL)
- contact {
- name {
- family "Smith"
- middle "Q"
- }
- }
+ name {
+ family "Smith"
+ middle "Q"
+ }
KDL
end
@@ -261,13 +238,11 @@ class VCardTest < Minitest::Test
def test_unknown_address_key_raises
error = assert_raises(ArgumentError) do
render(<<~KDL)
- contact {
- name "John"
- address {
- street "123 Main St"
- province "IL"
- }
- }
+ name "John"
+ address {
+ street "123 Main St"
+ province "IL"
+ }
KDL
end
@@ -277,10 +252,8 @@ class VCardTest < Minitest::Test
def test_unknown_property_raises
error = assert_raises(ArgumentError) do
render(<<~KDL)
- contact {
- name "John"
- phone "+1-555-1234" tpye="mobile"
- }
+ name "John"
+ phone "+1-555-1234" tpye="mobile"
KDL
end
@@ -407,7 +380,7 @@ class VCardTest < Minitest::Test
name_children = {family:, given:, additional:, prefix:, suffix:}
.filter_map { |part, value| "#{part} #{kdl_string(value)}" unless value.nil? }
- contact = +"contact {\n"
+ contact = +""
if name_children.empty?
contact << " name #{kdl_string(display)}\n"
else
@@ -426,14 +399,14 @@ class VCardTest < Minitest::Test
inner = parts.map { |part, value| " #{part} #{kdl_string(value)}\n" }.join
contact << " address#{suffix} {\n#{inner} }\n"
end
- contact << "}\n"
+ contact
end
def test_escaped_fn_survives_round_trip
Hegel.test do |tc|
display = tc.draw(text(min_size: 1, max_size: 200))
vcard = ProTacts::VCard.render(
- KDL.parse(kdl_contact(display:)).nodes.first,
+ KDL.parse(kdl_contact(display:)),
uid: "uid"
)
@@ -447,7 +420,7 @@ class VCardTest < Minitest::Test
display = tc.draw(text(min_size: 1, max_size: 300))
uid = tc.draw(text(min_size: 1, max_size: 300))
vcard = ProTacts::VCard.render(
- KDL.parse(kdl_contact(display:)).nodes.first,
+ KDL.parse(kdl_contact(display:)),
uid:
)
@@ -494,7 +467,7 @@ class VCardTest < Minitest::Test
[%w[street city state zip country].zip([street, city, state, zip, country]).to_h, addr_type]
}
)
- vcard = ProTacts::VCard.render(KDL.parse(kdl).nodes.first, uid:)
+ vcard = ProTacts::VCard.render(KDL.parse(kdl), uid:)
parsed = parse_vcard(vcard)
raise "FN mismatch" unless parsed.fetch(:fn) == expected_fn(display, components)
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 70c6c61..ec4126a 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -152,8 +152,8 @@ class WebTest < Minitest::Test
def test_listing_and_multiget_serve_every_contact_on_disk
with_contacts({
- "aiden.kdl" => "contact { name \"Aiden\" }",
- "znorth.kdl" => "contact { name \"Zed\" }",
+ "aiden.kdl" => "name \"Aiden\"",
+ "znorth.kdl" => "name \"Zed\"",
}) do
request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
@@ -172,7 +172,7 @@ class WebTest < Minitest::Test
end
def test_multiget_reports_unknown_hrefs_as_404
- with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do
request "/dav/addressbook/", method: "REPORT", input: multiget("aiden", "nope")
assert_equal 207, last_response.status
@@ -183,7 +183,7 @@ class WebTest < Minitest::Test
end
def test_multiget_escapes_vcard_content_for_xml
- with_contacts({"aiden.kdl" => "contact { name \"A & B <Team>\" }"}) do
+ with_contacts({"aiden.kdl" => "name \"A & B <Team>\""}) do
request "/dav/addressbook/", method: "REPORT", input: multiget("aiden")
assert_equal 207, last_response.status
@@ -192,7 +192,7 @@ class WebTest < Minitest::Test
end
def test_sync_collection_returns_etags_only
- with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do
request "/dav/addressbook/", method: "REPORT", input: <<~XML
<?xml version="1.0" encoding="UTF-8"?>
<A:sync-collection xmlns:A="DAV:">