vcard: parse KDL contacts into vCard 3.0
REV and PRODID stay out for now; they derive from file state that
lands with the etag work.

Assisted-by: GLM-5.3 via pi
change uumsnmuvvmvxrzuyozlovqvqmvsunxky
commit 89649d7ad2708830b1bd1ac7d22761a54bbee2c8
author Alpha Chen <alpha@kejadlen.dev>
date
parent kvkpsllo
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
new file mode 100644
index 0000000..f903679
--- /dev/null
+++ b/lib/pro_tacts/vcard.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require "kdl"
+
+module ProTacts
+  # Translates a parsed `contact` KDL node 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).
+  module VCard
+    # Folded lines must not exceed 75 octets, excluding the line break
+    # (RFC 2426 section 2.6). The octet count, not character count, is
+    # what matters: a continuation must never split a multibyte
+    # character.
+    LINE_LIMIT = 75
+
+    NAME_COMPONENTS = %w[family given additional prefix suffix].freeze
+    ADDRESS_PARTS = %w[street city state zip country].freeze
+
+    TEXT_ESCAPES = {
+      "\\" => "\\\\",
+      ";" => "\\;",
+      "," => "\\,",
+      "\n" => "\\n"
+    }.freeze
+
+    module_function
+
+    def render(contact, uid:)
+      name = contact.children.find { |node| node.name == "name" }
+      raise ArgumentError, "contact requires a name" unless name
+
+      lines = [
+        "BEGIN:VCARD",
+        "VERSION:3.0",
+        "N:#{structured_name(name)}",
+        "FN:#{escape(display_name(name))}",
+        *typed_property_lines(contact, "phone", "TEL"),
+        *typed_property_lines(contact, "email", "EMAIL"),
+        *address_lines(contact),
+        "UID:#{escape(uid)}",
+        "END:VCARD"
+      ]
+
+      "#{lines.map { |line| fold(line) }.join("\r\n")}\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.each.with_object({}) do |child, acc|
+        next unless NAME_COMPONENTS.include?(child.name)
+
+        acc[child.name] = string_argument(child)
+      end
+
+      return components(NAME_COMPONENTS.map { |component| overrides.fetch(component, "") }) 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, "", "", ""])
+    end
+
+    def typed_property_lines(contact, kdl_name, vcard_name)
+      contact.children.select { |node| node.name == kdl_name }.map do |node|
+        type = node.properties["type"]&.value
+        prefix = type ? "#{vcard_name};TYPE=#{type}" : vcard_name
+        "#{prefix}:#{escape(string_argument(node))}"
+      end
+    end
+
+    # 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 { |node| node.name == "address" }.map do |node|
+        parts = node.children.each.with_object({}) do |child, acc|
+          next unless ADDRESS_PARTS.include?(child.name)
+
+          acc[child.name] = string_argument(child)
+        end
+
+        components = ["", "", *ADDRESS_PARTS.map { |part| parts.fetch(part, "") }]
+        type = node.properties["type"]&.value
+        prefix = type ? "ADR;TYPE=#{type}" : "ADR"
+        "#{prefix}:#{components(components)}"
+      end
+    end
+
+    # Text values escape backslash, the component separator, and the
+    # sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
+    # normalized to the `\n` escape because a raw line break would end
+    # the property line.
+    def escape(text)
+      text.gsub(/\r\n|\r/, "\n").gsub(/[\\;,\n]/) { |char| TEXT_ESCAPES.fetch(char) }
+    end
+
+    # Escapes each component, then joins with the component separator.
+    def components(values)
+      values.map { |value| escape(value) }.join(";")
+    end
+
+    # Folds a logical line into physical lines of at most LINE_LIMIT
+    # octets, each continuation starting with a single space (RFC 2426
+    # section 2.6). The walk is character-wise so a multibyte character
+    # is never split mid-sequence.
+    def fold(line)
+      return line if line.bytesize <= LINE_LIMIT
+
+      folded = +""
+      width = 0
+      line.each_char do |char|
+        if width + char.bytesize > LINE_LIMIT
+          folded << "\r\n "
+          width = 1
+        end
+        folded << char
+        width += char.bytesize
+      end
+      folded
+    end
+
+    def display_name(name)
+      argument = name.arguments.first
+      raise ArgumentError, "name requires a display string" unless argument
+
+      argument.value.to_s
+    end
+
+    def string_argument(node)
+      argument = node.arguments.first
+      raise ArgumentError, "#{node.name} requires a string argument" if argument.nil?
+
+      argument.value.to_s
+    end
+  end
+end
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
new file mode 100644
index 0000000..dab986b
--- /dev/null
+++ b/test/pro_tacts/test_vcard.rb
@@ -0,0 +1,404 @@
+# frozen_string_literal: true
+
+require_relative "../test_helper"
+
+require "kdl"
+require "hegel"
+
+require "pro_tacts/vcard"
+
+class VCardTest < Minitest::Test
+  include Hegel::Syntax::Methods
+
+  def render(kdl, uid: "test-uid")
+    ProTacts::VCard.render(KDL.parse(kdl).nodes.first, 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"
+        }
+      }
+    KDL
+
+    assert_equal <<~VCARD.gsub("\n", "\r\n"), vcard
+      BEGIN:VCARD
+      VERSION:3.0
+      N:Smith;John;;;
+      FN:John Smith
+      TEL;TYPE=mobile:+1-555-1234
+      EMAIL;TYPE=home:john@example.com
+      ADR;TYPE=home:;;123 Main St;Springfield;IL;62701;USA
+      UID:test-uid
+      END:VCARD
+    VCARD
+  end
+
+  def test_single_token_name_gets_empty_given
+    vcard = render(<<~KDL)
+      contact {
+        name "Cher"
+      }
+    KDL
+
+    assert_includes vcard, "N:Cher;;;;"
+  end
+
+  def test_name_components_override_the_heuristic
+    vcard = render(<<~KDL)
+      contact {
+        name "Ludwig van Beethoven" {
+          family "van Beethoven"
+          given "Ludwig"
+        }
+      }
+    KDL
+
+    assert_includes vcard, "N:van Beethoven;Ludwig;;;"
+  end
+
+  def test_one_component_override_leaves_the_rest_empty
+    vcard = render(<<~KDL)
+      contact {
+        name "Bach" {
+          family "Bach"
+        }
+      }
+    KDL
+
+    assert_includes vcard, "N:Bach;;;;"
+  end
+
+  def test_values_are_escaped
+    vcard = render(<<~KDL)
+      contact {
+        name "semi;colon, comma back\\\\slash"
+      }
+    KDL
+
+    assert_includes vcard, "FN:semi\\;colon\\, comma back\\\\slash"
+  end
+
+  def test_newlines_escape_as_literal_n
+    vcard = render(<<~KDL)
+      contact {
+        name "two\\nlines"
+      }
+    KDL
+
+    assert_includes vcard, "FN:two\\nlines"
+  end
+
+  def test_long_lines_fold_and_unfold_intact
+    vcard = render(<<~KDL)
+      contact {
+        name "#{"x" * 30}#{("é" * 60)}"
+      }
+    KDL
+
+    physical = vcard.split("\r\n")
+    assert_operator physical.length, :>, 1, "expected folding"
+    physical.each { |line| assert_operator line.bytesize, :<=, 75 }
+
+    logical = physical.each.with_object([]) do |line, acc|
+      line.start_with?(" ") ? acc.last << line[1..] : acc << line.dup
+    end
+    assert_equal "FN:#{"x" * 30}#{("é" * 60)}", logical.find { |l| l.start_with?("FN:") }
+  end
+
+  def test_properties_without_type
+    vcard = render(<<~KDL)
+      contact {
+        name "John"
+        phone "+1-555-1234"
+        address {
+          street "123 Main St"
+        }
+      }
+    KDL
+
+    assert_includes vcard, "TEL:+1-555-1234"
+    assert_includes vcard, "ADR:;;123 Main St;;;;"
+  end
+
+  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"
+      }
+    KDL
+
+    lines = vcard.split("\r\n")
+    assert_equal %w[+1-555-1 +1-555-2 +1-555-3], lines.grep(/\ATEL/).map { it.split(":", 2).last }
+    assert_equal 1, lines.grep(/\AEMAIL/).length
+  end
+
+  def test_missing_name_raises
+    error = assert_raises(ArgumentError) do
+      render(<<~KDL)
+        contact {
+          phone "+1-555-1234"
+        }
+      KDL
+    end
+
+    assert_equal "contact requires a name", error.message
+  end
+
+  def test_name_without_display_string_raises
+    error = assert_raises(ArgumentError) do
+      render(<<~KDL)
+        contact {
+          name {
+            family "Bach"
+          }
+        }
+      KDL
+    end
+
+    assert_equal "name requires a display string", error.message
+  end
+
+  def test_property_without_value_raises
+    error = assert_raises(ArgumentError) do
+      render(<<~KDL)
+        contact {
+          name "John"
+          phone type="mobile"
+        }
+      KDL
+    end
+
+    assert_equal "phone requires a string argument", error.message
+  end
+
+  ## Property tests
+  #
+  # The oracles are deliberately independent of the renderer: an
+  # unfold/unescape/parse implementation written here in the test, so a
+  # renderer bug cannot hide behind shared code.
+
+  # The renderer normalizes CRLF and CR to \n before escaping, so the
+  # oracle applies the same normalization before comparing.
+  def normalize(text)
+    text.gsub(/\r\n|\r/, "\n")
+  end
+
+  # Serializes a string as a KDL quoted string: backslash, quote, and
+  # the line breaks are escaped (CRLF and CR normalize to a single \n,
+  # matching the renderer's documented normalization), and the C0/DEL
+  # control characters that KDL forbids raw inside a string become \u
+  # escapes.
+  def kdl_string(text)
+    escaped = normalize(text).chars.map do |char|
+      case char
+      when "\\" then "\\\\"
+      when '"' then '\\"'
+      when "\n", "\r" then "\\n"
+      when /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/ then format('\u{%02x}', char.ord)
+      else char
+      end
+    end.join
+    "\"#{escaped}\""
+  end
+
+  # Joins physical lines back into logical ones by removing the folding
+  # break: CRLF followed by a single space.
+  def logical_lines(vcard)
+    vcard.split("\r\n").each.with_object([]) do |line, acc|
+      line.start_with?(" ") ? acc.last << line[1..] : acc << line.dup
+    end
+  end
+
+  # Reverses RFC 2426 section 2.4.2 escaping.
+  def unescape(text)
+    text.gsub(/\\(.)/) do
+      case Regexp.last_match(1)
+      when "n" then "\n"
+      when "\\", ";", "," then Regexp.last_match(1)
+      else raise "unknown vCard escape: \\#{Regexp.last_match(1)}"
+      end
+    end
+  end
+
+  # Splits on a separator that is not itself escaped.
+  def split_unescaped(text, separator)
+    parts = [+""]
+    index = 0
+    while index < text.length
+      if text[index] == "\\" && index + 1 < text.length
+        parts.last << text[index, 2]
+        index += 2
+      elsif text[index] == separator
+        parts << +""
+        index += 1
+      else
+        parts.last << text[index]
+        index += 1
+      end
+    end
+    parts
+  end
+
+  # Minimal independent vCard parser: returns the fields the renderer
+  # produces, with values unescaped and components split.
+  def parse_vcard(vcard)
+    fields = {n: [], tel: [], email: [], adr: []}
+    logical_lines(vcard).each do |line|
+      head, value = line.split(":", 2)
+      name, raw_params = head.split(";", 2)
+      type = raw_params&.then { |params| params[/\ATYPE=(.*)\z/, 1] }
+      case name
+      when "FN" then fields[:fn] = unescape(value)
+      when "UID" then fields[:uid] = unescape(value)
+      when "N", "ADR"
+        components = split_unescaped(value, ";").map { |part| unescape(part) }
+        fields[:n] = components if name == "N"
+        fields[:adr] << [components, type] if name == "ADR"
+      when "TEL" then fields[:tel] << [unescape(value), type]
+      when "EMAIL" then fields[:email] << [unescape(value), type]
+      end
+    end
+    fields
+  end
+
+  # The display-name heuristic, reimplemented: last token family, the
+  # rest given.
+  def derived_n(display)
+    tokens = normalize(display).split
+    family = tokens.last || ""
+    given = tokens.length > 1 ? tokens.first(tokens.length - 1).join(" ") : ""
+    [family, given, "", "", ""]
+  end
+
+  # Builds the KDL source for a contact from generated field data.
+  def kdl_contact(display:, family: nil, given: nil, additional: nil, prefix: nil, suffix: nil,
+    phones: [], emails: [], addresses: [])
+    name_children = {family:, given:, additional:, prefix:, suffix:}
+      .filter_map { |part, value| "#{part} #{kdl_string(value)}" unless value.nil? }
+
+    contact = +"contact {\n"
+    if name_children.empty?
+      contact << "  name #{kdl_string(display)}\n"
+    else
+      contact << "  name #{kdl_string(display)} {\n#{name_children.map { |c| "    #{c}\n" }.join}  }\n"
+    end
+    phones.each do |value, type|
+      suffix = type ? " type=\"#{type}\"" : ""
+      contact << "  phone #{kdl_string(value)}#{suffix}\n"
+    end
+    emails.each do |value, type|
+      suffix = type ? " type=\"#{type}\"" : ""
+      contact << "  email #{kdl_string(value)}#{suffix}\n"
+    end
+    addresses.each do |parts, type|
+      suffix = type ? " type=\"#{type}\"" : ""
+      inner = parts.map { |part, value| "    #{part} #{kdl_string(value)}\n" }.join
+      contact << "  address#{suffix} {\n#{inner}  }\n"
+    end
+    contact << "}\n"
+  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,
+        uid: "uid"
+      )
+
+      fn = parse_vcard(vcard).fetch(:fn)
+      raise "FN did not survive escaping" unless fn == normalize(display)
+    end
+  end
+
+  def test_physical_lines_fit_in_75_octets
+    Hegel.test do |tc|
+      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,
+        uid:
+      )
+
+      physical = vcard.split("\r\n")
+      too_long = physical.find { |line| line.bytesize > 75 }
+      raise "line exceeds 75 octets: #{too_long&.bytesize}" if too_long
+      raise "folded away the terminators" unless physical.first == "BEGIN:VCARD" && physical.last == "END:VCARD"
+      raise "UID did not survive folding" unless parse_vcard(vcard).fetch(:uid) == normalize(uid)
+    end
+  end
+
+  def test_contact_fields_survive_round_trip
+    type = from_regex("[a-zA-Z0-9]{1,10}", fullmatch: true)
+    Hegel.test do |tc|
+      display = tc.draw(text(max_size: 30))
+      components = {
+        family: tc.draw(optional(text(max_size: 20))),
+        given: tc.draw(optional(text(max_size: 20))),
+        additional: tc.draw(optional(text(max_size: 20))),
+        prefix: tc.draw(optional(text(max_size: 20))),
+        suffix: tc.draw(optional(text(max_size: 20)))
+      }
+      phones = tc.draw(arrays(tuples(text(max_size: 30), optional(type)), max_size: 5))
+      emails = tc.draw(arrays(tuples(text(max_size: 30), optional(type)), max_size: 5))
+      addresses = tc.draw(arrays(
+        tuples(
+          text(max_size: 20), text(max_size: 20), text(max_size: 20),
+          text(max_size: 20), text(max_size: 20), optional(type)
+        ),
+        max_size: 3
+      ))
+      uid = tc.draw(uuids)
+
+      kdl = kdl_contact(
+        display:,
+        **components,
+        phones:,
+        emails:,
+        addresses: addresses.map { |street, city, state, zip, country, addr_type|
+          [%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:)
+      parsed = parse_vcard(vcard)
+
+      raise "FN mismatch" unless parsed.fetch(:fn) == normalize(display)
+      raise "UID mismatch" unless parsed.fetch(:uid) == uid
+
+      expected_n = if components.values.none?
+        derived_n(display)
+      else
+        %i[family given additional prefix suffix].map { |part| normalize(components.fetch(part) || "") }
+      end
+      raise "N mismatch" unless parsed.fetch(:n) == expected_n
+
+      expected_phones = phones.map { |value, phone_type| [normalize(value), phone_type] }
+      raise "TEL mismatch" unless parsed.fetch(:tel) == expected_phones
+
+      expected_emails = emails.map { |value, email_type| [normalize(value), email_type] }
+      raise "EMAIL mismatch" unless parsed.fetch(:email) == expected_emails
+
+      raise "ADR count mismatch" unless parsed.fetch(:adr).length == addresses.length
+      addresses.zip(parsed.fetch(:adr)).each do |(street, city, state, zip, country, addr_type), (got, got_type)|
+        raise "ADR components mismatch" unless got == ["", "", normalize(street), normalize(city), normalize(state), normalize(zip), normalize(country)]
+        raise "ADR type mismatch" unless got_type == addr_type
+      end
+    end
+  end
+end