237 lines
9.5 KiB
Rust
237 lines
9.5 KiB
Rust
//! The edit journal: one JSON line per applied edit in `memory/edits/YYYY-MM.jsonl`, the owner's
|
|
//! record of every write, and `undo` of the latest edit of a file (M5a spec, section 5).
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use bxcore::{MemoryFile, Timestamp};
|
|
|
|
use crate::edit::{Context, Outcome};
|
|
use crate::files::Store;
|
|
|
|
/// One line of the journal.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Record {
|
|
/// `YYYY-MM-DD-NNNN`, numbered from 1 each day.
|
|
pub id: String,
|
|
pub time: String,
|
|
pub session: String,
|
|
pub call: u64,
|
|
/// `add`, `replace`, `remove`, `consolidate`, `note`, or `undo`.
|
|
pub action: String,
|
|
/// The identity file changed, if any.
|
|
pub file: Option<String>,
|
|
/// Where the write went: `memory.md`, `user.md` or `notes.md`.
|
|
pub to: String,
|
|
pub before: Vec<String>,
|
|
pub after: Vec<String>,
|
|
/// The `notes.md` entries appended, as written.
|
|
pub notes: Vec<String>,
|
|
pub untrusted: bool,
|
|
/// The flag rule the write matched, when it was saved as untrusted notes (M5b spec, section
|
|
/// 6.3). Absent from the line otherwise, so earlier lines read as they are.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub flag: Option<String>,
|
|
/// For an undo, the edit it reverted.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub undoes: Option<String>,
|
|
}
|
|
|
|
fn file_of(name: &str) -> Option<MemoryFile> {
|
|
match name {
|
|
"memory.md" => Some(MemoryFile::Memory),
|
|
"user.md" => Some(MemoryFile::User),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Every record, oldest first, from every month's file.
|
|
pub fn records(store: &Store) -> Result<Vec<Record>, String> {
|
|
// Read the directory `store.path("edits")`: NotFound -> Ok(empty); another error ->
|
|
// Err(format!("{}: {e}", dir.display())). Take the names that end ".jsonl" and sort them. For
|
|
// each file, `read_to_string` and parse every line as a Record with serde_json; a bad line ->
|
|
// Err(format!("{}: line {}: {e}", path.display(), n + 1)) with n counted from 0. Return all
|
|
// records, oldest first.
|
|
let dir = store.path("edits");
|
|
let mut paths: Vec<std::path::PathBuf> = match std::fs::read_dir(&dir) {
|
|
Ok(rd) => rd
|
|
.filter_map(|entry| entry.ok().map(|e| e.path()))
|
|
.collect(),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
|
Err(e) => return Err(format!("{}: {e}", dir.display())),
|
|
};
|
|
paths.retain(|p| {
|
|
p.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.is_some_and(|n| n.ends_with(".jsonl"))
|
|
});
|
|
paths.sort();
|
|
let mut out: Vec<Record> = Vec::new();
|
|
for path in paths {
|
|
let text =
|
|
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
|
for (n, line) in text.lines().enumerate() {
|
|
let record: Record = serde_json::from_str(line)
|
|
.map_err(|e| format!("{}: line {}: {e}", path.display(), n + 1))?;
|
|
out.push(record);
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Append a record, giving it the next id of its day.
|
|
pub(crate) fn append(store: &Store, mut record: Record, now: Timestamp) -> Result<String, String> {
|
|
// 1. `day` is the first 10 characters of `now.to_rfc3339()` ("0000-00-00" if shorter).
|
|
// 2. `taken` = how many of `records(store)?` have an id starting format!("{day}-").
|
|
// 3. record.id = format!("{day}-{:04}", taken + 1).
|
|
// 4. `create_dir_all(store.path("edits"))` (an error names the directory),
|
|
// `serde_json::to_string(&record)`, then `store.append(&format!("edits/{month}.jsonl"),
|
|
// &format!("{line}\n"))?` where month is the first 7 characters of day. Ok(record.id).
|
|
let rfc = now.to_rfc3339();
|
|
let day = rfc.get(..10).unwrap_or("0000-00-00");
|
|
let taken = records(store)?
|
|
.iter()
|
|
.filter(|r| r.id.starts_with(&format!("{day}-")))
|
|
.count();
|
|
record.id = format!("{day}-{:04}", taken + 1);
|
|
let edits_dir = store.path("edits");
|
|
std::fs::create_dir_all(&edits_dir).map_err(|e| format!("{}: {e}", edits_dir.display()))?;
|
|
let line = serde_json::to_string(&record).map_err(|e| e.to_string())?;
|
|
let month = day.get(..7).unwrap_or("0000-00");
|
|
store.append(&format!("edits/{month}.jsonl"), &format!("{line}\n"))?;
|
|
Ok(record.id)
|
|
}
|
|
|
|
/// Journal an applied call; the id it was given.
|
|
pub fn record(store: &Store, ctx: &Context, outcome: &Outcome) -> Result<String, String> {
|
|
// `append` a Record: id empty (append sets it), time `ctx.now.to_rfc3339()`, session and call
|
|
// from ctx, action `outcome.action`, file `change.file` as its name, to that name or "notes.md"
|
|
// when there is no file, before and after from the change, notes the change's notes each
|
|
// `render()`ed, untrusted `ctx.untrusted`, flag `outcome.flag` as a String, undoes None; `now`
|
|
// is ctx.now.
|
|
let change = &outcome.change;
|
|
let notes: Vec<String> = change.notes.iter().map(|n| n.render()).collect();
|
|
let file = change.file.map(|f| f.name().to_string());
|
|
let to = match &file {
|
|
Some(name) => name.clone(),
|
|
None => "notes.md".to_string(),
|
|
};
|
|
append(
|
|
store,
|
|
Record {
|
|
id: String::new(),
|
|
time: ctx.now.to_rfc3339(),
|
|
session: ctx.session.clone(),
|
|
call: ctx.call,
|
|
action: outcome.action.to_string(),
|
|
file,
|
|
to,
|
|
before: change.before.clone(),
|
|
after: change.after.clone(),
|
|
notes,
|
|
untrusted: ctx.untrusted,
|
|
flag: outcome.flag.map(str::to_string),
|
|
undoes: None,
|
|
},
|
|
ctx.now,
|
|
)
|
|
}
|
|
|
|
/// The last `count` records, one line each, oldest first.
|
|
pub fn log(store: &Store, count: usize) -> Result<String, String> {
|
|
// The last `count` of `records(store)?` (all of them when fewer), oldest first, one line each:
|
|
// format!("{} {} {} {} {}", id, time, session, action, to), then " untrusted" when the
|
|
// record is untrusted, then format!(" flagged {rule}") when it has a flag; joined by
|
|
// newlines. With no records at all: "no memory edits yet".
|
|
let all = records(store)?;
|
|
if all.is_empty() {
|
|
return Ok("no memory edits yet".to_string());
|
|
}
|
|
let selected = &all[all.len().saturating_sub(count)..];
|
|
let lines: Vec<String> = selected
|
|
.iter()
|
|
.map(|r| {
|
|
let mut line = format!(
|
|
"{} {} {} {} {}",
|
|
r.id, r.time, r.session, r.action, r.to
|
|
);
|
|
if r.untrusted {
|
|
line.push_str(" untrusted");
|
|
}
|
|
if let Some(rule) = &r.flag {
|
|
line.push_str(&format!(" flagged {rule}"));
|
|
}
|
|
line
|
|
})
|
|
.collect();
|
|
Ok(lines.join("\n"))
|
|
}
|
|
|
|
/// Revert edit `id`, if it is the latest edit of its identity file and the file still holds
|
|
/// exactly what it left. Notes it appended stay (the file is append-only). The undo is journalled.
|
|
pub fn undo(store: &Store, id: &str, now: Timestamp) -> Result<String, String> {
|
|
// 1. Find the record with this id in `records(store)?`, else Err(format!("no edit {id}")).
|
|
// 2. Its file must be Some and known to `file_of`, else Err(format!("{id} changed no identity
|
|
// file; notes are append-only")).
|
|
// 3. The latest record whose file is the same name must be this one, else Err(format!("{id} is
|
|
// not the latest edit of {name}")).
|
|
// 4. `store.entries(file)?` must equal record.after, else Err(format!("{name} has changed since
|
|
// {id}; nothing was undone")).
|
|
// 5. `store.write_entries(file, &record.before)?`, then `append` a Record: session "owner",
|
|
// call 0, action "undo", file and to the name, before the current entries, after
|
|
// record.before, no notes, untrusted false, flag None, undoes Some(id).
|
|
// 6. Ok(format!("undone: {id} ({name} restored; journalled as {undo_id})")).
|
|
let all = records(store)?;
|
|
let record = all
|
|
.iter()
|
|
.find(|r| r.id == id)
|
|
.ok_or_else(|| format!("no edit {id}"))?;
|
|
let name: &str = match &record.file {
|
|
Some(n) => n,
|
|
None => {
|
|
return Err(format!(
|
|
"{id} changed no identity file; notes are append-only"
|
|
));
|
|
}
|
|
};
|
|
let file = match file_of(name) {
|
|
Some(f) => f,
|
|
None => {
|
|
return Err(format!(
|
|
"{id} changed no identity file; notes are append-only"
|
|
));
|
|
}
|
|
};
|
|
let latest = all.iter().rev().find(|r| r.file.as_deref() == Some(name));
|
|
if latest.is_none_or(|r| r.id != id) {
|
|
return Err(format!("{id} is not the latest edit of {name}"));
|
|
}
|
|
let current = store.entries(file)?;
|
|
if current != record.after {
|
|
return Err(format!("{name} has changed since {id}; nothing was undone"));
|
|
}
|
|
store.write_entries(file, &record.before)?;
|
|
let undo_id = append(
|
|
store,
|
|
Record {
|
|
id: String::new(),
|
|
time: now.to_rfc3339(),
|
|
session: "owner".to_string(),
|
|
call: 0,
|
|
action: "undo".to_string(),
|
|
file: Some(name.to_string()),
|
|
to: name.to_string(),
|
|
before: current,
|
|
after: record.before.clone(),
|
|
notes: Vec::new(),
|
|
untrusted: false,
|
|
flag: None,
|
|
undoes: Some(id.to_string()),
|
|
},
|
|
now,
|
|
)?;
|
|
Ok(format!(
|
|
"undone: {id} ({name} restored; journalled as {undo_id})"
|
|
))
|
|
}
|