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