Skip to main content

steamlogger::storage

src/storage.rs· 210 lines

Reads and writes the JSON log. Two public functions, one private helper, and one rule: the target file is never partially written.

use steamlogger::storage;

Functions​

read_log​

pub fn read_log(path: &Path) -> anyhow::Result<GameLog>

Load a log file, tolerating two shapes and one absence.

InputResult
path does not existOk(GameLog::default()) — an empty log
Content starts with [ (after leading whitespace)Parsed as Vec<LogEntry> and wrapped into GameLog { games }
Any other contentParsed as GameLog ({"games": [...]})
Unreadable fileErr from std::fs::read_to_string
Invalid JSONErr with context malformed JSON log in <path> (or malformed JSON log array in <path> for the array form)
let log = storage::read_log(Path::new("steamlog.json"))?;
println!("{} sessions", log.games.len());

The bare-array form exists so hand-written or migrated files like

[{"name": "Quake", "start": "2026-08-05T08:00:00-03:00"}]

load without conversion. They are rewritten in the envelope form on the next save.

:::danger main discards this error The binary calls storage::read_log(&config.output_file).unwrap_or_default(), so a corrupt log starts an empty session list and the first write replaces the damaged file. Library users who care about their history should match on the Err instead — see Resilience. :::

write_log​

pub fn write_log(path: &Path, log: &GameLog) -> anyhow::Result<()>

Serialise the whole log and atomically replace path.

Steps

  1. serde_json::to_string_pretty(log) — two-space indentation.
  2. Append a trailing \n (so the file is POSIX-friendly and diff-friendly).
  3. create_dir_all(parent) when path has a non-empty parent — missing directories are created for you.
  4. Write everything to temp_path(path) — <path>.tmp.
  5. std::fs::rename(tmp, path) — the atomic swap.

Errors — JSON serialisation (practically impossible for these types), create_dir_all, the temp write, or the rename. All are returned as anyhow errors; main prints failed to save log: … and continues.

use steamlogger::{model::{GameLog, LogEntry}, storage};
use std::path::Path;

let mut log = GameLog::default();
log.games.push(LogEntry::new("Portal".into(), "2026-08-02T09:00:00-03:00".into()));
storage::write_log(Path::new("/var/lib/steamlogger/steamlog.json"), &log)?;

Output:

steamlog.json
{
"games": [
{
"name": "Portal",
"start": "2026-08-02T09:00:00-03:00"
}
]
}

Why temp + rename​

rename(2) is atomic within a filesystem on Unix, and std::fs::rename maps to MoveFileExW with MOVEFILE_REPLACE_EXISTING on Windows. A reader therefore always observes a complete file — either the old one or the new one. Writing in place would leave a truncated, unparseable log if the process died mid-write, which for a program that writes every 30 seconds is a real risk.

:::note No fsync, and the temp file must share the filesystem write_log does not call sync_all() on the file or its directory, so a power loss can still lose the newest write (never corrupt the old one). The temp file is created next to the target, which also guarantees the rename stays within one filesystem. :::

Known edge case: relative paths with a parent directory​

temp_path builds the temp name by appending .tmp to the whole path string and then calling Path::with_file_name:

fn temp_path(path: &Path) -> std::path::PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(".tmp");
path.with_file_name(name)
}

with_file_name pops the last component and pushes the new name. Pushing an absolute path replaces the buffer entirely, so absolute targets work; a bare filename has no parent, so it works too. A relative path with a directory component is the odd one out:

pathTemp path producedWorks?
steamlog.jsonsteamlog.json.tmp✅
/var/lib/steamlogger/steamlog.json/var/lib/steamlogger/steamlog.json.tmp✅
logs/steamlog.jsonlogs/logs/steamlog.json.tmp❌ directory does not exist → write fails

The existing test nested_parent_dir_created passes because it uses an absolute path under the temp directory. Until this is fixed, configure output_file as either a bare filename or an absolute path — the configuration guide recommends the same.

temp_path (private)​

fn temp_path(path: &Path) -> std::path::PathBuf

Returns the sibling temp file name used by write_log — see the edge case above. The unit test no_stray_temp_file_after_write asserts that no .tmp file survives a successful write.

Behavioural summary​

PropertyGuarantee
Partial writesImpossible — temp + rename
Concurrent writersUnprotected: last writer wins, no locking
Parent directoriesCreated automatically on write
FormattingPretty, two-space indent, trailing newline
Field omissionOptional fields with None are not written
Read toleranceMissing file, envelope form, or bare array

Tests​

8 unit tests in src/storage.rs, plus storage_roundtrip_temp in the integration suite.

TestAsserts
missing_file_reads_emptyNo file → empty log, no error
round_trip_preserves_entryAll seven fields survive write → read
pretty_output_with_skipped_optionalsIndented output, absent optionals, trailing newline
overwrite_replaces_old_logA second write fully replaces the first
no_stray_temp_file_after_write.tmp is gone after a successful write
malformed_json_is_errorGarbage content → Err
bare_array_is_normalizedTop-level array is wrapped into games
nested_parent_dir_createdMissing parent directories are created
storage_roundtrip_temp (integration)Full entry round trip through the public API

See also​

  • Log format — the schema this module persists.
  • model — the types being serialised.
  • Resilience — durability in the broader failure model.