#![allow(clippy::unwrap_used)]
use assert_cmd::Command;
use predicates::prelude::*;
use std::path::Path;
fn schema_cmd() -> Command {
Command::cargo_bin("schema").unwrap()
}
fn init_repo(dir: &Path) {
schema_cmd()
.args(["init", dir.to_str().unwrap()])
.current_dir(dir)
.assert()
.success();
}
fn write_schema(dir: &Path, name: &str, vertices: &[(&str, &str)]) {
let mut verts = serde_json::Map::new();
for (id, kind) in vertices {
verts.insert(
id.to_string(),
serde_json::json!({
"id": id, "kind": kind, "nsid": null
}),
);
}
let schema = serde_json::json!({
"protocol": "test",
"vertices": verts,
"edges": [],
"hyper_edges": {},
"constraints": {},
"required": {},
"nsids": {},
"variants": {},
"orderings": [],
"recursion_points": {},
"spans": {},
"usage_modes": [],
"nominal": {},
"outgoing": {},
"incoming": {},
"between": []
});
let path = dir.join(name);
std::fs::write(&path, serde_json::to_string_pretty(&schema).unwrap()).unwrap();
}
fn add_and_commit(dir: &Path, schema_file: &str, message: &str) {
schema_cmd()
.args(["add", schema_file])
.current_dir(dir)
.assert()
.success();
schema_cmd()
.args(["commit", "-m", message])
.current_dir(dir)
.assert()
.success();
}
fn stdout_of(cmd: &mut Command) -> String {
let output = cmd.output().unwrap();
String::from_utf8(output.stdout).unwrap()
}
#[test]
fn cli_init_success() {
let tmp = tempfile::tempdir().unwrap();
schema_cmd()
.args(["init", tmp.path().to_str().unwrap()])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Initialized"));
}
#[test]
fn cli_init_with_initial_branch() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "seed");
schema_cmd()
.args(["branch", "main", "-m", "develop"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["status"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("develop"));
}
#[test]
fn cli_status_no_commits() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
schema_cmd()
.args(["status"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("no commits yet"));
}
#[test]
fn cli_status_short() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
schema_cmd()
.args(["status", "-s", "-b"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("## main"));
}
#[test]
fn cli_status_porcelain() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
schema_cmd()
.args(["status", "--porcelain"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("## main"));
}
#[test]
fn cli_add_commit_log() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v1.json", "initial schema");
schema_cmd()
.args(["log"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(
predicate::str::contains("initial schema")
.and(predicate::str::contains("Author:"))
.and(predicate::str::contains("Date:")),
);
}
#[test]
fn cli_add_dry_run() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
schema_cmd()
.args(["add", "--dry-run", "v1.json"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Would stage"));
schema_cmd()
.args(["commit", "-m", "should fail"])
.current_dir(tmp.path())
.assert()
.failure();
}
#[test]
fn cli_commit_amend() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "original message");
schema_cmd()
.args(["commit", "--amend", "-m", "amended message"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("amended"));
let log_out = stdout_of(
schema_cmd()
.args(["log", "--oneline"])
.current_dir(tmp.path()),
);
assert!(log_out.contains("amended message"));
assert_eq!(log_out.trim().lines().count(), 1);
}
#[test]
fn cli_commit_no_staged_fails() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
schema_cmd()
.args(["commit", "-m", "nothing staged"])
.current_dir(tmp.path())
.assert()
.failure();
}
#[test]
fn cli_add_unchanged_fails() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "first");
schema_cmd()
.args(["add", "v1.json"])
.current_dir(tmp.path())
.assert()
.failure();
}
#[test]
fn cli_log_default() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "first commit");
schema_cmd()
.args(["log"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(
predicate::str::contains("Author:")
.and(predicate::str::contains("Date:"))
.and(predicate::str::contains("first commit")),
);
}
#[test]
fn cli_log_oneline() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "one-liner");
let out = stdout_of(
schema_cmd()
.args(["log", "--oneline"])
.current_dir(tmp.path()),
);
let lines: Vec<&str> = out.trim().lines().collect();
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("one-liner"));
}
#[test]
fn cli_log_limit() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "first");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "second");
let out = stdout_of(
schema_cmd()
.args(["log", "--oneline", "-n", "1"])
.current_dir(tmp.path()),
);
let lines: Vec<&str> = out.trim().lines().collect();
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("second"));
}
#[test]
fn cli_log_format() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "formatted");
let out = stdout_of(
schema_cmd()
.args(["log", "--format", "%h %s"])
.current_dir(tmp.path()),
);
let line = out.trim();
assert!(line.contains("formatted"));
let parts: Vec<&str> = line.splitn(2, ' ').collect();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].len(), 7); }
#[test]
fn cli_log_grep() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "first commit");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "second fix");
let out = stdout_of(
schema_cmd()
.args(["log", "--oneline", "--grep", "second"])
.current_dir(tmp.path()),
);
let lines: Vec<&str> = out.trim().lines().collect();
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("second fix"));
}
#[test]
fn cli_diff_two_files() {
let tmp = tempfile::tempdir().unwrap();
write_schema(tmp.path(), "old.json", &[("a", "object")]);
write_schema(tmp.path(), "new.json", &[("a", "object"), ("c", "string")]);
schema_cmd()
.args(["diff", "old.json", "new.json"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("change(s) detected"));
}
#[test]
fn cli_diff_stat() {
let tmp = tempfile::tempdir().unwrap();
write_schema(tmp.path(), "old.json", &[("a", "object")]);
write_schema(tmp.path(), "new.json", &[("a", "object"), ("c", "string")]);
schema_cmd()
.args(["diff", "--stat", "old.json", "new.json"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("addition(s)"));
}
#[test]
fn cli_diff_name_only() {
let tmp = tempfile::tempdir().unwrap();
write_schema(tmp.path(), "old.json", &[("a", "object")]);
write_schema(tmp.path(), "new.json", &[("a", "object"), ("c", "string")]);
schema_cmd()
.args(["diff", "--name-only", "old.json", "new.json"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("c"));
}
#[test]
fn cli_diff_staged() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
schema_cmd()
.args(["add", "v2.json"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["diff", "--staged"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("change(s) detected"));
}
#[test]
fn cli_branch_create_list() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["branch", "feature"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Created branch feature"));
schema_cmd()
.args(["branch"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("feature").and(predicate::str::contains("main")));
}
#[test]
fn cli_branch_delete() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["branch", "feature"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["branch", "-d", "feature"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Deleted branch feature"));
}
#[test]
fn cli_branch_force_delete() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["branch", "feature"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["branch", "-D", "feature"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Deleted branch feature"));
}
#[test]
fn cli_branch_rename() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["branch", "old-name"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["branch", "old-name", "-m", "new-name"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains(
"Renamed branch old-name -> new-name",
));
let out = stdout_of(schema_cmd().args(["branch"]).current_dir(tmp.path()));
assert!(out.contains("new-name"));
assert!(!out.contains("old-name"));
}
#[test]
fn cli_tag_annotated() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["tag", "-a", "v1.0", "-m", "release"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Tagged").and(predicate::str::contains("v1.0")));
schema_cmd()
.args(["tag"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("v1.0"));
}
#[test]
fn cli_checkout_branch() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["branch", "feature"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["checkout", "feature"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Switched to branch 'feature'"));
schema_cmd()
.args(["status"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("feature"));
}
#[test]
fn cli_checkout_create() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["checkout", "-b", "new-feature"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains(
"Switched to a new branch 'new-feature'",
));
schema_cmd()
.args(["status"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("new-feature"));
}
#[test]
fn cli_checkout_detached() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "for detach");
let log_out = stdout_of(
schema_cmd()
.args(["log", "--format", "%H"])
.current_dir(tmp.path()),
);
let full_hash = log_out.trim();
schema_cmd()
.args(["checkout", "--detach", full_hash])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("HEAD is now at"));
schema_cmd()
.args(["status"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("detached"));
}
#[test]
fn cli_merge_fast_forward() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial on main");
schema_cmd()
.args(["checkout", "-b", "feature"])
.current_dir(tmp.path())
.assert()
.success();
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "feature work");
schema_cmd()
.args(["checkout", "main"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["merge", "feature"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Merge successful"));
}
#[test]
fn cli_merge_ff_only_fails() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["checkout", "-b", "feature"])
.current_dir(tmp.path())
.assert()
.success();
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "feature");
schema_cmd()
.args(["checkout", "main"])
.current_dir(tmp.path())
.assert()
.success();
write_schema(tmp.path(), "v3.json", &[("a", "object"), ("c", "integer")]);
add_and_commit(tmp.path(), "v3.json", "main diverge");
schema_cmd()
.args(["merge", "--ff-only", "feature"])
.current_dir(tmp.path())
.assert()
.failure();
}
#[test]
fn cli_stash_push_list_pop() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
schema_cmd()
.args(["add", "v2.json"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["stash", "push", "-m", "wip changes"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Saved working state"));
schema_cmd()
.args(["stash", "list"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("stash@{0}"));
schema_cmd()
.args(["stash", "pop"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Restored stash"));
}
#[test]
fn cli_stash_apply() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
schema_cmd()
.args(["add", "v2.json"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["stash", "push", "-m", "save"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["stash", "apply"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Applied stash@{0}"));
schema_cmd()
.args(["stash", "list"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("stash@{0}"));
}
#[test]
fn cli_stash_clear() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
schema_cmd()
.args(["add", "v2.json"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["stash", "push", "-m", "will clear"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["stash", "clear"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Cleared all stash entries"));
}
#[test]
fn cli_gc_dry_run() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["gc", "--dry-run"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(
predicate::str::contains("Reachable objects:")
.and(predicate::str::contains("Would delete:")),
);
}
#[test]
fn cli_blame_vertex() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v1.json", "added vertices");
schema_cmd()
.args(["blame", "--element-type", "vertex", "a"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("added vertices").and(predicate::str::contains("Date:")));
}
#[test]
fn cli_remote_stubs_complete() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
schema_cmd()
.args(["remote", "list"])
.current_dir(tmp.path())
.assert()
.failure()
.stderr(predicate::str::contains("not yet implemented"));
}
#[test]
fn cli_push_requires_url() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
schema_cmd()
.args(["push"])
.current_dir(tmp.path())
.assert()
.failure()
.stderr(predicate::str::contains("remote URL required"));
}
#[test]
fn cli_clone_requires_panproto_url() {
let tmp = tempfile::tempdir().unwrap();
schema_cmd()
.args(["clone", "https://example.com/repo"])
.current_dir(tmp.path())
.assert()
.failure()
.stderr(predicate::str::contains("panproto://"));
}
fn write_protocol_schema(dir: &Path, name: &str, protocol: &str, vertices: &[(&str, &str)]) {
let mut verts = serde_json::Map::new();
for (id, kind) in vertices {
verts.insert(
id.to_string(),
serde_json::json!({
"id": id, "kind": kind, "nsid": null
}),
);
}
let schema = serde_json::json!({
"protocol": protocol,
"vertices": verts,
"edges": [],
"hyper_edges": {},
"constraints": {},
"required": {},
"nsids": {},
"variants": {},
"orderings": [],
"recursion_points": {},
"spans": {},
"usage_modes": [],
"nominal": {},
"outgoing": {},
"incoming": {},
"between": []
});
let path = dir.join(name);
std::fs::write(&path, serde_json::to_string_pretty(&schema).unwrap()).unwrap();
}
fn write_migration(dir: &Path, name: &str, vertex_map: &[(&str, &str)]) {
let vmap: serde_json::Map<String, serde_json::Value> = vertex_map
.iter()
.map(|(k, v)| (k.to_string(), serde_json::json!(v)))
.collect();
let mig = serde_json::json!({
"vertex_map": vmap,
"edge_map": [],
"hyper_edge_map": {},
"label_map": [],
"resolver": [],
"hyper_resolver": []
});
let path = dir.join(name);
std::fs::write(&path, serde_json::to_string_pretty(&mig).unwrap()).unwrap();
}
#[test]
fn cli_validate_valid_schema() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(
tmp.path(),
"schema.json",
"atproto",
&[("root", "object"), ("root.name", "string")],
);
schema_cmd()
.args(["validate", "--protocol", "atproto", "schema.json"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Schema is valid."));
}
#[test]
fn cli_validate_invalid_schema() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(tmp.path(), "bad.json", "atproto", &[("root", "bogus_kind")]);
schema_cmd()
.args(["validate", "--protocol", "atproto", "bad.json"])
.current_dir(tmp.path())
.assert()
.failure()
.stdout(predicate::str::contains("error"));
}
#[test]
fn cli_check_valid_migration() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(
tmp.path(),
"src.json",
"atproto",
&[("a", "object"), ("b", "string")],
);
write_protocol_schema(
tmp.path(),
"tgt.json",
"atproto",
&[("a", "object"), ("b", "string"), ("c", "integer")],
);
write_migration(tmp.path(), "mig.json", &[("a", "a"), ("b", "b")]);
schema_cmd()
.args([
"check",
"--src",
"src.json",
"--tgt",
"tgt.json",
"--mapping",
"mig.json",
])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Migration is valid."));
}
#[test]
fn cli_lift_identity() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(tmp.path(), "src.json", "atproto", &[("root", "string")]);
write_protocol_schema(tmp.path(), "tgt.json", "atproto", &[("root", "string")]);
write_migration(tmp.path(), "mig.json", &[("root", "root")]);
let record = serde_json::json!("Alice");
std::fs::write(
tmp.path().join("record.json"),
serde_json::to_string_pretty(&record).unwrap(),
)
.unwrap();
let output = schema_cmd()
.args([
"lift",
"--migration",
"mig.json",
"--src-schema",
"src.json",
"--tgt-schema",
"tgt.json",
"record.json",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
output.status.success(),
"lift command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
stdout.contains("Alice"),
"expected 'Alice' in output: {stdout}"
);
}
#[test]
fn cli_diff_name_status() {
let tmp = tempfile::tempdir().unwrap();
write_schema(tmp.path(), "old.json", &[("a", "object")]);
write_schema(tmp.path(), "new.json", &[("a", "object"), ("c", "string")]);
let output = schema_cmd()
.args(["diff", "--name-status", "old.json", "new.json"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(output.status.success());
assert!(
stdout.contains("A\t"),
"expected 'A\\t' marker in name-status output: {stdout}"
);
}
#[test]
fn cli_show_commit() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial commit");
schema_cmd()
.args(["show", "HEAD"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(
predicate::str::contains("commit")
.and(predicate::str::contains("Schema:"))
.and(predicate::str::contains("Author:")),
);
}
#[test]
fn cli_show_with_stat() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "first");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "second");
schema_cmd()
.args(["show", "HEAD", "--stat"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("addition"));
}
#[test]
fn cli_rebase_success() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "seed on main");
schema_cmd()
.args(["checkout", "-b", "feature"])
.current_dir(tmp.path())
.assert()
.success();
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "feature work");
schema_cmd()
.args(["checkout", "main"])
.current_dir(tmp.path())
.assert()
.success();
write_schema(tmp.path(), "v3.json", &[("a", "object"), ("c", "integer")]);
add_and_commit(tmp.path(), "v3.json", "main advance");
schema_cmd()
.args(["checkout", "feature"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["rebase", "main"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Rebased onto"));
}
#[test]
fn cli_cherry_pick_success() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial on main");
schema_cmd()
.args(["checkout", "-b", "feature"])
.current_dir(tmp.path())
.assert()
.success();
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "feature commit");
let log_out = stdout_of(
schema_cmd()
.args(["log", "--format", "%H"])
.current_dir(tmp.path()),
);
let feature_hash = log_out.trim().lines().next().unwrap().trim();
schema_cmd()
.args(["checkout", "main"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["cherry-pick", feature_hash])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Cherry-picked"));
}
#[test]
fn cli_cherry_pick_with_x() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["checkout", "-b", "feature"])
.current_dir(tmp.path())
.assert()
.success();
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "feature work");
let log_out = stdout_of(
schema_cmd()
.args(["log", "--format", "%H"])
.current_dir(tmp.path()),
);
let feature_hash = log_out.trim().lines().next().unwrap().trim();
schema_cmd()
.args(["checkout", "main"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["cherry-pick", "-x", feature_hash])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Cherry-picked"));
}
#[test]
fn cli_reset_soft_output() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "first");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "second");
let log_out = stdout_of(
schema_cmd()
.args(["log", "--format", "%H"])
.current_dir(tmp.path()),
);
let first_hash = log_out.trim().lines().last().unwrap().trim();
schema_cmd()
.args(["reset", "--soft", first_hash])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("HEAD is now at").and(predicate::str::contains("soft")));
}
#[test]
fn cli_reset_hard_output() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "first");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "second");
let log_out = stdout_of(
schema_cmd()
.args(["log", "--format", "%H"])
.current_dir(tmp.path()),
);
let first_hash = log_out.trim().lines().last().unwrap().trim();
schema_cmd()
.args(["reset", "--hard", first_hash])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("HEAD is now at").and(predicate::str::contains("hard")));
}
#[test]
fn cli_bisect_output() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "commit one");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "commit two");
write_schema(
tmp.path(),
"v3.json",
&[("a", "object"), ("b", "string"), ("c", "integer")],
);
add_and_commit(tmp.path(), "v3.json", "commit three");
let log_out = stdout_of(
schema_cmd()
.args(["log", "--format", "%H"])
.current_dir(tmp.path()),
);
let hashes: Vec<&str> = log_out.trim().lines().map(str::trim).collect();
let last = hashes.first().unwrap();
let first = hashes.last().unwrap();
let output = schema_cmd()
.args(["bisect", first, last])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
output.status.success(),
"bisect failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
stdout.contains("Breaking commit") || stdout.contains("Test commit"),
"expected bisect output, got: {stdout}"
);
}
#[test]
fn cli_reflog_shows_history() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "first");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "second");
let output = schema_cmd()
.args(["reflog", "HEAD"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(output.status.success());
assert!(
stdout.contains("HEAD@{0}") || stdout.contains("->"),
"expected reflog entries, got: {stdout}"
);
}
#[test]
fn cli_reflog_with_all() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["reflog", "--all"])
.current_dir(tmp.path())
.assert()
.success();
}
#[test]
fn cli_log_author_filter() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
schema_cmd()
.args(["add", "v1.json"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["commit", "-m", "by alice", "--author", "alice"])
.current_dir(tmp.path())
.assert()
.success();
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
schema_cmd()
.args(["add", "v2.json"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["commit", "-m", "by bob", "--author", "bob"])
.current_dir(tmp.path())
.assert()
.success();
let out = stdout_of(
schema_cmd()
.args(["log", "--oneline", "--author", "alice"])
.current_dir(tmp.path()),
);
assert!(out.contains("by alice"), "expected alice's commit: {out}");
assert!(
!out.contains("by bob"),
"should not contain bob's commit: {out}"
);
}
#[test]
fn cli_branch_verbose() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["branch", "feature"])
.current_dir(tmp.path())
.assert()
.success();
let out = stdout_of(schema_cmd().args(["branch", "-v"]).current_dir(tmp.path()));
assert!(
out.contains("main"),
"expected 'main' in branch list: {out}"
);
assert!(
out.contains("feature"),
"expected 'feature' in branch list: {out}"
);
assert!(
out.contains("initial"),
"expected commit message in verbose output: {out}"
);
}
#[test]
fn cli_merge_no_commit() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial on main");
schema_cmd()
.args(["checkout", "-b", "feature"])
.current_dir(tmp.path())
.assert()
.success();
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
add_and_commit(tmp.path(), "v2.json", "feature work");
schema_cmd()
.args(["checkout", "main"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["merge", "--no-commit", "feature"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Merge successful"));
}
#[test]
fn cli_merge_abort() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
schema_cmd()
.args(["merge", "--abort"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Merge aborted"));
}
#[test]
fn cli_commit_allow_empty() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
let output = schema_cmd()
.args(["commit", "--allow-empty", "-m", "empty commit"])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success()
|| stderr.contains("failed to commit")
|| stderr.contains("nothing"),
"expected graceful behavior, got stderr: {stderr}"
);
}
#[test]
fn cli_stash_show() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
schema_cmd()
.args(["add", "v2.json"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["stash", "push", "-m", "wip"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["stash", "show"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("stash@{0}"));
}
#[test]
fn cli_stash_drop() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
write_schema(tmp.path(), "v1.json", &[("a", "object")]);
add_and_commit(tmp.path(), "v1.json", "initial");
write_schema(tmp.path(), "v2.json", &[("a", "object"), ("b", "string")]);
schema_cmd()
.args(["add", "v2.json"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["stash", "push", "-m", "will drop"])
.current_dir(tmp.path())
.assert()
.success();
schema_cmd()
.args(["stash", "drop"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Dropped stash@{0}"));
}
#[test]
fn cli_pull_fetch_require_url() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
schema_cmd()
.args(["pull"])
.current_dir(tmp.path())
.assert()
.failure()
.stderr(predicate::str::contains("remote URL required"));
schema_cmd()
.args(["fetch"])
.current_dir(tmp.path())
.assert()
.failure()
.stderr(predicate::str::contains("remote URL required"));
}
#[test]
fn cli_lift_string_identity() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(tmp.path(), "src.json", "atproto", &[("root", "string")]);
write_protocol_schema(tmp.path(), "tgt.json", "atproto", &[("root", "string")]);
write_migration(tmp.path(), "mig.json", &[("root", "root")]);
let record = serde_json::json!("Hello, world!");
std::fs::write(
tmp.path().join("record.json"),
serde_json::to_string_pretty(&record).unwrap(),
)
.unwrap();
schema_cmd()
.args([
"lift",
"--migration",
"mig.json",
"--src-schema",
"src.json",
"--tgt-schema",
"tgt.json",
"record.json",
])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("Hello, world!"));
}
#[test]
fn cli_lift_integer_identity() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(tmp.path(), "src.json", "atproto", &[("root", "integer")]);
write_protocol_schema(tmp.path(), "tgt.json", "atproto", &[("root", "integer")]);
write_migration(tmp.path(), "mig.json", &[("root", "root")]);
std::fs::write(tmp.path().join("record.json"), "42").unwrap();
schema_cmd()
.args([
"lift",
"--migration",
"mig.json",
"--src-schema",
"src.json",
"--tgt-schema",
"tgt.json",
"record.json",
])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("42"));
}
#[test]
fn cli_lift_verbose() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(tmp.path(), "src.json", "atproto", &[("root", "string")]);
write_protocol_schema(tmp.path(), "tgt.json", "atproto", &[("root", "string")]);
write_migration(tmp.path(), "mig.json", &[("root", "root")]);
std::fs::write(
tmp.path().join("record.json"),
serde_json::to_string_pretty(&serde_json::json!("Alice")).unwrap(),
)
.unwrap();
let output = schema_cmd()
.args([
"--verbose",
"lift",
"--migration",
"mig.json",
"--src-schema",
"src.json",
"--tgt-schema",
"tgt.json",
"record.json",
])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(output.status.success(), "verbose lift should succeed");
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("vertex mappings"),
"stderr should mention vertex mappings, got: {stderr}"
);
assert!(
stderr.contains("nodes") && stderr.contains("arcs"),
"stderr should mention node and arc counts, got: {stderr}"
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("Alice"));
}
#[test]
fn cli_lift_bad_migration_fails() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(tmp.path(), "src.json", "atproto", &[("root", "string")]);
write_protocol_schema(tmp.path(), "tgt.json", "atproto", &[("root", "string")]);
write_migration(tmp.path(), "mig.json", &[("root", "nonexistent")]);
std::fs::write(tmp.path().join("record.json"), "\"test\"").unwrap();
schema_cmd()
.args([
"lift",
"--migration",
"mig.json",
"--src-schema",
"src.json",
"--tgt-schema",
"tgt.json",
"record.json",
])
.current_dir(tmp.path())
.assert()
.failure();
}
use panproto_core::gat::Name;
use panproto_core::inst;
use panproto_core::mig;
use panproto_core::schema::{Edge, Schema, Vertex};
use smallvec::SmallVec;
use std::collections::HashMap;
fn make_lift_schema(
vertices: &[(&str, &str)],
edges: &[(&str, &str, &str, &str)], ) -> Schema {
let mut vert_map = HashMap::new();
for (id, kind) in vertices {
vert_map.insert(
Name::from(*id),
Vertex {
id: Name::from(*id),
kind: Name::from(*kind),
nsid: None,
},
);
}
let mut edge_map = HashMap::new();
let mut outgoing: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
let mut incoming: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
let mut between: HashMap<(Name, Name), SmallVec<Edge, 2>> = HashMap::new();
for (src, tgt, kind, name) in edges {
let edge = Edge {
src: (*src).into(),
tgt: (*tgt).into(),
kind: (*kind).into(),
name: Some((*name).into()),
};
edge_map.insert(edge.clone(), Name::from(*kind));
outgoing
.entry(Name::from(*src))
.or_default()
.push(edge.clone());
incoming
.entry(Name::from(*tgt))
.or_default()
.push(edge.clone());
between
.entry((Name::from(*src), Name::from(*tgt)))
.or_default()
.push(edge);
}
Schema {
protocol: "test".into(),
vertices: vert_map,
edges: edge_map,
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing,
incoming,
between,
}
}
fn make_migration(
vertex_map: &[(&str, &str)],
edge_map_entries: &[(Edge, Edge)],
) -> mig::Migration {
mig::Migration {
vertex_map: vertex_map
.iter()
.map(|(k, v)| (Name::from(*k), Name::from(*v)))
.collect(),
edge_map: edge_map_entries.iter().cloned().collect(),
hyper_edge_map: HashMap::new(),
label_map: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
expr_resolvers: HashMap::new(),
}
}
#[test]
fn lift_api_add_field() {
let src_schema = make_lift_schema(
&[("root", "object"), ("root.name", "string")],
&[("root", "root.name", "prop", "name")],
);
let tgt_schema = make_lift_schema(
&[
("root", "object"),
("root.name", "string"),
("root.email", "string"),
],
&[
("root", "root.name", "prop", "name"),
("root", "root.email", "prop", "email"),
],
);
let name_edge = Edge {
src: "root".into(),
tgt: "root.name".into(),
kind: "prop".into(),
name: Some("name".into()),
};
let migration = make_migration(
&[("root", "root"), ("root.name", "root.name")],
&[(name_edge.clone(), name_edge)],
);
let compiled = mig::compile(&src_schema, &tgt_schema, &migration).unwrap();
let record = serde_json::json!({"name": "Alice"});
let instance = inst::parse_json(&src_schema, "root", &record).unwrap();
let lifted = mig::lift_wtype(&compiled, &src_schema, &tgt_schema, &instance).unwrap();
let output = inst::to_json(&tgt_schema, &lifted);
assert_eq!(output["name"], "Alice", "name should be preserved");
assert!(
output.get("email").is_none() || output["email"].is_null(),
"email should be absent or null in lifted output"
);
}
#[test]
fn lift_api_drop_field() {
let src_schema = make_lift_schema(
&[
("root", "object"),
("root.name", "string"),
("root.age", "integer"),
],
&[
("root", "root.name", "prop", "name"),
("root", "root.age", "prop", "age"),
],
);
let tgt_schema = make_lift_schema(
&[("root", "object"), ("root.name", "string")],
&[("root", "root.name", "prop", "name")],
);
let name_edge = Edge {
src: "root".into(),
tgt: "root.name".into(),
kind: "prop".into(),
name: Some("name".into()),
};
let migration = make_migration(
&[("root", "root"), ("root.name", "root.name")],
&[(name_edge.clone(), name_edge)],
);
let compiled = mig::compile(&src_schema, &tgt_schema, &migration).unwrap();
let record = serde_json::json!({"name": "Bob", "age": 30});
let instance = inst::parse_json(&src_schema, "root", &record).unwrap();
let lifted = mig::lift_wtype(&compiled, &src_schema, &tgt_schema, &instance).unwrap();
let output = inst::to_json(&tgt_schema, &lifted);
assert_eq!(output["name"], "Bob", "name should be preserved");
assert!(
output.get("age").is_none(),
"age should be absent from lifted output, got: {output}"
);
}
#[test]
fn lift_api_identity_all_fields_survive() {
let schema = make_lift_schema(
&[
("root", "object"),
("root.name", "string"),
("root.email", "string"),
],
&[
("root", "root.name", "prop", "name"),
("root", "root.email", "prop", "email"),
],
);
let name_edge = Edge {
src: "root".into(),
tgt: "root.name".into(),
kind: "prop".into(),
name: Some("name".into()),
};
let email_edge = Edge {
src: "root".into(),
tgt: "root.email".into(),
kind: "prop".into(),
name: Some("email".into()),
};
let migration = make_migration(
&[
("root", "root"),
("root.name", "root.name"),
("root.email", "root.email"),
],
&[
(name_edge.clone(), name_edge),
(email_edge.clone(), email_edge),
],
);
let compiled = mig::compile(&schema, &schema, &migration).unwrap();
let record = serde_json::json!({"name": "Eve", "email": "eve@example.com"});
let instance = inst::parse_json(&schema, "root", &record).unwrap();
let lifted = mig::lift_wtype(&compiled, &schema, &schema, &instance).unwrap();
let output = inst::to_json(&schema, &lifted);
assert_eq!(output["name"], "Eve");
assert_eq!(output["email"], "eve@example.com");
}
#[test]
fn lift_api_multi_field_projection() {
let src_schema = make_lift_schema(
&[
("root", "object"),
("root.name", "string"),
("root.email", "string"),
("root.age", "integer"),
],
&[
("root", "root.name", "prop", "name"),
("root", "root.email", "prop", "email"),
("root", "root.age", "prop", "age"),
],
);
let tgt_schema = make_lift_schema(
&[
("root", "object"),
("root.name", "string"),
("root.email", "string"),
],
&[
("root", "root.name", "prop", "name"),
("root", "root.email", "prop", "email"),
],
);
let name_edge = Edge {
src: "root".into(),
tgt: "root.name".into(),
kind: "prop".into(),
name: Some("name".into()),
};
let email_edge = Edge {
src: "root".into(),
tgt: "root.email".into(),
kind: "prop".into(),
name: Some("email".into()),
};
let migration = make_migration(
&[
("root", "root"),
("root.name", "root.name"),
("root.email", "root.email"),
],
&[
(name_edge.clone(), name_edge),
(email_edge.clone(), email_edge),
],
);
let compiled = mig::compile(&src_schema, &tgt_schema, &migration).unwrap();
let record = serde_json::json!({"name": "Dana", "email": "dana@example.com", "age": 25});
let instance = inst::parse_json(&src_schema, "root", &record).unwrap();
let lifted = mig::lift_wtype(&compiled, &src_schema, &tgt_schema, &instance).unwrap();
let output = inst::to_json(&tgt_schema, &lifted);
assert_eq!(output["name"], "Dana");
assert_eq!(output["email"], "dana@example.com");
assert!(
output.get("age").is_none(),
"age should be absent from lifted output, got: {output}"
);
}
#[test]
fn lift_api_preserves_value_types() {
let src_schema = make_lift_schema(
&[
("root", "object"),
("root.active", "boolean"),
("root.name", "string"),
],
&[
("root", "root.active", "prop", "active"),
("root", "root.name", "prop", "name"),
],
);
let tgt_schema = make_lift_schema(
&[("root", "object"), ("root.active", "boolean")],
&[("root", "root.active", "prop", "active")],
);
let active_edge = Edge {
src: "root".into(),
tgt: "root.active".into(),
kind: "prop".into(),
name: Some("active".into()),
};
let migration = make_migration(
&[("root", "root"), ("root.active", "root.active")],
&[(active_edge.clone(), active_edge)],
);
let compiled = mig::compile(&src_schema, &tgt_schema, &migration).unwrap();
let record = serde_json::json!({"active": true, "name": "test"});
let instance = inst::parse_json(&src_schema, "root", &record).unwrap();
let lifted = mig::lift_wtype(&compiled, &src_schema, &tgt_schema, &instance).unwrap();
let output = inst::to_json(&tgt_schema, &lifted);
assert_eq!(output["active"], true, "boolean value should be preserved");
assert!(
output.get("name").is_none(),
"dropped field should be absent, got: {output}"
);
}
#[test]
fn cli_lens_generate_json_topn_requirements_single_document() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(tmp.path(), "src.json", "atproto", &[("root", "string")]);
write_protocol_schema(tmp.path(), "tgt.json", "atproto", &[("root", "string")]);
let output = schema_cmd()
.args([
"lens",
"generate",
"src.json",
"tgt.json",
"--protocol",
"atproto",
"--json",
"--top-n",
"2",
"--requirements",
])
.current_dir(tmp.path())
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!("stdout was not a single JSON document: {e}\n---\n{stdout}\n---")
});
assert!(parsed.is_object(), "root must be an object, got {parsed}");
assert!(
parsed.get("candidates").is_some(),
"root must carry a `candidates` section; got keys {:?}",
parsed.as_object().map(|o| o.keys().collect::<Vec<_>>()),
);
assert!(
parsed.get("requirements").is_some(),
"root must carry a `requirements` section; got keys {:?}",
parsed.as_object().map(|o| o.keys().collect::<Vec<_>>()),
);
}
#[test]
fn cli_lens_generate_json_fuse_empty_chain_does_not_leak_partial_json() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(tmp.path(), "src.json", "atproto", &[("root", "string")]);
write_protocol_schema(tmp.path(), "tgt.json", "atproto", &[("root", "string")]);
let output = schema_cmd()
.args([
"lens",
"generate",
"src.json",
"tgt.json",
"--protocol",
"atproto",
"--json",
"--fuse",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
if !stdout.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&stdout).unwrap_or_else(|e| {
panic!("stdout must be empty or a single JSON document: {e}\n---\n{stdout}\n---")
});
}
}
#[test]
fn cli_lens_generate_chain_topn_single_document() {
let tmp = tempfile::tempdir().unwrap();
write_protocol_schema(tmp.path(), "src.json", "atproto", &[("root", "string")]);
write_protocol_schema(tmp.path(), "tgt.json", "atproto", &[("root", "string")]);
let output = schema_cmd()
.args([
"lens",
"generate",
"src.json",
"tgt.json",
"--protocol",
"atproto",
"--chain",
"--top-n",
"3",
])
.current_dir(tmp.path())
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!("stdout was not a single JSON document: {e}\n---\n{stdout}\n---")
});
assert!(
parsed.get("candidates").is_some(),
"chain-mode root with --top-n must embed `candidates`; got {parsed}"
);
}
fn write_lying_iso_theory(dir: &Path, name: &str) {
let doc = serde_json::json!({
"id": "test.lying_iso",
"description": "fixture with a lying coercion",
"theory": "LyingIso",
"sorts": [
{ "name": "Str", "kind": { "type": "val", "value_kind": "string" } }
],
"ops": [
{ "name": "upper", "input": "Str", "output": "Str" }
],
"equations": [],
"directed_equations": [
{
"name": "lying_upper_iso",
"lhs": "upper(x)",
"rhs": "x",
"impl_expr": "upper(x)",
"inverse": "x",
"source_kind": "string",
"target_kind": "string",
"coercion_class": "iso"
}
],
"policies": []
});
let path = dir.join(name);
std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
}
fn write_honest_identity_theory(dir: &Path, name: &str) {
let doc = serde_json::json!({
"id": "test.honest_iso",
"description": "fixture with honest identity coercion",
"theory": "HonestIso",
"sorts": [
{ "name": "Str", "kind": { "type": "val", "value_kind": "string" } }
],
"ops": [],
"equations": [],
"directed_equations": [
{
"name": "identity_iso",
"lhs": "x",
"rhs": "x",
"impl_expr": "x",
"inverse": "x",
"source_kind": "string",
"target_kind": "string",
"coercion_class": "iso"
}
],
"policies": []
});
let path = dir.join(name);
std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
}
#[test]
fn theory_check_coercion_laws_fails_on_lying_iso() {
let tmp = tempfile::tempdir().unwrap();
write_lying_iso_theory(tmp.path(), "lying.json");
let output = schema_cmd()
.args(["theory", "check-coercion-laws", "lying.json"])
.current_dir(tmp.path())
.assert()
.failure()
.get_output()
.clone();
let stderr = String::from_utf8(output.stderr).unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let combined = format!("{stdout}\n{stderr}");
assert!(
combined.contains("lying_upper_iso") || combined.contains("violation"),
"expected violation output, got:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}",
);
}
#[test]
fn theory_check_coercion_laws_passes_on_honest_iso() {
let tmp = tempfile::tempdir().unwrap();
write_honest_identity_theory(tmp.path(), "honest.json");
schema_cmd()
.args(["theory", "check-coercion-laws", "honest.json"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("clean"));
}
#[test]
fn theory_check_coercion_laws_json_output_is_valid() {
let tmp = tempfile::tempdir().unwrap();
write_honest_identity_theory(tmp.path(), "honest.json");
let output = schema_cmd()
.args(["theory", "check-coercion-laws", "honest.json", "--json"])
.current_dir(tmp.path())
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!("stdout was not a single JSON document: {e}\n---\n{stdout}\n---")
});
assert_eq!(parsed["clean"], serde_json::Value::Bool(true));
assert_eq!(parsed["total_violations"], serde_json::json!(0));
}
#[test]
fn theory_check_coercion_laws_json_violations_carry_typed_kind() {
let tmp = tempfile::tempdir().unwrap();
write_lying_iso_theory(tmp.path(), "lying.json");
let output = schema_cmd()
.args(["theory", "check-coercion-laws", "lying.json", "--json"])
.current_dir(tmp.path())
.assert()
.failure()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!("stdout was not a single JSON document: {e}\n---\n{stdout}\n---")
});
assert_eq!(parsed["clean"], serde_json::Value::Bool(false));
let theories = parsed["theories"].as_array().unwrap();
let mut saw_violation = false;
let mut saw_typed_kind = false;
for theory in theories {
let eqs = theory["equations"].as_array().unwrap();
for eq in eqs {
let vs = eq["violations"].as_array().unwrap();
for v in vs {
saw_violation = true;
let kind = v["kind"].as_str().unwrap_or_else(|| {
panic!(
"violation must carry a typed `kind` string field, \
got {v}"
)
});
assert!(
matches!(
kind,
"Backward"
| "Forward"
| "NonDeterministic"
| "MissingInverse"
| "ForwardEvalError"
| "InverseEvalError"
| "UnknownClass"
),
"unexpected violation kind {kind:?} in {v}"
);
if kind == "Backward" || kind == "Forward" {
saw_typed_kind = true;
}
}
}
}
assert!(saw_violation, "expected at least one violation in {parsed}");
assert!(
saw_typed_kind,
"expected at least one Backward or Forward kind in {parsed}"
);
}
fn write_two_theory_bundle(dir: &Path, name: &str) {
let doc = serde_json::json!({
"id": "test.two_theory_bundle",
"description": "two-theory bundle for determinism regression test",
"bundle": "Pair",
"theories": [
{
"theory": "BetaTheory",
"sorts": [
{ "name": "Str", "kind": { "type": "val", "value_kind": "string" } }
],
"ops": [],
"equations": [],
"directed_equations": [],
"policies": []
},
{
"theory": "AlphaTheory",
"sorts": [
{ "name": "Str", "kind": { "type": "val", "value_kind": "string" } }
],
"ops": [],
"equations": [],
"directed_equations": [],
"policies": []
}
]
});
let path = dir.join(name);
std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
}
fn write_theory_binding_alternate_var(dir: &Path, name: &str) {
let doc = serde_json::json!({
"id": "test.alternate_var",
"description": "fixture whose equation binds `v`",
"theory": "AltVar",
"sorts": [
{ "name": "Str", "kind": { "type": "val", "value_kind": "string" } }
],
"ops": [],
"equations": [],
"directed_equations": [
{
"name": "alt_var_iso",
"lhs": "v",
"rhs": "v",
"impl_expr": "v",
"inverse": "v",
"source_kind": "string",
"target_kind": "string",
"coercion_class": "iso"
}
],
"policies": []
});
let path = dir.join(name);
std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
}
#[test]
fn theory_check_coercion_laws_emits_var_name_hint() {
let tmp = tempfile::tempdir().unwrap();
write_theory_binding_alternate_var(tmp.path(), "alt.json");
let output = schema_cmd()
.args(["theory", "check-coercion-laws", "alt.json"])
.current_dir(tmp.path())
.assert()
.failure()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("hint:") && stdout.contains("--var-name v"),
"expected unbound-variable hint mentioning `--var-name v`; got stdout:\n{stdout}",
);
}
#[test]
fn theory_check_coercion_laws_accepts_var_name_override() {
let tmp = tempfile::tempdir().unwrap();
write_theory_binding_alternate_var(tmp.path(), "alt.json");
schema_cmd()
.args([
"theory",
"check-coercion-laws",
"alt.json",
"--var-name",
"v",
])
.current_dir(tmp.path())
.assert()
.success();
}
#[test]
fn theory_compile_json_output_is_deterministic_across_runs() {
let tmp = tempfile::tempdir().unwrap();
write_two_theory_bundle(tmp.path(), "pair.json");
let run_once = || -> String {
let output = schema_cmd()
.args(["theory", "compile", "pair.json", "--json"])
.current_dir(tmp.path())
.assert()
.success()
.get_output()
.clone();
String::from_utf8(output.stdout).unwrap()
};
let first = run_once();
for _ in 0..5 {
assert_eq!(run_once(), first, "theory compile JSON must be stable");
}
let parsed: serde_json::Value = serde_json::from_str(&first).unwrap();
let theories = parsed["theories"].as_array().unwrap();
let names: Vec<&str> = theories.iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(names, vec!["AlphaTheory", "BetaTheory"]);
}