///|
/// The same store as `cmd/main`, with a `.db` file behind it.
///
/// This is a separate binary rather than a flag on `cmd/main` because
/// `moonbit-community/sqlite3` only builds for `native` and `wasm`. Adding it
/// to `cmd/main` would drop that package from `js` and `wasm-gc`, and
/// `src/tesseract` would still have to stay dependency-free for the same
/// reason. Nothing in the core library or the original CLI is touched.
///
/// Persistence is a snapshot, not a mirror: the lattice lives in memory for the
/// life of the process and reaches the file when `.save` asks it to. Closing
/// without saving loses whatever was written since the last one.
///
/// `.open` restores what the file stored, timestamps included — each cell is
/// reinserted with its own stamp rather than a fresh one from this process's log
/// counter. The log's own numbering does not carry over, so stamps made after an
/// `.open` continue from this process, not from where the saved file stopped.
///
/// ```sh
/// moon run cmd/persist --target native -- .open sudoku.db \
/// "INSERT INTO tesseract (key,value,digit) VALUES ('a','{\"n\":1}',1) AT 1;" \
/// .save .status
/// ```
///|;
///|
/// stdout carries everything the commands report; stderr carries the banner and
/// failures only.
///
/// `cmd/main` splits its streams because `.export` writes a document that gets
/// redirected into a file. Nothing here does that — a persistence session is
/// read by a person — so the same split would only make a successful run print
/// nothing at all.
struct Cli {
mut quit : Bool
mut opened : String
mut saved : Bool
}
///|
let cli : Cli = { quit: false, opened: "", saved: false, }
///|
fn out(text : String) -> Unit {
println(text)
}
///|
fn warn(text : String) -> Unit {
eprintln(text)
}
///|
/// Runs one command, turning a SQLite failure into a message.
///
/// A `SqliteError` that reaches `main` is not a report: it aborts the process
/// with a PanicError and a native backtrace. "no such file" deserves a line of
/// text, not a crash dump.
fn safely(label : String, body : () -> Unit raise) -> Unit {
body() catch {
// `SqliteError` is an abstract error type, so it has no constructor pattern
// for `catch` to match on; the message is read through a `match` instead.
e =>
match e {
@sqlite3.SqliteError(msg~, ..) => warn(label + " failed: " + msg)
_ => warn(label + " failed: " + e.to_string())
}
}
}
///|
fn print_help() -> Unit {
out("")
out("usage: moon run cmd/persist --target native -- ...")
out("")
out("commands:")
out(" .open replace the store with the contents of ")
out(
" .save [path] write the store to , or to the last .open",
)
out(" .status lattice occupancy and cursor")
out(" .verify check the digits against the sudoku rules")
out(" .help this list")
out("")
out("anything else is executed as SQL by the same parser cmd/main uses.")
out("the store is in memory between .open and .save; nothing is mirrored.")
}
///|
fn print_status(db : @tesseract.Tesseract) -> Unit {
let lat = @tesseract.Tesseract::lattice_of_db(db)
let s = @tesseract.Tesseract::status(db)
let here = @tesseract.coords_of(lat, s.current_position).unwrap()
out("database: " + s.db_name)
out(
"lattice : " +
@tesseract.lattice_shape_text(lat) +
" = " +
@tesseract.sudoku_line_count(lat).to_string() +
" lines, " +
(@tesseract.DIM * lat.side()).to_string() +
" slabs",
)
out("records : " + s.current_count.to_string())
out("digits : " + s.digit_count.to_string())
out(
"cursor : " +
s.current_position.to_string() +
" " +
@tesseract.format_coords(here),
)
if cli.opened != "" {
out("file : " + cli.opened)
}
}
///|
/// `.verify` — the same check `cmd/main` runs, over the digits in the store.
fn verify(db : @tesseract.Tesseract) -> Unit {
let lat = @tesseract.Tesseract::lattice_of_db(db)
let all = @tesseract.Tesseract::select(
db,
"",
"",
None,
@tesseract.no_filter(),
None,
)
let digits = @tesseract.empty_digits(lat)
for row in all.data {
if row.digit != @tesseract.NO_DIGIT {
@tesseract.set_digit(lat, digits, row.position, row.digit)
}
}
let lines = @tesseract.line_violations(lat, digits)
let slabs = @tesseract.slab_violations(lat, digits)
out("line violations : " + lines.length().to_string())
out("slab imbalances : " + slabs.length().to_string())
}
///|
/// Everything after the first word, trimmed.
fn argument(line : String, keyword : String) -> String {
@tesseract.trim_ws(@tesseract.slice(line, keyword.length(), line.length()))
}
///|
fn handle_dot(db : @tesseract.Tesseract, line : String) -> Bool {
if line == ".help" || line == "-h" || line == "--help" {
print_help()
return true
}
if line == ".status" {
print_status(db)
return true
}
if line == ".verify" {
verify(db)
return true
}
if @tesseract.starts_with(line, ".open ") {
let path = argument(line, ".open")
safely(".open", fn() raise {
let report = @tesseract_sqlite.load(path, db)
cli.opened = path
out(
"opened " +
path +
": " +
report.loaded.to_string() +
" record(s), " +
report.rejected.to_string() +
" rejected, cursor " +
report.cursor.to_string(),
)
})
return true
}
if @tesseract.starts_with(line, ".save") {
let wanted = argument(line, ".save")
let path = if wanted == "" { cli.opened } else { wanted }
if path == "" {
warn(".save needs a path, or an .open to take it from")
} else {
safely(".save", fn() raise {
let written = @tesseract_sqlite.save(path, db)
cli.saved = true
out("saved " + written.to_string() + " record(s) to " + path)
})
}
return true
}
if line == ".quit" || line == ".exit" {
cli.quit = true
return true
}
// Not a dot command: `false` is how `main` learns to run this as SQL.
false
}
///|
fn main {
let all = @env.args()
let args = if all.length() > 0 { all[1:] } else { all }
warn("Tesseract 4D - persistence CLI (native + wasm)")
if args.length() == 0 {
print_help()
return
}
let logger = @tesseract.new_logger("tesseract")
// Same reasoning as `cmd/main`, and it matters more here: `.save` writes the
// timestamps it was given into the `.db` and `.open` reads them back, so a
// persisted file full of `#7` counters is not a timestamp.
@tesseract.TesseractLogger::set_clock(logger, @tesseract.system_clock())
let db = @tesseract.new_db("tesseract", @tesseract.default_lattice(), logger)
@tesseract.TesseractLogger::set_echo(
@tesseract.Tesseract::get_logger(db),
true,
)
let mut i = 0
while i < args.length() {
// A command and its argument arrive as two argv entries under ordinary
// shell word-splitting: `-- .open sudoku.db` is `.open` and `sudoku.db`.
// A bare `.open` matches nothing in `handle_dot` and falls through to the
// SQL parser, which reports "Unsupported SQL statement: .open" and leaves
// `cli.opened` empty so the `.save` that follows has no file to write to.
//
// `.save`'s argument is optional, so an entry that looks like another
// command is left alone - otherwise a trailing bare `.save` would swallow
// the `.status` after it and write to a file called ".status".
let mut arg = args[i]
if (arg == ".open" || arg == ".save") &&
i + 1 < args.length() &&
args[i + 1].length() > 0 &&
args[i + 1].at(0) != '.' {
arg = arg + " " + args[i + 1]
i = i + 1
}
if !cli.quit {
if !handle_dot(db, arg) {
out(@tesseract.format_response(@tesseract.execute(db, arg)))
}
}
i = i + 1
}
// A session that opened a file and never wrote one back loses everything it
// did. Saying so is the whole reason `.save` is a separate verb rather than
// something that happens on exit.
if cli.opened != "" && !cli.saved {
out("note: nothing was saved — the store was in memory only")
}
}