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
change xywyrwluuzxmznwtmpsmnmomnutlwzvv
commit a1afcf9446ba049e90b9e8d57ab7806a464ef6d9
author Alpha Chen <alpha@kejadlen.dev>
date
parent nyrqxpyy
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?