F1 step 1: names, read-only rules and the packed listing (host-tested)

lib/files: path parts; names checked for rename and new folder; why
something can't be renamed, moved or deleted (the Gemini cache, a file
being written or a folder holding one, the firmware's top-level
folders), and why it can't go into a folder; the viewer for a file by
its name, with a sniff for text. FileList keeps a folder's entries
packed, 256 at most, the first 256 by name whatever order the card lists
them in, and sorts by name, date or size with folders first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 07:37:36 +02:00
co-authored by Claude Opus 5.5
parent 63bae576ef
commit 7ab8f043d0
6 changed files with 557 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
#include "file_list.h"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <ctime>
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<unsigned char>(*x)), cy = std::tolower(static_cast<unsigned char>(*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<unsigned>(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<unsigned>(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<unsigned>(bytes / 1048576));
else std::snprintf(s, sizeof s, "%.1f GB", bytes / 1073741824.0);
return s;
}
} // namespace
void FileList::clear() {
std::vector<Entry>().swap(entries_);
std::vector<uint16_t>().swap(order_);
std::vector<char>().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<uint32_t>(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<uint32_t>(names_.size()), size, modified, folder});
names_.insert(names_.end(), name, name + len);
order_.push_back(static_cast<uint16_t>(order_.size()));
}
void FileList::compact() {
std::vector<char> packed;
packed.reserve(names_.size() - wasted_);
for (Entry& e : entries_) {
const char* n = names_.data() + e.name;
e.name = static_cast<uint32_t>(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<uint16_t>(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<int>(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<time_t>(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
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
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<Entry> entries_;
std::vector<uint16_t> order_;
std::vector<char> 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
+99
View File
@@ -0,0 +1,99 @@
#include "file_names.h"
#include <cctype>
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<char>(std::tolower(static_cast<unsigned char>(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<unsigned char>(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<std::string>& 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
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
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<std::string>& 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
+114
View File
@@ -0,0 +1,114 @@
#include <unity.h>
#include <cstdlib>
#include <ctime>
#include <string>
#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<uint32_t>(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();
}
+118
View File
@@ -0,0 +1,118 @@
#include <unity.h>
#include <string>
#include <vector>
#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<std::string> 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<const uint8_t*>(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();
}