diff --git a/lib/files/src/file_list.cpp b/lib/files/src/file_list.cpp new file mode 100644 index 0000000..c8fdb42 --- /dev/null +++ b/lib/files/src/file_list.cpp @@ -0,0 +1,131 @@ +#include "file_list.h" + +#include +#include +#include +#include +#include + +namespace roro::files { + +namespace { + +// Names compare letters only, whatever the case; ties by the bytes, so the order is total. +int compareNames(const char* a, const char* b) { + for (const char *x = a, *y = b;; ++x, ++y) { + int cx = std::tolower(static_cast(*x)), cy = std::tolower(static_cast(*y)); + if (cx != cy) return cx < cy ? -1 : 1; + if (!cx) break; + } + return std::strcmp(a, b); +} + +constexpr uint32_t kYear2020 = 1577836800; + +std::string sizeText(uint32_t bytes) { + char s[16]; + if (bytes < 1024) std::snprintf(s, sizeof s, "%u B", static_cast(bytes)); + else if (bytes < 10 * 1024) std::snprintf(s, sizeof s, "%.1f KB", bytes / 1024.0); + else if (bytes < 1024 * 1024) std::snprintf(s, sizeof s, "%u KB", static_cast(bytes / 1024)); + else if (bytes < 10u * 1024 * 1024) std::snprintf(s, sizeof s, "%.1f MB", bytes / 1048576.0); + else if (bytes < 1024u * 1024 * 1024) std::snprintf(s, sizeof s, "%u MB", static_cast(bytes / 1048576)); + else std::snprintf(s, sizeof s, "%.1f GB", bytes / 1073741824.0); + return s; +} + +} // namespace + +void FileList::clear() { + std::vector().swap(entries_); + std::vector().swap(order_); + std::vector().swap(names_); + more_ = wasted_ = 0; +} + +bool FileList::before(const Entry& a, const Entry& b, FileSort by) const { + if (a.folder != b.folder) return a.folder; + if (!a.folder) { + if (by == FileSort::Date && a.modified != b.modified) return a.modified > b.modified; + if (by == FileSort::Size && a.size != b.size) return a.size > b.size; + } + return compareNames(names_.data() + a.name, names_.data() + b.name) < 0; +} + +void FileList::add(const char* name, uint32_t size, uint32_t modified, bool folder) { + size_t len = std::strlen(name) + 1; + if (entries_.size() >= kMax) { + // Full: the new entry takes the place of the one that sorts last by name, if it sorts + // before it. The old name's bytes stay in the buffer until there's enough waste to pack. + more_++; + size_t last = 0; + for (size_t i = 1; i < entries_.size(); i++) + if (before(entries_[last], entries_[i], FileSort::Name)) last = i; + Entry candidate{static_cast(names_.size()), size, modified, folder}; + names_.insert(names_.end(), name, name + len); + if (!before(candidate, entries_[last], FileSort::Name)) { + names_.resize(names_.size() - len); + return; + } + wasted_ += std::strlen(names_.data() + entries_[last].name) + 1; + entries_[last] = candidate; + if (wasted_ > 2048) compact(); + return; + } + if (entries_.empty()) { + entries_.reserve(32); + names_.reserve(512); + } + entries_.push_back({static_cast(names_.size()), size, modified, folder}); + names_.insert(names_.end(), name, name + len); + order_.push_back(static_cast(order_.size())); +} + +void FileList::compact() { + std::vector packed; + packed.reserve(names_.size() - wasted_); + for (Entry& e : entries_) { + const char* n = names_.data() + e.name; + e.name = static_cast(packed.size()); + packed.insert(packed.end(), n, n + std::strlen(n) + 1); + } + names_.swap(packed); + wasted_ = 0; +} + +void FileList::sort(FileSort by) { + if (wasted_) compact(); + names_.shrink_to_fit(); + entries_.shrink_to_fit(); + order_.resize(entries_.size()); + for (size_t i = 0; i < order_.size(); i++) order_[i] = static_cast(i); + std::sort(order_.begin(), order_.end(), [&](uint16_t a, uint16_t b) { return before(entries_[a], entries_[b], by); }); +} + +int FileList::find(const std::string& name) const { + for (size_t i = 0; i < order_.size(); i++) + if (name == this->name(i)) return static_cast(i); + return -1; +} + +size_t FileList::bytes() const { + return entries_.capacity() * sizeof(Entry) + order_.capacity() * sizeof(uint16_t) + names_.capacity(); +} + +std::string formatStamp(uint32_t modified) { + if (modified < kYear2020) return "-"; + time_t t = static_cast(modified); + struct tm local; + localtime_r(&t, &local); + char s[20]; + std::snprintf(s, sizeof s, "%04d-%02d-%02d %02d:%02d", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, local.tm_hour, + local.tm_min); + return s; +} + +std::string rowDetail(bool folder, uint32_t size, uint32_t modified) { + if (folder) return "folder"; + std::string stamp = formatStamp(modified); + return sizeText(size) + " " + (stamp == "-" ? stamp : stamp.substr(0, 10)); +} + +} // namespace roro::files diff --git a/lib/files/src/file_list.h b/lib/files/src/file_list.h new file mode 100644 index 0000000..cf9f0a7 --- /dev/null +++ b/lib/files/src/file_list.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +namespace roro::files { + +enum class FileSort : uint8_t { Name, Date, Size }; + +// A folder's entries for the Storage App (F1, Q129, Q136): 256 at most, the names packed into +// one buffer, about 10 KB when full. A bigger folder keeps the first 256 by name, whatever order +// the card lists them in, and counts the rest. +class FileList { + public: + static constexpr size_t kMax = 256; + + void clear(); + void add(const char* name, uint32_t size, uint32_t modified, bool folder); + void sort(FileSort by); // folders first, by name; then files by name, newest or biggest first + + size_t count() const { return entries_.size(); } + size_t more() const { return more_; } // entries that didn't fit + // By position after sort(). + const char* name(size_t i) const { return names_.data() + entries_[order_[i]].name; } + uint32_t size(size_t i) const { return entries_[order_[i]].size; } + uint32_t modified(size_t i) const { return entries_[order_[i]].modified; } // Unix time, 0 if unknown + bool folder(size_t i) const { return entries_[order_[i]].folder; } + int find(const std::string& name) const; // position, or -1 + size_t bytes() const; // memory held + + private: + struct Entry { + uint32_t name; // offset into names_ + uint32_t size, modified; + bool folder; + }; + bool before(const Entry& a, const Entry& b, FileSort by) const; + void compact(); + + std::vector entries_; + std::vector order_; + std::vector names_; + size_t more_ = 0, wasted_ = 0; +}; + +// What a row shows on the right (Q129): "folder", or "1.2 KB 2026-10-05". A file dated before +// 2020 was written before the clock was set: "-" (Q137). Local time. +std::string rowDetail(bool folder, uint32_t size, uint32_t modified); +std::string formatStamp(uint32_t modified); // "2026-10-05 20:00", or "-" + +} // namespace roro::files diff --git a/lib/files/src/file_names.cpp b/lib/files/src/file_names.cpp new file mode 100644 index 0000000..2a1ad4a --- /dev/null +++ b/lib/files/src/file_names.cpp @@ -0,0 +1,99 @@ +#include "file_names.h" + +#include + +namespace roro::files { + +const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes"}; +const size_t kFirmwareFolderCount = sizeof kFirmwareFolders / sizeof kFirmwareFolders[0]; + +std::string parentOf(const std::string& path) { + size_t slash = path.rfind('/'); + return slash == std::string::npos || slash == 0 ? "/" : path.substr(0, slash); +} + +std::string baseName(const std::string& path) { + size_t slash = path.rfind('/'); + return slash == std::string::npos ? path : path.substr(slash + 1); +} + +std::string joinPath(const std::string& folder, const std::string& name) { + return folder == "/" ? "/" + name : folder + "/" + name; +} + +std::string extensionOf(const std::string& name) { + size_t dot = name.rfind('.'); + if (dot == std::string::npos || dot == 0) return ""; + std::string ext = name.substr(dot + 1); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); + return ext; +} + +bool isInside(const std::string& path, const std::string& folder) { + if (folder == "/") return true; + if (path.compare(0, folder.size(), folder) != 0) return false; + return path.size() == folder.size() || path[folder.size()] == '/'; +} + +std::string checkName(const std::string& name) { + if (name.empty()) return "A name can't be empty"; + if (name == "." || name == "..") return "\".\" and \"..\" aren't names"; + if (name.size() > 64) return "A name can be 64 characters at most"; + for (char c : name) { + if (static_cast(c) < 0x20) return "A name can't contain control characters"; + for (char bad : std::string("/\\:*?\"<>|")) + if (c == bad) return std::string("A name can't contain ") + c; + } + if (name.back() == '.' || name.back() == ' ') return "A name can't end with a dot or a space"; + return ""; +} + +std::string whyReadOnly(const std::string& path, const std::vector& inUse) { + if (path == "/" || path.empty()) return "That's the card itself"; + if (isInside(path, kGeminiCache)) return std::string(kGeminiCache) + " is the Gemini App's working space"; + for (const std::string& open : inUse) { + if (open == path) return "It's being written right now"; + if (isInside(open, path)) return "It holds a file that's being written right now"; + } + for (size_t i = 0; i < kFirmwareFolderCount; i++) + if (path == kFirmwareFolders[i]) return std::string("The firmware keeps its files in ") + path; + return ""; +} + +std::string whyNotInto(const std::string& source, const std::string& into) { + if (isInside(into, kGeminiCache)) return std::string(kGeminiCache) + " is the Gemini App's working space"; + if (isInside(into, source)) return "A folder can't go inside itself"; + if (parentOf(source) == into) return "It's already there"; + return ""; +} + +std::string copyName(const std::string& name, int n) { + size_t dot = name.rfind('.'); + std::string suffix = " (" + std::to_string(n) + ")"; + if (dot == std::string::npos || dot == 0) return name + suffix; + return name.substr(0, dot) + suffix + name.substr(dot); +} + +FileKind kindOf(const std::string& name) { + std::string ext = extensionOf(name); + if (ext == "gpx") return FileKind::Gpx; + if (ext == "pcap") return FileKind::Pcap; + if (ext == "ota") return FileKind::Ota; + for (const char* text : {"txt", "log", "gmi", "csv", "md", "json", "ini", "conf", "ir"}) + if (ext == text) return FileKind::Text; + return FileKind::Unknown; +} + +bool opensAtEnd(const std::string& name) { return extensionOf(name) == "log"; } + +bool looksLikeText(const uint8_t* data, size_t len) { + size_t odd = 0; + for (size_t i = 0; i < len; i++) { + uint8_t b = data[i]; + if (b == 0) return false; + if (b < 0x20 && b != '\n' && b != '\r' && b != '\t') odd++; + } + return odd * 20 <= len; // a stray control character or two is still text +} + +} // namespace roro::files diff --git a/lib/files/src/file_names.h b/lib/files/src/file_names.h new file mode 100644 index 0000000..2181163 --- /dev/null +++ b/lib/files/src/file_names.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +namespace roro::files { + +// Paths on the SD card are absolute and use '/': "/gemini/saved/index.gmi". +std::string parentOf(const std::string& path); // "/" for a top-level entry and for "/" +std::string baseName(const std::string& path); // "" for "/" +std::string joinPath(const std::string& folder, const std::string& name); +std::string extensionOf(const std::string& name); // lower case, without the dot; "" if none +bool isInside(const std::string& path, const std::string& folder); // a folder is inside itself + +// A name typed for rename or a new folder (F1, Q131): "" if FAT and this App can take it, or why not. +std::string checkName(const std::string& name); + +// The top-level folders the firmware keeps its files in. They can't be renamed or deleted; +// what's in them can (Q130). +extern const char* const kFirmwareFolders[]; +extern const size_t kFirmwareFolderCount; +constexpr const char* kGeminiCache = "/gemini/cache"; + +// Why `path` can't be renamed, moved or deleted, or "" if it can (Q130). `inUse`: the files the +// firmware has open right now. +std::string whyReadOnly(const std::string& path, const std::vector& inUse); + +// Why `source` can't be copied or moved into the folder `into`, or "" if it can. +std::string whyNotInto(const std::string& source, const std::string& into); + +// "a (2).gmi": the name of a copy made next to its original. +std::string copyName(const std::string& name, int n); + +// Which viewer opens a file (Q134), from its name. Unknown: look at the first bytes. +enum class FileKind : uint8_t { Text, Gpx, Pcap, Ota, Unknown }; +FileKind kindOf(const std::string& name); +bool opensAtEnd(const std::string& name); // logs +bool looksLikeText(const uint8_t* data, size_t len); + +} // namespace roro::files diff --git a/test/test_file_list/test_file_list.cpp b/test/test_file_list/test_file_list.cpp new file mode 100644 index 0000000..cb840ba --- /dev/null +++ b/test/test_file_list/test_file_list.cpp @@ -0,0 +1,114 @@ +#include + +#include +#include +#include + +#include "file_list.h" + +using namespace roro::files; + +void setUp() {} +void tearDown() {} + +void test_entries() { + FileList list; + TEST_ASSERT_EQUAL_size_t(0, list.count()); + list.add("b.txt", 1200, 1791230400, false); + list.add("saved", 0, 1791230000, true); + TEST_ASSERT_EQUAL_size_t(2, list.count()); + list.sort(FileSort::Name); + TEST_ASSERT_EQUAL_STRING("saved", list.name(0)); // folders first + TEST_ASSERT_TRUE(list.folder(0)); + TEST_ASSERT_EQUAL_STRING("b.txt", list.name(1)); + TEST_ASSERT_EQUAL_UINT32(1200, list.size(1)); + TEST_ASSERT_EQUAL_UINT32(1791230400u, list.modified(1)); +} + +// Q129: folders first, then by name whatever the case; `s` cycles name, date, size. +void test_sorting() { + FileList list; + list.add("zeta.log", 10, 300, false); + list.add("Alpha.log", 3000, 100, false); + list.add("beta.log", 200, 200, false); + list.add("tracks", 0, 50, true); + list.add("Captures", 0, 60, true); + + list.sort(FileSort::Name); + const char* byName[] = {"Captures", "tracks", "Alpha.log", "beta.log", "zeta.log"}; + for (int i = 0; i < 5; i++) TEST_ASSERT_EQUAL_STRING(byName[i], list.name(i)); + + list.sort(FileSort::Date); // newest first, folders still first + const char* byDate[] = {"Captures", "tracks", "zeta.log", "beta.log", "Alpha.log"}; + for (int i = 0; i < 5; i++) TEST_ASSERT_EQUAL_STRING(byDate[i], list.name(i)); + + list.sort(FileSort::Size); // biggest first + const char* bySize[] = {"Captures", "tracks", "Alpha.log", "beta.log", "zeta.log"}; + for (int i = 0; i < 5; i++) TEST_ASSERT_EQUAL_STRING(bySize[i], list.name(i)); +} + +// Q136: 256 entries at most. A bigger folder keeps the first 256 by name, whatever order the +// card lists them in, and says how many more there are. +void test_more_than_fits() { + FileList list; + char name[24]; + for (int i = 399; i >= 0; i--) { // the card's order: backwards + std::snprintf(name, sizeof name, "2026-%03d.log", i); + list.add(name, static_cast(i), 1000 + i, false); + } + TEST_ASSERT_EQUAL_size_t(256, list.count()); + TEST_ASSERT_EQUAL_size_t(144, list.more()); + list.sort(FileSort::Name); + TEST_ASSERT_EQUAL_STRING("2026-000.log", list.name(0)); + TEST_ASSERT_EQUAL_STRING("2026-255.log", list.name(255)); + TEST_ASSERT_EQUAL_UINT32(255, list.size(255)); + // And it stays small: 400 names went through, the memory is for 256. + TEST_ASSERT_TRUE(list.bytes() < 12 * 1024); +} + +void test_folders_are_kept_before_files_when_full() { + FileList list; + char name[24]; + for (int i = 0; i < 300; i++) { + std::snprintf(name, sizeof name, "a%03d.log", i); + list.add(name, 1, 1, false); + } + list.add("zz-folder", 0, 1, true); // sorts first, so it's among the 256 + list.sort(FileSort::Name); + TEST_ASSERT_EQUAL_STRING("zz-folder", list.name(0)); + TEST_ASSERT_EQUAL_size_t(45, list.more()); +} + +void test_find_and_clear() { + FileList list; + list.add("a.txt", 1, 1, false); + list.add("b.txt", 2, 2, false); + list.sort(FileSort::Name); + TEST_ASSERT_EQUAL_INT(1, list.find("b.txt")); + TEST_ASSERT_EQUAL_INT(-1, list.find("c.txt")); + list.clear(); + TEST_ASSERT_EQUAL_size_t(0, list.count()); + TEST_ASSERT_EQUAL_size_t(0, list.more()); +} + +// Q129, Q137: what a row shows on the right. A date before 2020 was never set. +void test_display() { + setenv("TZ", "UTC", 1); + tzset(); + TEST_ASSERT_EQUAL_STRING("folder", rowDetail(true, 0, 0).c_str()); + TEST_ASSERT_EQUAL_STRING("1.2 KB 2026-10-05", rowDetail(false, 1200, 1791230400).c_str()); + TEST_ASSERT_EQUAL_STRING("0 B -", rowDetail(false, 0, 315532800).c_str()); // 1980-01-01, FAT's zero + TEST_ASSERT_EQUAL_STRING("2026-10-05 20:00", formatStamp(1791230400).c_str()); + TEST_ASSERT_EQUAL_STRING("-", formatStamp(0).c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_entries); + RUN_TEST(test_sorting); + RUN_TEST(test_more_than_fits); + RUN_TEST(test_folders_are_kept_before_files_when_full); + RUN_TEST(test_find_and_clear); + RUN_TEST(test_display); + return UNITY_END(); +} diff --git a/test/test_file_names/test_file_names.cpp b/test/test_file_names/test_file_names.cpp new file mode 100644 index 0000000..40730e8 --- /dev/null +++ b/test/test_file_names/test_file_names.cpp @@ -0,0 +1,118 @@ +#include + +#include +#include + +#include "file_names.h" + +using namespace roro::files; + +void setUp() {} +void tearDown() {} + +void test_path_parts() { + TEST_ASSERT_EQUAL_STRING("/gemini/saved", parentOf("/gemini/saved/index.gmi").c_str()); + TEST_ASSERT_EQUAL_STRING("/", parentOf("/gemini").c_str()); + TEST_ASSERT_EQUAL_STRING("/", parentOf("/").c_str()); + TEST_ASSERT_EQUAL_STRING("index.gmi", baseName("/gemini/saved/index.gmi").c_str()); + TEST_ASSERT_EQUAL_STRING("", baseName("/").c_str()); + TEST_ASSERT_EQUAL_STRING("/gemini/a.gmi", joinPath("/gemini", "a.gmi").c_str()); + TEST_ASSERT_EQUAL_STRING("/a.gmi", joinPath("/", "a.gmi").c_str()); + TEST_ASSERT_EQUAL_STRING("gmi", extensionOf("Index.GMI").c_str()); + TEST_ASSERT_EQUAL_STRING("pcap", extensionOf("20261005-183340.pcap").c_str()); + TEST_ASSERT_EQUAL_STRING("", extensionOf("README").c_str()); + TEST_ASSERT_EQUAL_STRING("", extensionOf(".hidden").c_str()); +} + +void test_inside() { + TEST_ASSERT_TRUE(isInside("/gemini/cache/page0.gmi", "/gemini/cache")); + TEST_ASSERT_TRUE(isInside("/gemini/cache", "/gemini/cache")); + TEST_ASSERT_FALSE(isInside("/gemini/cached", "/gemini/cache")); // a longer name isn't inside + TEST_ASSERT_TRUE(isInside("/irc/x.log", "/")); +} + +// Q131: names typed for rename and new folder. +void test_names_checked() { + TEST_ASSERT_EQUAL_STRING("", checkName("notes 2026.txt").c_str()); + TEST_ASSERT_EQUAL_STRING("", checkName("#roro").c_str()); + TEST_ASSERT_EQUAL_STRING("A name can't be empty", checkName("").c_str()); + TEST_ASSERT_EQUAL_STRING("A name can't contain /", checkName("a/b").c_str()); + TEST_ASSERT_EQUAL_STRING("A name can't contain :", checkName("12:30.txt").c_str()); + TEST_ASSERT_EQUAL_STRING("A name can't contain ?", checkName("what?").c_str()); + TEST_ASSERT_EQUAL_STRING("\".\" and \"..\" aren't names", checkName("..").c_str()); + TEST_ASSERT_EQUAL_STRING("A name can't end with a dot or a space", checkName("notes.").c_str()); + TEST_ASSERT_EQUAL_STRING("A name can't end with a dot or a space", checkName("notes ").c_str()); + TEST_ASSERT_EQUAL_STRING("A name can be 64 characters at most", checkName(std::string(65, 'a')).c_str()); + TEST_ASSERT_EQUAL_STRING("", checkName(std::string(64, 'a')).c_str()); +} + +// Q130: what can't be renamed, moved or deleted, and why. +void test_read_only() { + std::vector inUse = {"/irc/irc.libera.chat/#roro/2026-10-06.log", "/gnss/tracks/20261006-071500.gpx"}; + TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/gemini/saved/a.gmi", inUse).c_str()); + TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/irc/irc.libera.chat/#roro/2026-10-05.log", inUse).c_str()); // yesterday's + TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/updates/roro9stack-v0.8.1.ota", inUse).c_str()); + TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/gemini/saved", inUse).c_str()); // a folder below the top level + + TEST_ASSERT_EQUAL_STRING("It's being written right now", whyReadOnly("/gnss/tracks/20261006-071500.gpx", inUse).c_str()); + TEST_ASSERT_EQUAL_STRING("It holds a file that's being written right now", whyReadOnly("/gnss/tracks", inUse).c_str()); + TEST_ASSERT_EQUAL_STRING("It holds a file that's being written right now", whyReadOnly("/irc/irc.libera.chat", inUse).c_str()); + TEST_ASSERT_EQUAL_STRING("/gemini/cache is the Gemini App's working space", whyReadOnly("/gemini/cache/page0.gmi", inUse).c_str()); + TEST_ASSERT_EQUAL_STRING("/gemini/cache is the Gemini App's working space", whyReadOnly("/gemini/cache", inUse).c_str()); + TEST_ASSERT_EQUAL_STRING("The firmware keeps its files in /irc", whyReadOnly("/irc", {}).c_str()); + TEST_ASSERT_EQUAL_STRING("The firmware keeps its files in /captures", whyReadOnly("/captures", {}).c_str()); + TEST_ASSERT_EQUAL_STRING("That's the card itself", whyReadOnly("/", {}).c_str()); + TEST_ASSERT_EQUAL_STRING("", whyReadOnly("/my stuff", {}).c_str()); // a top-level folder of the user's own +} + +// Where something may be put (paste, new folder): not into the cache, not into itself. +void test_destinations() { + TEST_ASSERT_EQUAL_STRING("", whyNotInto("/gemini/saved/a.gmi", "/notes").c_str()); + TEST_ASSERT_EQUAL_STRING("", whyNotInto("/gemini/saved/a.gmi", "/").c_str()); + TEST_ASSERT_EQUAL_STRING("/gemini/cache is the Gemini App's working space", whyNotInto("/notes/a.txt", "/gemini/cache").c_str()); + TEST_ASSERT_EQUAL_STRING("A folder can't go inside itself", whyNotInto("/gemini/saved", "/gemini/saved/sub").c_str()); + TEST_ASSERT_EQUAL_STRING("A folder can't go inside itself", whyNotInto("/gemini/saved", "/gemini/saved").c_str()); + TEST_ASSERT_EQUAL_STRING("It's already there", whyNotInto("/gemini/saved/a.gmi", "/gemini/saved").c_str()); +} + +// A copy next to the original needs another name: "a (2).gmi". +void test_copy_names() { + TEST_ASSERT_EQUAL_STRING("a (2).gmi", copyName("a.gmi", 2).c_str()); + TEST_ASSERT_EQUAL_STRING("README (3)", copyName("README", 3).c_str()); + TEST_ASSERT_EQUAL_STRING("archive.tar (2).gz", copyName("archive.tar.gz", 2).c_str()); +} + +// Q134: which viewer opens a file. +void test_kinds() { + TEST_ASSERT_TRUE(kindOf("2026-10-06.log") == FileKind::Text); + TEST_ASSERT_TRUE(kindOf("index.gmi") == FileKind::Text); + TEST_ASSERT_TRUE(kindOf("2026-10-05.CSV") == FileKind::Text); + TEST_ASSERT_TRUE(kindOf("note.txt") == FileKind::Text); + TEST_ASSERT_TRUE(kindOf("20261006-071500.gpx") == FileKind::Gpx); + TEST_ASSERT_TRUE(kindOf("20261005-183340.pcap") == FileKind::Pcap); + TEST_ASSERT_TRUE(kindOf("roro9stack-v0.8.1.ota") == FileKind::Ota); + TEST_ASSERT_TRUE(kindOf("firmware.bin") == FileKind::Unknown); // sniffed: text or a hex dump + TEST_ASSERT_TRUE(opensAtEnd("2026-10-06.log")); + TEST_ASSERT_FALSE(opensAtEnd("index.gmi")); +} + +void test_looks_like_text() { + const char text[] = "line one\nline two with an \xC3\xA9\r\n\ttabbed"; + TEST_ASSERT_TRUE(looksLikeText(reinterpret_cast(text), sizeof text - 1)); + const uint8_t binary[] = {0xE9, 0x05, 0x02, 0x00, 0x10, 0x00, 0x00, 0x40}; + TEST_ASSERT_FALSE(looksLikeText(binary, sizeof binary)); + TEST_ASSERT_TRUE(looksLikeText(nullptr, 0)); // an empty file reads as text +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_path_parts); + RUN_TEST(test_inside); + RUN_TEST(test_names_checked); + RUN_TEST(test_read_only); + RUN_TEST(test_destinations); + RUN_TEST(test_copy_names); + RUN_TEST(test_kinds); + RUN_TEST(test_looks_like_text); + return UNITY_END(); +}