Files
boxmaker/crates/memoryd/src/files.rs
T
kyleandClaude Opus 5.5 2b3ae080b4 proto becomes bxcore, the one shared crate (M5b task 00)
Done the way the owner asked and the spike mapped: the crate and the
manifests renamed, then what the build reported fixed. Layers: 138
"proto unresolved" errors across 169 files; cargo fmt reordering
imports; six fixture paths built as strings in brokerd's and bxctl's
tests, which the compiler cannot see, and comments naming
crates/proto/; the crate-dependency check and its self-test. One more
the spike did not reach: a `\nproto =` key inside a printf format in
the self-test, which a word-boundary replacement skips because `n` and
`p` are both word characters.

make gate: gate: ok, 944 tests; no new entry in /tmp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:48:38 -07:00

218 lines
8.8 KiB
Rust

//! The files under `memory/` and the memory block (M5a spec, sections 3 and 4). `memoryd` creates
//! what is missing and never overwrites anything; files are 0600 in a 0700 directory.
use std::fs::{DirBuilder, OpenOptions, Permissions};
use std::io::Write;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use bxcore::MemoryFile;
use crate::check::{Refused, check};
use crate::entries;
/// The most characters `soul.md` may have.
pub const SOUL_LIMIT: usize = 4_000;
/// `n` with commas between thousands: `2,200`.
pub fn grouped(n: usize) -> String {
// `let digits = n.to_string()`. Walk its chars with their index i; before each char where i > 0
// and `(digits.len() - i).is_multiple_of(3)`, push a comma. So 2200 is "2,200" and 999 is
// "999".
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
/// The memory directory.
#[derive(Debug, Clone)]
pub struct Store {
dir: PathBuf,
}
/// The block for a new session, and the identity entries left out of it because they failed the
/// check (for `memoryd` to log).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block {
pub text: String,
pub left_out: Vec<(MemoryFile, Refused)>,
}
fn fail(path: &Path, e: std::io::Error) -> String {
format!("{}: {e}", path.display())
}
impl Store {
pub fn new(dir: &Path) -> Store {
Store {
dir: dir.to_path_buf(),
}
}
pub fn path(&self, name: &str) -> PathBuf {
self.dir.join(name)
}
/// Create `memory/` (0700) and each missing file (0600): `soul.md` from `starter`, the others
/// empty. Nothing that exists is changed. The error names the path.
pub fn init(&self, starter: &Path) -> Result<(), String> {
// 1. `DirBuilder::new().recursive(true).mode(0o700).create(&self.dir)`, then
// `set_permissions(&self.dir, Permissions::from_mode(0o700))`.
// 2. When `self.path("soul.md")` does not exist: `read_to_string(starter)`, then
// `self.create(&soul, &text)`.
// 3. Each of "memory.md", "user.md", "notes.md" that does not exist: `self.create(&path,
// "")`. Every error through `fail(path, e)`. Nothing that exists is changed. Ok(()).
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&self.dir)
.map_err(|e| fail(&self.dir, e))?;
std::fs::set_permissions(&self.dir, Permissions::from_mode(0o700))
.map_err(|e| fail(&self.dir, e))?;
if !self.path("soul.md").exists() {
let text = std::fs::read_to_string(starter).map_err(|e| fail(starter, e))?;
let soul = self.path("soul.md");
self.create(&soul, &text)?;
}
for name in ["memory.md", "user.md", "notes.md"] {
let path = self.path(name);
if !path.exists() {
self.create(&path, "")?;
}
}
Ok(())
}
/// Create `path` at 0600 with `text`; an existing file is an error, never overwritten.
fn create(&self, path: &Path, text: &str) -> Result<(), String> {
// `OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)`, then
// `write_all(text.as_bytes())`. Errors through `fail(path, e)`. create_new makes an
// existing file an error: it is never overwritten.
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|e| fail(path, e))?;
file.write_all(text.as_bytes()).map_err(|e| fail(path, e))?;
Ok(())
}
/// The entries of an identity file.
pub fn entries(&self, file: MemoryFile) -> Result<Vec<String>, String> {
// `read_to_string(self.path(file.name()))` (an error through `fail`), then
// `entries::parse(&text)`.
let path = self.path(file.name());
let text = std::fs::read_to_string(&path).map_err(|e| fail(&path, e))?;
Ok(entries::parse(&text))
}
/// Replace an identity file's entries: written to a temporary file at 0600, then renamed.
pub fn write_entries(&self, file: MemoryFile, list: &[String]) -> Result<(), String> {
// The file is `self.path(file.name())`; the temporary file is
// `self.path(&format!(".{}.tmp", file.name()))`. Remove the temporary file if it is there
// (ignore the result), `self.create(&temp, &entries::render(list))?`, then
// `std::fs::rename(&temp, &path)` (an error through `fail(&path, e)`).
let path = self.path(file.name());
let temp = self.path(&format!(".{}.tmp", file.name()));
let _ = std::fs::remove_file(&temp);
self.create(&temp, &entries::render(list))?;
std::fs::rename(&temp, &path).map_err(|e| fail(&path, e))?;
Ok(())
}
/// Append `text` to a file (0600, created if missing), as one write.
pub fn append(&self, name: &str, text: &str) -> Result<(), String> {
// `OpenOptions::new().append(true).create(true).mode(0o600).open(&path)` for
// `self.path(name)`, then one `write_all(text.as_bytes())`. Errors through `fail`.
let path = self.path(name);
let mut file = OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(&path)
.map_err(|e| fail(&path, e))?;
file.write_all(text.as_bytes())
.map_err(|e| fail(&path, e))?;
Ok(())
}
/// The block for a new session (M5a spec, section 4). A file over its limit, or unreadable,
/// is an error: no session starts without its memory and no sign of it. Identity entries that
/// fail the check are left out and reported; `soul.md` is the owner's and is not checked.
pub fn block(&self) -> Result<Block, String> {
// 1. Read `self.path("soul.md")` (an error through `fail`) and trim() it. When its
// chars().count() > SOUL_LIMIT: Err(format!("{}: {} characters; its limit is {}",
// path.display(), grouped(chars), grouped(SOUL_LIMIT))).
// 2. `text` starts as format!("# soul.md\n{}", soul), with "(empty)" in place of an empty
// soul.
// 3. For file in [MemoryFile::Memory, MemoryFile::User]: `self.entries(file)?`; `size =
// entries::size(&list)`; when size > file.limit(), the same Err sentence with the path,
// size and limit of that file. Each entry: `check(&entry)` Ok -> kept; Err(refused) ->
// push (file, refused) onto `left_out`. The body is "(empty)" when nothing is kept, else
// `entries::render(&kept).trim_end()`. Append format!("\n\n# {} ({} of {}
// characters)\n{body}", file.name(), grouped(size), grouped(file.limit())) to `text`.
// `size` counts the whole file, entries left out included.
// 4. Ok(Block { text, left_out }).
let soul_path = self.path("soul.md");
let soul = std::fs::read_to_string(&soul_path).map_err(|e| fail(&soul_path, e))?;
let soul = soul.trim();
let chars = soul.chars().count();
if chars > SOUL_LIMIT {
return Err(format!(
"{}: {} characters; its limit is {}",
soul_path.display(),
grouped(chars),
grouped(SOUL_LIMIT)
));
}
let mut text = if soul.is_empty() {
"# soul.md\n(empty)".to_string()
} else {
format!("# soul.md\n{soul}")
};
let mut left_out = Vec::new();
for file in [MemoryFile::Memory, MemoryFile::User] {
let list = self.entries(file)?;
let size = entries::size(&list);
if size > file.limit() {
let path = self.path(file.name());
return Err(format!(
"{}: {} characters; its limit is {}",
path.display(),
grouped(size),
grouped(file.limit())
));
}
let mut kept = Vec::new();
for entry in &list {
match check(entry) {
Ok(()) => kept.push(entry.clone()),
Err(refused) => left_out.push((file, refused)),
}
}
let body = if kept.is_empty() {
"(empty)".to_string()
} else {
entries::render(&kept).trim_end().to_string()
};
text.push_str(&format!(
"\n\n# {} ({} of {} characters)\n{body}",
file.name(),
grouped(size),
grouped(file.limit())
));
}
Ok(Block { text, left_out })
}
}