vcard: make name components and a display string mutually exclusive
Two spellings of the same truth could silently disagree, so
name_fields pattern-matches the four shapes and raises on both
or neither. A components-only name now also derives FN (prefix,
given, additional, family, suffix) instead of requiring a display
string.
Assisted-by: GLM-5.3 via pi
diff --git a/docs/plans/2026-01-12-carddav-reference.md b/docs/plans/2026-01-12-carddav-reference.md
index 2a37b06..a58f7b1 100644
--- a/docs/plans/2026-01-12-carddav-reference.md
+++ b/docs/plans/2026-01-12-carddav-reference.md
@@ -252,13 +252,15 @@ contact {
}
```
-`name` derives `N` from the display string (last token family, the rest
-given). Component children override that heuristic entirely — when any
-is present, `N` is built from exactly those, with missing components
-empty:
+`name` is either a display string or exact components — never both.
+A display string derives `N` from its tokens (last token family, the
+rest given) and becomes `FN` verbatim; components build `N` exactly
+(missing parts empty) and derive `FN` from prefix, given, additional,
+family, and suffix, joined in that order with empty parts skipped.
+Providing both raises, since the two spellings could disagree:
```kdl
-name "Ludwig van Beethoven" {
+name {
family "van Beethoven"
given "Ludwig"
}
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 365f2ec..a6d0a17 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -30,11 +30,13 @@ module ProTacts
name = contact.children.find { it.name == "name" }
raise ArgumentError, "contact requires a name" unless name
+ n, fn = name_fields(name)
+
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
- "N:#{structured_name(name)}",
- "FN:#{escape(display_name(name))}",
+ "N:#{components(n)}",
+ "FN:#{escape(fn)}",
*typed_property_lines(contact, "phone", "TEL"),
*typed_property_lines(contact, "email", "EMAIL"),
*address_lines(contact),
@@ -46,22 +48,38 @@ module ProTacts
lines.map { fold(it) }.join("\r\n")
end
- # `name "John Smith"` derives N:Smith;John;;; (last token family, the
- # rest given). Component children override the heuristic entirely:
- # when any of them is present, N is built from exactly those, and
- # every missing component renders empty.
- def structured_name(name)
- overrides = name.children
- .select { NAME_COMPONENTS.include?(it.name) }
- .to_h { [it.name, string_argument(it)] }
-
- return components(NAME_COMPONENTS.map { overrides.fetch(it, "") }) unless overrides.empty?
-
- display = display_name(name)
- tokens = display.split
- family = tokens.last || ""
- given = tokens.length > 1 ? tokens.first(tokens.length - 1).join(" ") : ""
- components([family, given, "", "", ""])
+ # A name is either a display string — `name "John Smith"`, where N
+ # is derived (last token family, the rest given) and FN is the string
+ # itself — or component children, where N is exactly those components
+ # and FN is derived from them (prefix, given, additional, family,
+ # suffix; empty parts skipped). Providing both is an error: they are
+ # two spellings of the same truth, and silently preferring one would
+ # hide the disagreement. Returns [N components, FN].
+ def name_fields(name)
+ overrides = name.children.select { NAME_COMPONENTS.include?(it.name) }
+
+ case [name.arguments, overrides]
+ in [Array[_, *], Array[_, *]]
+ raise ArgumentError, "name takes a display string or component children, not both"
+ in [Array[_, *], []]
+ display = display_name(name)
+ tokens = display.split
+ family = tokens.last || ""
+ given = tokens.length > 1 ? tokens.first(tokens.length - 1).join(" ") : ""
+ [[family, given, "", "", ""], display]
+ in [[], []]
+ raise ArgumentError, "name requires a display string or component children"
+ in [[], _]
+ values = overrides.to_h { [it.name, string_argument(it)] }
+ n = NAME_COMPONENTS.map { values.fetch(it, "") }
+ fn = %w[prefix given additional family suffix]
+ .map { values.fetch(it, "") }.reject(&:empty?).join(" ")
+ # FN is required (RFC 2426 section 4.1.1), so components that are
+ # all empty have nothing to render it from.
+ raise ArgumentError, "name components cannot all be empty" if fn.empty?
+
+ [n, fn]
+ end
end
def typed_property_lines(contact, kdl_name, vcard_name)
@@ -122,10 +140,7 @@ module ProTacts
end
def display_name(name)
- argument = name.arguments.first
- raise ArgumentError, "name requires a display string" unless argument
-
- argument.value.to_s
+ name.arguments.first.value.to_s
end
def string_argument(node)
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 55a8d1e..44353c3 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -55,10 +55,10 @@ class VCardTest < Minitest::Test
assert_includes vcard, "N:Cher;;;;"
end
- def test_name_components_override_the_heuristic
+ def test_components_only_name
vcard = render(<<~KDL)
contact {
- name "Ludwig van Beethoven" {
+ name {
family "van Beethoven"
given "Ludwig"
}
@@ -66,18 +66,36 @@ class VCardTest < Minitest::Test
KDL
assert_includes vcard, "N:van Beethoven;Ludwig;;;"
+ assert_includes vcard, "FN:Ludwig van Beethoven"
end
- def test_one_component_override_leaves_the_rest_empty
+ def test_one_component_leaves_the_rest_empty
vcard = render(<<~KDL)
contact {
- name "Bach" {
+ name {
family "Bach"
}
}
KDL
assert_includes vcard, "N:Bach;;;;"
+ assert_includes vcard, "FN:Bach"
+ end
+
+ def test_fn_joins_components_in_display_order
+ vcard = render(<<~KDL)
+ contact {
+ name {
+ prefix "Dr."
+ given "John"
+ additional "Jacob"
+ family "Smith"
+ suffix "Jr."
+ }
+ }
+ KDL
+
+ assert_includes vcard, "FN:Dr. John Jacob Smith Jr."
end
def test_values_are_escaped
@@ -158,18 +176,44 @@ class VCardTest < Minitest::Test
assert_equal "contact requires a name", error.message
end
- def test_name_without_display_string_raises
+ def test_name_with_both_display_and_components_raises
+ error = assert_raises(ArgumentError) do
+ render(<<~KDL)
+ contact {
+ name "Ludwig van Beethoven" {
+ family "van Beethoven"
+ }
+ }
+ KDL
+ end
+
+ assert_equal "name takes a display string or component children, not both", error.message
+ end
+
+ def test_name_with_neither_display_nor_components_raises
+ error = assert_raises(ArgumentError) do
+ render(<<~KDL)
+ contact {
+ name
+ }
+ KDL
+ end
+
+ assert_equal "name requires a display string or component children", error.message
+ end
+
+ def test_all_empty_components_raise
error = assert_raises(ArgumentError) do
render(<<~KDL)
contact {
name {
- family "Bach"
+ family ""
}
}
KDL
end
- assert_equal "name requires a display string", error.message
+ assert_equal "name components cannot all be empty", error.message
end
def test_property_without_value_raises
@@ -279,6 +323,17 @@ class VCardTest < Minitest::Test
fields
end
+ # The expected FN: the display string for a display-only name, or the
+ # components joined in display order for a components-only name.
+ # Values are normalized (CR/CRLF → LF) the way the renderer's escape
+ # step normalizes them before writing.
+ def expected_fn(display, components)
+ return normalize(display) if components.values.none?
+
+ %w[prefix given additional family suffix]
+ .map { normalize(components.fetch(it.to_sym) || "") }.reject(&:empty?).join(" ")
+ end
+
# The display-name heuristic, reimplemented: last token family, the
# rest given.
def derived_n(display)
@@ -298,7 +353,7 @@ class VCardTest < Minitest::Test
if name_children.empty?
contact << " name #{kdl_string(display)}\n"
else
- contact << " name #{kdl_string(display)} {\n#{name_children.map { " #{it}\n" }.join} }\n"
+ contact << " name {\n#{name_children.map { " #{it}\n" }.join} }\n"
end
phones.each do |value, type|
suffix = type ? " type=\"#{type}\"" : ""
@@ -367,6 +422,10 @@ class VCardTest < Minitest::Test
max_size: 3
))
uid = tc.draw(uuids)
+ # The renderer rejects a components-only name whose parts are all
+ # empty (FN would have nothing to draw from), so the generator
+ # honors that contract.
+ tc.assume(components.values.any? { !it.nil? && !it.empty? })
kdl = kdl_contact(
display:,
@@ -380,7 +439,7 @@ class VCardTest < Minitest::Test
vcard = ProTacts::VCard.render(KDL.parse(kdl).nodes.first, uid:)
parsed = parse_vcard(vcard)
- raise "FN mismatch" unless parsed.fetch(:fn) == normalize(display)
+ raise "FN mismatch" unless parsed.fetch(:fn) == expected_fn(display, components)
raise "UID mismatch" unless parsed.fetch(:uid) == uid
expected_n = if components.values.none?