Start the pw_string module

Introduce functions for outputting numbers as strings. Unlike some
snprintf implementations, these functions support 64-bit integers. Also,
very limited floating point support is provided.

These functions are faster than snprintf and never truncate numbers.

Change-Id: Id5ae06a5d175f15403c34cddb2a077a88be47cf8
diff --git a/BUILD.gn b/BUILD.gn
index 4e6c4eb..ac0e8cb 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -18,6 +18,7 @@
     "$dir_pw_preprocessor",
     "$dir_pw_span",
     "$dir_pw_status",
+    "$dir_pw_string",
     "$dir_pw_unit_test",
   ]
 }
diff --git a/modules.gni b/modules.gni
index 7fe69dc..881c756 100644
--- a/modules.gni
+++ b/modules.gni
@@ -27,5 +27,6 @@
 dir_pw_preprocessor = "$dir_pigweed/pw_preprocessor"
 dir_pw_span = "$dir_pigweed/pw_span"
 dir_pw_status = "$dir_pigweed/pw_status"
+dir_pw_string = "$dir_pigweed/pw_string"
 dir_pw_toolchain = "$dir_pigweed/pw_toolchain"
 dir_pw_unit_test = "$dir_pigweed/pw_unit_test"
diff --git a/pw_string/BUILD.gn b/pw_string/BUILD.gn
new file mode 100644
index 0000000..0963511
--- /dev/null
+++ b/pw_string/BUILD.gn
@@ -0,0 +1,48 @@
+# Copyright 2019 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+import("$dir_pw_unit_test/test.gni")
+
+config("default_config") {
+  include_dirs = [ "public" ]
+}
+
+source_set("pw_string") {
+  public_configs = [
+    "$dir_pw_build:pw_default_cpp",
+    ":default_config",
+  ]
+  public = [
+    "public/pw_string/type_to_string.h",
+  ]
+  sources = [
+    "type_to_string.cc",
+  ]
+  sources += public
+  public_deps = [
+    "$dir_pw_preprocessor",
+    "$dir_pw_span",
+    "$dir_pw_status",
+  ]
+}
+
+pw_test("type_to_string_test") {
+  deps = [
+    ":pw_string",
+    "$dir_pw_unit_test:main",
+  ]
+  sources = [
+    "type_to_string_test.cc",
+  ]
+}
diff --git a/pw_string/public/pw_string/type_to_string.h b/pw_string/public/pw_string/type_to_string.h
new file mode 100644
index 0000000..7a994c0
--- /dev/null
+++ b/pw_string/public/pw_string/type_to_string.h
@@ -0,0 +1,86 @@
+// Copyright 2019 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy
+// of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations
+// under the License.
+#pragma once
+
+// This file provides functions for writing string representations of a few
+// types to character buffers. Generally, the generic ToString function defined
+// in "pw_string/to_string.h" should be used instead of these functions.
+
+#include <cstdint>
+#include <type_traits>
+
+#include "pw_span/span.h"
+#include "pw_status/status_with_size.h"
+
+namespace pw::string {
+
+// Returns the number of digits in the decimal representation of the provided
+// non-negative integer. Returns 1 for 0 or 1 + log base 10 for other numbers.
+uint_fast8_t DecimalDigitCount(uint64_t integer);
+
+// Writes an integer as a null-terminated string in base 10. Returns the number
+// of characters written, excluding the null terminator, and the status.
+//
+// Numbers are never truncated; if the entire number does not fit, only a null
+// terminator is written and the status is RESOURCE_EXHAUSTED.
+//
+// IntToString is templated, but a single 64-bit integer implementation is used
+// for all integer types. The template is used for two reasons:
+//
+//   1. IntToString(int64_t) and IntToString(uint64_t) overloads are ambiguous
+//      when called with types other than int64_t or uint64_t. Using the
+//      template allows IntToString to be called with any integral type.
+//
+//   2. Templating IntToString allows the compiler to emit small functions like
+//      IntToString<int> or IntToString<short> that perform casting / sign
+//      extension on the various integer types. This saves code size, since call
+//      sites pass their arguments directly and casting instructions are shared.
+//
+template <typename T>
+StatusWithSize IntToString(T integer, const span<char>& buffer) {
+  if constexpr (std::is_signed_v<T>) {
+    return IntToString<int64_t>(integer, buffer);
+  } else {
+    return IntToString<uint64_t>(integer, buffer);
+  }
+}
+
+template <>
+StatusWithSize IntToString(uint64_t integer, const span<char>& buffer);
+
+template <>
+StatusWithSize IntToString(int64_t integer, const span<char>& buffer);
+
+// Rounds a floating point number to an integer and writes it as a
+// null-terminated string. Returns the number of characters written, excluding
+// the null terminator, and the status.
+//
+// Numbers are never truncated; if the entire number does not fit, only a null
+// terminator is written and the status is RESOURCE_EXHAUSTED.
+//
+// WARNING: This is NOT a fully-functioning float-printing implementation! It
+// simply outputs the closest integer, "inf", or "NaN". Floating point numbers
+// too large to represent as a 64-bit int are treated as infinite.
+//
+// Examples:
+//
+//   FloatAsIntToString(1.25, buffer)     -> writes "1" to the buffer
+//   FloatAsIntToString(-4.9, buffer)     -> writes "-5" to the buffer
+//   FloatAsIntToString(3.5e20, buffer)   -> writes "inf" to the buffer
+//   FloatAsIntToString(INFINITY, buffer) -> writes "-inf" to the buffer
+//   FloatAsIntToString(-NAN, buffer)     -> writes "-NaN" to the buffer
+//
+StatusWithSize FloatAsIntToString(float value, const span<char>& buffer);
+
+}  // namespace pw::string
diff --git a/pw_string/type_to_string.cc b/pw_string/type_to_string.cc
new file mode 100644
index 0000000..6997e1e
--- /dev/null
+++ b/pw_string/type_to_string.cc
@@ -0,0 +1,155 @@
+// Copyright 2019 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy
+// of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations
+// under the License.
+
+#include "pw_string/type_to_string.h"
+
+#include <cmath>
+#include <cstddef>
+#include <cstring>
+#include <limits>
+
+namespace pw::string {
+namespace {
+
+// Powers of 10 (except 0) as an array. This table is fairly large (160 B), but
+// avoids having to recalculate these values for each DecimalDigitCount call.
+constexpr std::array<uint64_t, 20> kPowersOf10{
+    0ull,
+    10ull,                    // 10^1
+    100ull,                   // 10^2
+    1000ull,                  // 10^3
+    10000ull,                 // 10^4
+    100000ull,                // 10^5
+    1000000ull,               // 10^6
+    10000000ull,              // 10^7
+    100000000ull,             // 10^8
+    1000000000ull,            // 10^9
+    10000000000ull,           // 10^10
+    100000000000ull,          // 10^11
+    1000000000000ull,         // 10^12
+    10000000000000ull,        // 10^13
+    100000000000000ull,       // 10^14
+    1000000000000000ull,      // 10^15
+    10000000000000000ull,     // 10^16
+    100000000000000000ull,    // 10^17
+    1000000000000000000ull,   // 10^18
+    10000000000000000000ull,  // 10^19
+};
+
+}  // namespace
+
+uint_fast8_t DecimalDigitCount(uint64_t integer) {
+  // This fancy piece of code takes the log base 2, then approximates the
+  // change-of-base formula by multiplying by 1233 / 4096.
+  // TODO(hepler): Replace __builtin_clzll with std::countl_zeros in C++20.
+  const uint_fast8_t log_10 = (64 - __builtin_clzll(integer | 1)) * 1233 >> 12;
+
+  // Adjust the estimated log base 10 by comparing against the power of 10.
+  return log_10 + (integer < kPowersOf10[log_10] ? 0u : 1u);
+}
+
+// std::to_chars is available for integers in recent versions of GCC. I looked
+// into switching to std::to_chars instead of this implementation. std::to_chars
+// increased binary size by 160 B on an -Os build (even after removing
+// DecimalDigitCount and its table). I didn't measure performance, but I don't
+// think std::to_chars will be faster, so I kept this implementation for now.
+template <>
+StatusWithSize IntToString(uint64_t value, const span<char>& buffer) {
+  constexpr uint32_t base = 10;
+  constexpr uint32_t max_uint32_base_power = 1'000'000'000;
+  constexpr uint_fast8_t max_uint32_base_power_exponent = 9;
+
+  const uint_fast8_t total_digits = DecimalDigitCount(value);
+
+  if (total_digits >= buffer.size()) {
+    if (!buffer.empty()) {
+      buffer[0] = '\0';
+    }
+    return StatusWithSize(Status::RESOURCE_EXHAUSTED, 0);
+  }
+
+  buffer[total_digits] = '\0';
+
+  uint_fast8_t remaining = total_digits;
+  while (remaining > 0u) {
+    uint32_t lower_digits;     // the value of the lower digits to write
+    uint_fast8_t digit_count;  // the number of lower digits to write
+
+    // 64-bit division is slow on 32-bit platforms, so print large numbers in
+    // 32-bit chunks to minimize the number of 64-bit divisions.
+    if (value <= std::numeric_limits<uint32_t>::max()) {
+      lower_digits = value;
+      digit_count = remaining;
+    } else {
+      lower_digits = value % max_uint32_base_power;
+      digit_count = max_uint32_base_power_exponent;
+      value /= max_uint32_base_power;
+    }
+
+    // Write the specified number of digits, with leading 0s.
+    for (uint_fast8_t i = 0; i < digit_count; ++i) {
+      buffer[--remaining] = lower_digits % base + '0';
+      lower_digits /= base;
+    }
+  }
+  return StatusWithSize(total_digits);
+}
+
+template <>
+StatusWithSize IntToString(int64_t value, const span<char>& buffer) {
+  if (value >= 0) {
+    return IntToString<uint64_t>(value, buffer);
+  }
+
+  // Write as an unsigned number, but leave room for the leading minus sign.
+  auto result = IntToString<uint64_t>(
+      std::abs(value), buffer.empty() ? buffer : buffer.subspan(1));
+
+  if (result.ok()) {
+    buffer[0] = '-';
+    return StatusWithSize(result.size() + 1);
+  } else if (!buffer.empty()) {
+    buffer[0] = '\0';
+  }
+  return StatusWithSize(Status::RESOURCE_EXHAUSTED, 0);
+}
+
+// TODO(hepler): Look into using the float overload of std::to_chars when it is
+//     available.
+StatusWithSize FloatAsIntToString(float value, const span<char>& buffer) {
+  // If it's finite and fits in an int64_t, print it as a rounded integer.
+  if (std::isfinite(value) &&
+      std::abs(value) <
+          static_cast<float>(std::numeric_limits<int64_t>::max())) {
+    return IntToString<int64_t>(std::round(value), buffer);
+  }
+
+  // Otherwise, print inf or NaN, if they fit.
+  if (const size_t written = 3 + std::signbit(value); written < buffer.size()) {
+    char* out = buffer.data();
+    if (std::signbit(value)) {
+      *out++ = '-';
+    }
+    std::memcpy(out, std::isnan(value) ? "NaN" : "inf", sizeof("NaN"));
+    return StatusWithSize(written);
+  }
+
+  if (!buffer.empty()) {
+    buffer[0] = '\0';
+  }
+
+  return StatusWithSize(Status::RESOURCE_EXHAUSTED, 0);
+}
+
+}  // namespace pw::string
diff --git a/pw_string/type_to_string_test.cc b/pw_string/type_to_string_test.cc
new file mode 100644
index 0000000..e7473b9
--- /dev/null
+++ b/pw_string/type_to_string_test.cc
@@ -0,0 +1,320 @@
+// Copyright 2019 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy
+// of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations
+// under the License.
+
+#include "pw_string/type_to_string.h"
+
+#include <cmath>
+#include <cstring>
+#include <limits>
+
+#include "gtest/gtest.h"
+
+namespace pw::string {
+namespace {
+
+TEST(Digits, DecimalDigits_AllOneDigit) {
+  for (uint64_t i = 0; i < 10; ++i) {
+    ASSERT_EQ(1u, DecimalDigitCount(i));
+  }
+}
+
+TEST(Digits, DecimalDigits_AllTwoDigit) {
+  for (uint64_t i = 10; i < 100u; ++i) {
+    ASSERT_EQ(2u, DecimalDigitCount(i));
+  }
+}
+
+TEST(Digits, DecimalDigits_1To19Digits) {
+  uint64_t value = 1;
+  for (unsigned digits = 1; digits <= 19u; ++digits) {
+    ASSERT_EQ(digits, DecimalDigitCount(value));
+    ASSERT_EQ(digits, DecimalDigitCount(value + 1));
+
+    value *= 10;
+    ASSERT_EQ(digits, DecimalDigitCount(value - 1));
+  }
+}
+
+TEST(Digits, DecimalDigits_20) {
+  for (uint64_t i : {
+           10'000'000'000'000'000'000llu,
+           10'000'000'000'000'000'001llu,
+           std::numeric_limits<unsigned long long>::max(),
+       }) {
+    ASSERT_EQ(20u, DecimalDigitCount(i));
+  }
+}
+
+class TestWithBuffer : public ::testing::Test {
+ protected:
+  static constexpr char kStartingString[] = "!@#$%^&*()!@#$%^&*()";
+  static constexpr char kUint64Max[] = "18446744073709551615";
+  static constexpr char kInt64Min[] = "-9223372036854775808";
+  static constexpr char kInt64Max[] = "9223372036854775807";
+
+  static_assert(sizeof(kStartingString) == sizeof(kUint64Max));
+
+  TestWithBuffer() { std::memcpy(buffer_, kStartingString, sizeof(buffer_)); }
+
+  char buffer_[sizeof(kUint64Max)];
+};
+
+class IntToStringTest : public TestWithBuffer {};
+
+TEST_F(IntToStringTest, Unsigned_EmptyBuffer_WritesNothing) {
+  auto result = IntToString(9u, span(buffer_, 0));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ(kStartingString, buffer_);
+}
+
+TEST_F(IntToStringTest, Unsigned_TooSmall_1Char_OnlyNullTerminates) {
+  auto result = IntToString(9u, span(buffer_, 1));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ("", buffer_);
+}
+
+TEST_F(IntToStringTest, Unsigned_TooSmall_2Chars_OnlyNullTerminates) {
+  auto result = IntToString(10u, span(buffer_, 2));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ("", buffer_);
+}
+
+TEST_F(IntToStringTest, Unsigned_TooSmall_3Chars_OnlyNullTerminates) {
+  auto result = IntToString(123u, span(buffer_, 3));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ("", buffer_);
+}
+
+TEST_F(IntToStringTest, Unsigned_1Char_FitsExactly) {
+  auto result = IntToString(0u, span(buffer_, 2));
+  EXPECT_EQ(1u, result.size());
+  EXPECT_TRUE(result.ok());
+  EXPECT_STREQ("0", buffer_);
+
+  result = IntToString(9u, span(buffer_, 2));
+  EXPECT_EQ(1u, result.size());
+  EXPECT_TRUE(result.ok());
+  EXPECT_STREQ("9", buffer_);
+}
+
+TEST_F(IntToStringTest, Unsigned_2Chars_FitsExactly) {
+  auto result = IntToString(10u, span(buffer_, 3));
+  EXPECT_EQ(2u, result.size());
+  EXPECT_STREQ("10", buffer_);
+}
+
+TEST_F(IntToStringTest, Unsigned_MaxFitsExactly) {
+  EXPECT_EQ(20u,
+            IntToString(std::numeric_limits<uint64_t>::max(),
+                        span(buffer_, sizeof(kUint64Max)))
+                .size());
+  EXPECT_STREQ(kUint64Max, buffer_);
+}
+
+TEST_F(IntToStringTest, SignedPositive_EmptyBuffer_WritesNothing) {
+  auto result = IntToString(9, span(buffer_, 0));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ(kStartingString, buffer_);
+}
+
+TEST_F(IntToStringTest, SignedPositive_TooSmall_NullTerminates) {
+  auto result = IntToString(9, span(buffer_, 1));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ("", buffer_);
+}
+
+TEST_F(IntToStringTest, SignedPositive_TooSmall_DoesNotWritePastEnd) {
+  EXPECT_EQ(0u, IntToString(9, span(buffer_, 1)).size());
+  EXPECT_EQ(0, std::memcmp("\0@#$%^&*()!@#$%^&*()", buffer_, sizeof(buffer_)));
+}
+
+TEST_F(IntToStringTest, SignedPositive_1Char_FitsExactly) {
+  auto result = IntToString(0, span(buffer_, 2));
+  EXPECT_EQ(1u, result.size());
+  EXPECT_TRUE(result.ok());
+  EXPECT_STREQ("0", buffer_);
+
+  result = IntToString(9, span(buffer_, 2));
+  EXPECT_EQ(1u, result.size());
+  EXPECT_TRUE(result.ok());
+  EXPECT_STREQ("9", buffer_);
+}
+
+TEST_F(IntToStringTest, SignedPositive_2Chars_FitsExactly) {
+  auto result = IntToString(10, span(buffer_, 4));
+  EXPECT_EQ(2u, result.size());
+  EXPECT_TRUE(result.ok());
+  EXPECT_STREQ("10", buffer_);
+}
+
+TEST_F(IntToStringTest, SignedPositive_MaxFitsExactly) {
+  auto result = IntToString(std::numeric_limits<int64_t>::max(),
+                            span(buffer_, sizeof(kInt64Min)));
+  EXPECT_EQ(19u, result.size());
+  EXPECT_STREQ(kInt64Max, buffer_);
+}
+
+TEST_F(IntToStringTest, SignedNegative_EmptyBuffer_WritesNothing) {
+  auto result = IntToString(-9, span(buffer_, 0));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ(kStartingString, buffer_);
+}
+
+TEST_F(IntToStringTest, SignedNegative_TooSmall_NullTerminates) {
+  auto result = IntToString(-9, span(buffer_, 1));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ("", buffer_);
+}
+
+TEST_F(IntToStringTest, SignedNegative_TooSmall_DoesNotWritePastEnd) {
+  // Note that two \0 are written due to the unsigned IntToString call.
+  EXPECT_EQ(0u, IntToString(-9, span(buffer_, 2)).size());
+  EXPECT_EQ(0, std::memcmp("\0\0#$%^&*()!@#$%^&*()", buffer_, sizeof(buffer_)));
+}
+
+TEST_F(IntToStringTest, SignedNegative_FitsExactly) {
+  auto result = IntToString(-9, span(buffer_, 3));
+  EXPECT_EQ(2u, result.size());
+  EXPECT_STREQ("-9", buffer_);
+  result = IntToString(-99, span(buffer_, 4));
+  EXPECT_EQ(3u, result.size());
+  EXPECT_STREQ("-99", buffer_);
+  result = IntToString(-123, span(buffer_, 5));
+  EXPECT_EQ(4u, result.size());
+  EXPECT_STREQ("-123", buffer_);
+}
+
+TEST_F(IntToStringTest, SignedNegative_MinFitsExactly) {
+  auto result = IntToString(std::numeric_limits<int64_t>::min(),
+                            span(buffer_, sizeof(kInt64Min)));
+  EXPECT_EQ(20u, result.size());
+  EXPECT_STREQ(kInt64Min, buffer_);
+}
+
+TEST(IntToString, SignedSweep) {
+  for (int i = -1002; i <= 1002; ++i) {
+    char buffer[6];
+    char printf_buffer[6];
+    int written = std::snprintf(printf_buffer, sizeof(printf_buffer), "%d", i);
+    auto result = IntToString(i, buffer);
+    ASSERT_EQ(static_cast<size_t>(written), result.size());
+    ASSERT_STREQ(printf_buffer, buffer);
+  }
+}
+
+TEST(IntToString, UnsignedSweep) {
+  for (unsigned i = 0; i <= 1002u; ++i) {
+    char buffer[5];
+    char printf_buffer[5];
+    int written = std::snprintf(printf_buffer, sizeof(printf_buffer), "%u", i);
+    auto result = IntToString(i, buffer);
+    ASSERT_EQ(static_cast<size_t>(written), result.size());
+    ASSERT_STREQ(printf_buffer, buffer);
+  }
+}
+
+class FloatAsIntToStringTest : public TestWithBuffer {};
+
+TEST_F(FloatAsIntToStringTest, PositiveInfinity) {
+  EXPECT_EQ(3u, FloatAsIntToString(INFINITY, buffer_).size());
+  EXPECT_STREQ("inf", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, NegativeInfinity) {
+  EXPECT_EQ(4u, FloatAsIntToString(-INFINITY, buffer_).size());
+  EXPECT_STREQ("-inf", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, PositiveNan) {
+  EXPECT_EQ(3u, FloatAsIntToString(NAN, buffer_).size());
+  EXPECT_STREQ("NaN", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, NegativeNan) {
+  EXPECT_EQ(4u, FloatAsIntToString(-NAN, buffer_).size());
+  EXPECT_STREQ("-NaN", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, RoundDown_PrintsNearestInt) {
+  EXPECT_EQ(1u, FloatAsIntToString(1.23, buffer_).size());
+  EXPECT_STREQ("1", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, RoundUp_PrintsNearestInt) {
+  EXPECT_EQ(4u, FloatAsIntToString(1234.5, buffer_).size());
+  EXPECT_STREQ("1235", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, RoundsToNegativeZero_PrintsZero) {
+  EXPECT_EQ(1u, FloatAsIntToString(-3.14e-20f, buffer_).size());
+  EXPECT_STREQ("0", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, RoundsToPositiveZero_PrintsZero) {
+  EXPECT_EQ(1u, FloatAsIntToString(3.14e-20f, buffer_).size());
+  EXPECT_STREQ("0", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, RoundDownNegative_PrintsNearestInt) {
+  volatile float x = -5.9;
+  EXPECT_EQ(2u, FloatAsIntToString(x, buffer_).size());
+  EXPECT_STREQ("-6", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, RoundUpNegative_PrintsNearestInt) {
+  EXPECT_EQ(9u, FloatAsIntToString(-50000000.1, buffer_).size());
+  EXPECT_STREQ("-50000000", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, LargerThanInteger) {
+  EXPECT_EQ(3u, FloatAsIntToString(3.14e20f, buffer_).size());
+  EXPECT_STREQ("inf", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, SmallerThanInteger) {
+  EXPECT_EQ(4u, FloatAsIntToString(-3.14e20f, buffer_).size());
+  EXPECT_STREQ("-inf", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, TooSmall_Numeric_NullTerminates) {
+  auto result = FloatAsIntToString(-3.14e20f, span(buffer_, 1));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ("", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, TooSmall_Infinity_NullTerminates) {
+  auto result = FloatAsIntToString(-INFINITY, span(buffer_, 3));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ("", buffer_);
+}
+
+TEST_F(FloatAsIntToStringTest, TooSmall_NaN_NullTerminates) {
+  auto result = FloatAsIntToString(NAN, span(buffer_, 2));
+  EXPECT_EQ(0u, result.size());
+  EXPECT_FALSE(result.ok());
+  EXPECT_STREQ("", buffer_);
+}
+
+}  // namespace
+}  // namespace pw::string