#![cfg(feature = "mem-repo")]
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use assert_cmd::Command;
use tempfile::TempDir;
fn cache_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
fn memstead() -> Command {
let mut cmd = Command::cargo_bin("memstead").expect("memstead binary must be built by cargo");
cmd.env("MEMSTEAD_OPERATOR_MODE", "1");
cmd
}
fn run_ok(root: &Path, cache: &Path, args: &[&str]) -> Vec<u8> {
memstead()
.current_dir(root)
.env("MEMSTEAD_MEM_CACHE", cache)
.args(args)
.assert()
.success()
.get_output()
.stdout
.clone()
}
const MANIFEST: &str = r#"name: fieldnotes
version: 0.1.0
description: A third-party vocabulary the installing workspace has never seen.
when_to_use: In the embedded-schema install tests.
types:
- note
relationships:
mode: strict
definitions:
- name: FOLLOWS
description: Sequential ordering between notes
default_weight: 2.0
- name: _default
description: Fallback weight for unknown relationships
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
const NOTE_TYPE: &str = r#"name: note
description: One field note.
when_to_use: For anything observed in the field.
sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
write_rules:
- One paragraph of observation.
metadata_fields:
- key: observer
description: Who wrote the note down.
field_type: string
required: true
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: FOLLOWS
no_self_loop_relationships:
- FOLLOWS
updatable_fields:
- title
- body
health_required_fields:
- body
staleness_threshold_days: 90
write_rules:
- Keep it short.
"#;
fn retired_selfloop_variant() -> String {
NOTE_TYPE.replace("no_self_loop_relationships:", "propagating_relationships:")
}
fn retired_optional_variant(optional: bool) -> String {
let out = NOTE_TYPE.replace(
" field_type: string\n required: true\n",
&format!(" field_type: string\n optional: {optional}\n"),
);
assert_ne!(out, NOTE_TYPE, "the polarity variant must actually differ");
out
}
fn write_package(dir: &Path, note_type: &str) {
fs::create_dir_all(dir.join("types")).unwrap();
fs::write(dir.join("schema.yaml"), MANIFEST).unwrap();
fs::write(dir.join("types").join("note.yaml"), note_type).unwrap();
}
fn publish_fieldnotes_archive(root: &Path, cache: &Path) -> PathBuf {
run_ok(root, cache, &["mem-repo", "init", "."]);
let pkg = root.join("fieldnotes-pkg");
write_package(&pkg, NOTE_TYPE);
run_ok(root, cache, &["schema", "install", pkg.to_str().unwrap()]);
run_ok(
root,
cache,
&[
"mem",
"init",
"field-log",
"--schema",
"fieldnotes@0.1.0",
"--no-gitignore",
],
);
run_ok(
root,
cache,
&[
"create",
"--mem",
"field-log",
"--title",
"Morning Count",
"--type",
"note",
"--section",
"body=Eleven herons on the east bank, just after first light.",
"--metadata",
"observer=A. Ranger",
],
);
let archive = root.join("field-log.mem");
run_ok(
root,
cache,
&[
"export",
"--format",
"mem",
"--mem",
"field-log",
"-o",
archive.to_str().unwrap(),
],
);
assert!(archive.is_file(), "export must produce the archive");
archive
}
fn fresh_receiver(root: &Path, cache: &Path) {
run_ok(root, cache, &["mem-repo", "init", "."]);
run_ok(root, cache, &["mem", "init", "notes", "--no-gitignore"]);
}
const NOTE_MEMBER: &str = ".memstead/schema/types/note.yaml";
const MARKER_MEMBER: &str = ".memstead/schema/schema-format.json";
fn repack(src: &Path, dest: &Path, member: &str, new_bytes: &[u8], drop: &[&str]) {
use std::io::{Read as _, Write as _};
let mut archive = zip::ZipArchive::new(fs::File::open(src).unwrap()).unwrap();
let mut writer = zip::ZipWriter::new(fs::File::create(dest).unwrap());
let opts = zip::write::SimpleFileOptions::default();
let mut replaced = false;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).unwrap();
let name = entry.name().to_string();
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).unwrap();
if drop.contains(&name.as_str()) {
continue;
}
writer.start_file(&name, opts).unwrap();
if name == member {
writer.write_all(new_bytes).unwrap();
replaced = true;
} else {
writer.write_all(&bytes).unwrap();
}
}
writer.finish().unwrap();
assert!(replaced, "archive must carry the member {member}");
}
fn staged_schemas(root: &Path) -> Vec<std::sync::Arc<memstead_schema::Schema>> {
match memstead_git_branch::mem_repo_schemas::load_schemas_from_ref(root).unwrap() {
memstead_git_branch::mem_repo_schemas::LoadOutcome::Schemas(s) => s,
_ => Vec::new(),
}
}
#[test]
fn archive_under_an_unknown_schema_installs_mounts_and_reads() {
let _guard = cache_guard();
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let archive = publish_fieldnotes_archive(sender.path(), cache.path());
fresh_receiver(receiver.path(), cache.path());
assert!(
!staged_schemas(receiver.path())
.iter()
.any(|s| s.manifest.name == "fieldnotes"),
"receiver must start with no knowledge of the publisher's schema"
);
run_ok(
receiver.path(),
cache.path(),
&["install", archive.to_str().unwrap()],
);
let out = run_ok(
receiver.path(),
cache.path(),
&["--json", "entity", "field-log--morning-count"],
);
let entity: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(entity["type"], "note", "got: {entity}");
assert!(
entity.to_string().contains("Eleven herons"),
"the installed mem's content must be readable: {entity}"
);
}
#[test]
fn archive_with_a_retired_selfloop_key_installs_and_keeps_its_meaning() {
let _guard = cache_guard();
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let archive = publish_fieldnotes_archive(sender.path(), cache.path());
let retired = sender.path().join("field-log-retired.mem");
repack(
&archive,
&retired,
NOTE_MEMBER,
retired_selfloop_variant().as_bytes(),
&[],
);
fresh_receiver(receiver.path(), cache.path());
run_ok(
receiver.path(),
cache.path(),
&["install", retired.to_str().unwrap()],
);
let staged = staged_schemas(receiver.path());
let fieldnotes = staged
.iter()
.find(|s| s.manifest.name == "fieldnotes")
.unwrap_or_else(|| panic!("staged schemas: {}", staged.len()));
let note = fieldnotes.types.get("note").expect("type `note` must load");
assert_eq!(
note.no_self_loop_relationships,
vec!["FOLLOWS".to_string()],
"the retired key's written meaning must survive the rename"
);
let out = run_ok(
receiver.path(),
cache.path(),
&["--json", "entity", "field-log--morning-count"],
);
let entity: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(entity["type"], "note", "got: {entity}");
}
#[test]
fn archive_with_a_retired_polarity_key_installs_and_keeps_its_meaning() {
let _guard = cache_guard();
let cache = TempDir::new().unwrap();
for (optional, expect_required) in [(false, true), (true, false)] {
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let archive = publish_fieldnotes_archive(sender.path(), cache.path());
let preflip = sender.path().join("field-log-preflip.mem");
repack(
&archive,
&preflip,
NOTE_MEMBER,
retired_optional_variant(optional).as_bytes(),
&[MARKER_MEMBER],
);
fresh_receiver(receiver.path(), cache.path());
run_ok(
receiver.path(),
cache.path(),
&["install", preflip.to_str().unwrap()],
);
let staged = staged_schemas(receiver.path());
let fieldnotes = staged
.iter()
.find(|s| s.manifest.name == "fieldnotes")
.unwrap_or_else(|| panic!("staged schemas: {}", staged.len()));
let note = fieldnotes.types.get("note").expect("type `note` must load");
let observer = note
.metadata_fields
.iter()
.find(|f| f.key == "observer")
.expect("metadata field `observer` must load");
assert_eq!(
observer.required_resolved, expect_required,
"`optional: {optional}` must invert to required={expect_required} — \
the written meaning must survive the retirement"
);
}
}
#[test]
fn authoring_refuses_what_a_sealed_archive_still_admits() {
let _guard = cache_guard();
let sender = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let ws = sender.path();
run_ok(ws, cache.path(), &["mem-repo", "init", "."]);
let cases: [(&str, String, &str, &str); 2] = [
(
"selfloop",
retired_selfloop_variant(),
"propagating_relationships",
"no_self_loop_relationships",
),
(
"polarity",
retired_optional_variant(false),
"optional",
"required: true",
),
];
for (label, content, retired_key, current_key) in &cases {
let pkg = ws.join(format!("retired-{label}-pkg"));
write_package(&pkg, content);
for verb in ["validate", "install"] {
let out = memstead()
.current_dir(ws)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.args(["--json", "schema", verb, pkg.to_str().unwrap()])
.assert()
.failure()
.get_output()
.stdout
.clone();
let envelope: serde_json::Value = serde_json::from_slice(&out).unwrap();
let rendered = envelope.to_string();
assert!(
rendered.contains(retired_key),
"`schema {verb}` must name the offending retired key \
`{retired_key}` — got: {rendered}"
);
assert!(
rendered.contains(current_key),
"`schema {verb}` must name the current spelling \
`{current_key}` so the author can act — got: {rendered}"
);
}
}
let publisher = TempDir::new().unwrap();
let archive = publish_fieldnotes_archive(publisher.path(), cache.path());
for (label, content, _, _) in &cases {
let sealed = publisher.path().join(format!("sealed-{label}.mem"));
let drop: &[&str] = if *label == "polarity" {
&[MARKER_MEMBER]
} else {
&[]
};
repack(&archive, &sealed, NOTE_MEMBER, content.as_bytes(), drop);
let receiver = TempDir::new().unwrap();
fresh_receiver(receiver.path(), cache.path());
run_ok(
receiver.path(),
cache.path(),
&["install", sealed.to_str().unwrap()],
);
}
}
#[test]
fn unloadable_embedded_schema_refuses_and_leaves_nothing_behind() {
let _guard = cache_guard();
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let archive = publish_fieldnotes_archive(sender.path(), cache.path());
let broken = sender.path().join("broken.mem");
repack(
&archive,
&broken,
NOTE_MEMBER,
b"name: note\nsections: [ this is not: valid: yaml\n",
&[],
);
fresh_receiver(receiver.path(), cache.path());
let out = memstead()
.current_dir(receiver.path())
.env("MEMSTEAD_MEM_CACHE", cache.path())
.args(["--json", "install", broken.to_str().unwrap()])
.assert()
.failure()
.get_output()
.stdout
.clone();
let envelope: serde_json::Value = serde_json::from_slice(&out).unwrap();
let code = envelope["code"].as_str().unwrap_or_default();
assert_ne!(code, "SCHEMA_NOT_FOUND", "got: {envelope}");
assert_ne!(code, "INTERNAL", "got: {envelope}");
assert_eq!(code, "EMBEDDED_SCHEMA_INVALID", "got: {envelope}");
let message = envelope["message"].as_str().unwrap_or_default();
assert!(
message.contains("note.yaml") || message.contains("parse"),
"the refusal must quote the loader's own diagnosis: {message}"
);
assert!(
!message.contains("memstead schema install"),
"the package is inside the archive — never advise obtaining it: {message}"
);
memstead()
.current_dir(receiver.path())
.env("MEMSTEAD_MEM_CACHE", cache.path())
.args(["--json", "entity", "field-log--morning-count"])
.assert()
.failure();
assert!(
!staged_schemas(receiver.path())
.iter()
.any(|s| s.manifest.name == "fieldnotes"),
"a refused install must leave no staged schema"
);
}
#[test]
fn reinstall_is_a_noop_and_a_shared_schema_installs_in_either_order() {
let _guard = cache_guard();
let sender = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let ws = sender.path();
run_ok(ws, cache.path(), &["mem-repo", "init", "."]);
let pkg = ws.join("fieldnotes-pkg");
write_package(&pkg, NOTE_TYPE);
run_ok(
ws,
cache.path(),
&["schema", "install", pkg.to_str().unwrap()],
);
let mut archives = Vec::new();
for mem in ["field-log", "tide-log"] {
run_ok(
ws,
cache.path(),
&[
"mem",
"init",
mem,
"--schema",
"fieldnotes@0.1.0",
"--no-gitignore",
],
);
run_ok(
ws,
cache.path(),
&[
"create",
"--mem",
mem,
"--title",
"First Entry",
"--type",
"note",
"--section",
"body=Something worth writing down.",
"--metadata",
"observer=A. Ranger",
],
);
let archive = ws.join(format!("{mem}.mem"));
run_ok(
ws,
cache.path(),
&[
"export",
"--format",
"mem",
"--mem",
mem,
"-o",
archive.to_str().unwrap(),
],
);
archives.push(archive);
}
for order in [[0usize, 1usize], [1, 0]] {
let receiver = TempDir::new().unwrap();
fresh_receiver(receiver.path(), cache.path());
for i in order {
run_ok(
receiver.path(),
cache.path(),
&["install", archives[i].to_str().unwrap()],
);
}
for mem in ["field-log", "tide-log"] {
run_ok(
receiver.path(),
cache.path(),
&["--json", "entity", &format!("{mem}--first-entry")],
);
}
let staged = staged_schemas(receiver.path());
assert_eq!(
staged
.iter()
.filter(|s| s.manifest.name == "fieldnotes")
.count(),
1,
"two mems sharing a schema stage it once"
);
}
let receiver = TempDir::new().unwrap();
fresh_receiver(receiver.path(), cache.path());
run_ok(
receiver.path(),
cache.path(),
&["install", archives[0].to_str().unwrap()],
);
let out = memstead()
.current_dir(receiver.path())
.env("MEMSTEAD_MEM_CACHE", cache.path())
.args(["--json", "install", archives[0].to_str().unwrap()])
.assert()
.success()
.get_output()
.stdout
.clone();
let payload: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(payload["mount"], "already_registered", "got: {payload}");
assert_eq!(payload["copied_to_cache"], false, "got: {payload}");
}