fd/fmt: Add storage consistent formatted output

This makes it trivial for a caller to write to files piecewise while
still guaranteeing that the output is always consistent. It performs
buffered writes to a tmpfile and only once successful does it swap out
for the resulting file.

Change-Id: I7e733a283ee60a47ddc6923cd9579fa49a7c5434
Signed-off-by: William A. Kennington III <wak@google.com>
diff --git a/include-fd/stdplus/fd/fmt.hpp b/include-fd/stdplus/fd/fmt.hpp
index 9647d0a..0f0537d 100644
--- a/include-fd/stdplus/fd/fmt.hpp
+++ b/include-fd/stdplus/fd/fmt.hpp
@@ -1,7 +1,10 @@
 #pragma once
+#include <filesystem>
 #include <fmt/format.h>
 #include <functional>
 #include <stdplus/fd/intf.hpp>
+#include <stdplus/fd/managed.hpp>
+#include <string_view>
 
 namespace stdplus
 {
@@ -36,5 +39,34 @@
     void writeIfNeeded();
 };
 
+class FormatToFile
+{
+  public:
+    explicit FormatToFile(std::string_view tmpl = "/tmp/stdplus.XXXXXX");
+    ~FormatToFile();
+    FormatToFile(const FormatToFile&) = delete;
+    FormatToFile(FormatToFile&&) = delete;
+    FormatToFile& operator=(const FormatToFile&) = delete;
+    FormatToFile& operator=(FormatToFile&&) = delete;
+
+    template <typename... Args>
+    void append(fmt::format_string<Args...> fmt, Args&&... args)
+    {
+        buf.append(fmt, std::forward<Args>(args)...);
+    }
+
+    void commit(const std::filesystem::path& out, int mode = 0644);
+
+    inline const std::string& getTmpname() const
+    {
+        return tmpname;
+    }
+
+  private:
+    std::string tmpname;
+    stdplus::ManagedFd fd;
+    FormatBuffer buf;
+};
+
 } // namespace fd
 } // namespace stdplus
diff --git a/src/fd/fmt.cpp b/src/fd/fmt.cpp
index fc6392d..217949d 100644
--- a/src/fd/fmt.cpp
+++ b/src/fd/fmt.cpp
@@ -1,5 +1,8 @@
+#include <cstdlib>
 #include <stdplus/fd/fmt.hpp>
 #include <stdplus/fd/ops.hpp>
+#include <stdplus/util/cexec.hpp>
+#include <sys/stat.h>
 
 namespace stdplus
 {
@@ -32,5 +35,41 @@
     }
 }
 
+FormatToFile::FormatToFile(std::string_view tmpl) :
+    tmpname(tmpl),
+    fd(CHECK_ERRNO(mkstemp(tmpname.data()),
+                   [&](int error) {
+                       auto msg = fmt::format("mkstemp({})", tmpname);
+                       tmpname.clear();
+                       throw std::system_error(error, std::generic_category(),
+                                               msg);
+                   })),
+    buf(fd)
+{
+}
+
+FormatToFile::~FormatToFile()
+{
+    if (!tmpname.empty())
+    {
+        std::error_code ec;
+        std::filesystem::remove(tmpname, ec);
+    }
+}
+
+void FormatToFile::commit(const std::filesystem::path& out, int mode)
+{
+    {
+        buf.flush();
+        auto ifd = std::move(fd);
+    }
+    CHECK_ERRNO(chmod(tmpname.c_str(), mode), [&](int error) {
+        throw std::system_error(error, std::generic_category(),
+                                fmt::format("chmod({}, {})", tmpname, mode));
+    });
+    std::filesystem::rename(tmpname, out);
+    tmpname.clear();
+}
+
 } // namespace fd
 } // namespace stdplus
diff --git a/test/fd/fmt.cpp b/test/fd/fmt.cpp
index 726b5e5..fe1fed4 100644
--- a/test/fd/fmt.cpp
+++ b/test/fd/fmt.cpp
@@ -1,7 +1,10 @@
 #include <gtest/gtest.h>
 
+#include <filesystem>
+#include <memory>
 #include <stdplus/fd/fmt.hpp>
 #include <stdplus/fd/managed.hpp>
+#include <stdplus/gtest/tmp.hpp>
 #include <stdplus/util/cexec.hpp>
 #include <sys/mman.h>
 
@@ -31,5 +34,42 @@
     EXPECT_EQ(4106, fd.lseek(0, Whence::Cur));
 }
 
+class FormatToFileTest : public gtest::TestWithTmp
+{
+  protected:
+    std::string tmpname;
+    std::unique_ptr<FormatToFile> file;
+
+    FormatToFileTest() :
+        tmpname(fmt::format("{}/tmp.XXXXXX", CaseTmpDir())),
+        file(std::make_unique<FormatToFile>(tmpname))
+    {
+        tmpname = file->getTmpname();
+        EXPECT_TRUE(std::filesystem::exists(tmpname));
+    }
+
+    ~FormatToFileTest() noexcept(true)
+    {
+        file.reset();
+        EXPECT_FALSE(std::filesystem::exists(tmpname));
+    }
+};
+
+TEST_F(FormatToFileTest, NoCommit)
+{
+    file->append("hi\n");
+    EXPECT_EQ(0, std::filesystem::file_size(tmpname));
+}
+
+TEST_F(FormatToFileTest, Basic)
+{
+    file->append("hi\n");
+    EXPECT_EQ(0, std::filesystem::file_size(tmpname));
+    auto filename = fmt::format("{}/out", CaseTmpDir());
+    file->commit(filename);
+    EXPECT_FALSE(std::filesystem::exists(tmpname));
+    EXPECT_EQ(3, std::filesystem::file_size(filename));
+}
+
 } // namespace fd
 } // namespace stdplus