Chapter 8: Files and a first save/load
This chapter:
- Read and write a whole file with
core:os. - Choose a simple, line-based text format for saving structured data.
- Encode a record to a line, and decode a line back to a record.
- Load tolerantly: a missing file means "start empty", not a crash.
- Write
notes_v1.odin, a rough first note-keeper that survives being closed and reopened.
Where we left off: Everything so far has lived only as long as the program ran. This chapter makes something outlive the program by making use of files.
8.1 Reading and writing whole files
core:os gives you whole-file operations that do exactly what they say:
data, err := os.read_entire_file_from_path("notes.txt", context.allocator)
if err != nil {
// file doesn't exist, or couldn't be read
}
defer delete(data)
write_err := os.write_entire_file("notes.txt", some_bytes)
read_entire_file_from_path hands you back the whole file as []byte, plus an Error that's nil on success. write_entire_file creates the file if it doesn't exist and overwrites it if it does, no separate "open, then write, then close" dance to get wrong.
Why "read it all" rather than streaming? Because a personal note-keeper's save file is small: dozens or hundreds of notes, not gigabytes. Reading a whole small file into memory at once is simpler code, and simpler code is the right level of engineering for our scope here. A file large enough that reading it whole became a real problem would need a genuinely different approach (reading it in chunks), which is more machinery than anything we need here.
8.2 Choosing a format
A note has two fields: a title and a body. The simplest format that can hold that is one line per note, with the two fields separated by a character unlikely to show up in real typed text:
FIELD_SEP :: "\x1f" // the ASCII "unit separator" -- not a character anyone types
\x1f is a genuinely obscure control character: nobody types it by accident the way they might type a comma or a pipe character in an ordinary sentence. One line per note also means "how many notes are there" is just "how many lines are there", no need to track record boundaries any more carefully than that.
Why not JSON, or a proper binary format? Both are more capable, and both are more machinery than a personal note-keeper needs. A flat, line-based format is trivially readable in a text editor if something goes wrong (Chapter 11 leans on exactly that when it deliberately corrupts a save file to test tolerant loading), and it's simple enough that
encode_note/decode_notebelow are each about four lines. The trade-off is real: this format would fall over the moment a note's body needed to contain a genuine newline, but it's the right trade off for what we are building.
8.3 Encoding and decoding a record
Encoding builds one line per note into a shared strings.Builder (Chapter 4's tool); decoding splits a line back apart on the same separator:
Note :: struct {
title: string,
body: string,
}
encode_note :: proc(n: Note, b: ^strings.Builder) {
strings.write_string(b, n.title)
strings.write_string(b, FIELD_SEP)
strings.write_string(b, n.body)
strings.write_byte(b, '\n')
}
decode_note :: proc(line: string) -> (n: Note, ok: bool) {
parts := strings.split(line, FIELD_SEP)
defer delete(parts)
if len(parts) != 2 {
return {}, false
}
return Note{title = strings.clone(parts[0]), body = strings.clone(parts[1])}, true
}
decode_note returns ok = false for anything that isn't exactly two fields: no separator at all, or more than one, rather than guessing at what a malformed line meant. strings.clone on the way out matters here: parts came from strings.split, which slices into the file data you're about to delete; without cloning, the note's title and body would be left pointing at memory that's no longer valid the moment the caller frees that buffer.
8.4 Tolerant loading
The rule for load_notes: a missing file means "nothing saved yet, start with zero notes", not an error to report, just an empty list to build up from.
load_notes :: proc() -> [dynamic]Note {
notes: [dynamic]Note
data, err := os.read_entire_file_from_path(SAVE_FILE, context.allocator)
if err != nil {
return notes // missing file -- start with zero notes
}
defer delete(data)
for line in strings.split_lines(string(data)) {
if len(line) == 0 {
continue
}
n, ok := decode_note(line)
if ok {
append(¬es, n)
}
}
return notes
}
save_notes is the mirror image: build every note into one Builder, write the result in one go.
save_notes :: proc(notes: []Note) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
for n in notes {
encode_note(n, &b)
}
err := os.write_entire_file(SAVE_FILE, transmute([]byte)strings.to_string(b))
if err != nil {
fmt.eprintfln("could not save notes: %v", err)
}
}
Why treat "missing" as fine, but still check the write error? A missing save file on the first ever run is completely normal: there was never anything to load. A write failing (a full disk, no permission to the directory) is a different kind of problem your program should at least mention, rather than silently pretending the notes were saved when they weren't. This is my standing rule for every load/save pair we write: be forgiving about a file simply not existing yet, vocal about anything going wrong.
8.5 The program: a rough first note-keeper
Put encoding, decoding, load and save behind a tiny add/list/quit loop, a single-file first draft of what Muninn becomes once it has a proper menu (Chapter 7) and a real store (Chapter 9):
// notes_v1.odin
package main
import "core:bufio"
import "core:fmt"
import "core:os"
import "core:strings"
Note :: struct {
title: string,
body: string,
}
SAVE_FILE :: "notes.txt"
FIELD_SEP :: "\x1f"
encode_note :: proc(n: Note, b: ^strings.Builder) {
strings.write_string(b, n.title)
strings.write_string(b, FIELD_SEP)
strings.write_string(b, n.body)
strings.write_byte(b, '\n')
}
decode_note :: proc(line: string) -> (n: Note, ok: bool) {
parts := strings.split(line, FIELD_SEP)
defer delete(parts)
if len(parts) != 2 {
return {}, false
}
return Note{title = strings.clone(parts[0]), body = strings.clone(parts[1])}, true
}
save_notes :: proc(notes: []Note) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
for n in notes {
encode_note(n, &b)
}
err := os.write_entire_file(SAVE_FILE, transmute([]byte)strings.to_string(b))
if err != nil {
fmt.eprintfln("could not save notes: %v", err)
}
}
load_notes :: proc() -> [dynamic]Note {
notes: [dynamic]Note
data, err := os.read_entire_file_from_path(SAVE_FILE, context.allocator)
if err != nil {
return notes
}
defer delete(data)
for line in strings.split_lines(string(data)) {
if len(line) == 0 {
continue
}
n, ok := decode_note(line)
if ok {
append(¬es, n)
}
}
return notes
}
read_line :: proc(scanner: ^bufio.Scanner) -> (line: string, ok: bool) {
if !bufio.scanner_scan(scanner) {
return "", false
}
return strings.trim_space(bufio.scanner_text(scanner)), true
}
main :: proc() {
notes := load_notes()
fmt.printfln("Loaded %d note(s).", len(notes))
scanner: bufio.Scanner
bufio.scanner_init(&scanner, os.to_reader(os.stdin))
defer bufio.scanner_destroy(&scanner)
for {
fmt.print("(a)dd, (l)ist, (q)uit: ")
cmd, ok := read_line(&scanner)
if !ok {
break
}
switch cmd {
case "a":
fmt.print("Title: ")
title, _ := read_line(&scanner)
fmt.print("Body: ")
body, _ := read_line(&scanner)
append(¬es, Note{title = strings.clone(title), body = strings.clone(body)})
save_notes(notes[:])
fmt.println("Saved.")
case "l":
for n, i in notes {
fmt.printfln("%d. %s -- %s", i+1, n.title, n.body)
}
case "q":
return
case:
fmt.println("Not a valid choice.")
}
}
}
First run, an empty directory:
$ odin run notes_v1.odin -file
Loaded 0 note(s).
(a)dd, (l)ist, (q)uit: a
Title: Shopping
Body: Milk and eggs
Saved.
(a)dd, (l)ist, (q)uit: a
Title: Todo
Body: Write chapter 8
Saved.
(a)dd, (l)ist, (q)uit: l
1. Shopping -- Milk and eggs
2. Todo -- Write chapter 8
(a)dd, (l)ist, (q)uit: q
Run it again, same directory:
$ odin run notes_v1.odin -file
Loaded 2 note(s).
(a)dd, (l)ist, (q)uit: l
1. Shopping -- Milk and eggs
2. Todo -- Write chapter 8
(a)dd, (l)ist, (q)uit: q
Both notes are there: load_notes found notes.txt from the first run and rebuilt the list from it. Every a (add) also calls save_notes immediately, so nothing typed is ever more than one command away from being on disk.
Checkpoint
notes_v1.odin adds and lists short notes, and what you type in one run is still there the next time you start the program. You've read and written a whole file, chosen and implemented a simple record format, and written a load that never crashes on a file that simply isn't there yet.
Exercises
- Add a
(d)eletecommand: list the notes with numbers (aslalready does), prompt for a number, remove that note from the[dynamic]Note, and save. (Solution sketch:ordered_removefromcore:slice, or slicing the dynamic array around the index, both work; check what happens if the user types a number that's out of range before you commit to either.) FIELD_SEPis"\x1f". What actually happens right now if a user types a title or body that happens to contain that exact character? Try it: add a note with a\x1ftyped into the body somehow (or construct one programmatically), and explain whatdecode_notedoes with the resulting line on the next load.load_notessilently skips any line that doesn't decode. Change it to also print how many lines it skipped, if any, after loading ("Loaded 3 note(s), skipped 1 unreadable line."). This is a small preview of Chapter 11's fuller corrupt-file handling.- Right now
save_notesrewrites the entire file on every single add. For a note-keeper with a handful of notes this is unnoticeable; sketch, in a sentence or two, why it would start to matter if someone had ten thousand notes, and what a smarter approach might look like (you don't need to implement it). - Delete
notes.txtby hand, then create a file callednotes.txtthat's actually a directory (mkdir notes.txt) instead. Run the program. What doesload_notesdo, and is that still "tolerant" in the sense ยง8.4 promised, or does this count as a different kind of failure that deserves different handling?
Further reading
- Odin,
core:os: https://pkg.odin-lang.org/core/os/ - Odin,
core:strings(split,split_lines,Builder): https://pkg.odin-lang.org/core/strings/