use std::{path::Path, str};
use serde_json::Value;
use tempfile::TempDir;
use super::{git_hermetic, heddle, heddle_help, heddle_output};
fn git(args: &[&str], dir: &Path) {
git_hermetic(args, dir);
}
fn overlay_with_records() -> TempDir {
let temp = TempDir::new().expect("tempdir");
let dir = temp.path();
git(&["init", "-q", "-b", "main", "."], dir);
std::fs::write(dir.join("f.txt"), "hello\n").expect("write f.txt");
git(&["add", "f.txt"], dir);
git(&["commit", "-qm", "init"], dir);
heddle(&["init"], Some(dir)).expect("heddle init");
heddle(
&[
"context",
"set",
"--path",
"f.txt",
"--kind",
"invariant",
"-m",
"stays lowercase",
],
Some(dir),
)
.expect("context set");
heddle(
&["discuss", "open", "f.txt", "greeting", "why lowercase?"],
Some(dir),
)
.expect("discuss open");
temp
}
fn clone_of(source: &Path) -> TempDir {
let temp = TempDir::new().expect("tempdir");
let dir = temp.path();
git(
&[
"clone",
"-q",
source.to_str().expect("utf8 source path"),
".",
],
dir,
);
assert!(
!dir.join(".heddle").exists(),
"a git clone must not carry `.heddle`"
);
temp
}
fn native_repo() -> TempDir {
let temp = TempDir::new().expect("tempdir");
heddle(&["init"], Some(temp.path())).expect("heddle init");
temp
}
fn json(args: &[&str], dir: &Path) -> Value {
let stdout = heddle(args, Some(dir)).unwrap_or_else(|e| panic!("`{args:?}` failed: {e}"));
serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("`{args:?}` should emit JSON, got {stdout:?}: {e}"))
}
#[test]
fn reads_in_a_clone_neither_claim_nor_create_a_heddle_store() {
let source = overlay_with_records();
let clone = clone_of(source.path());
let dir = clone.path();
let before = json(&["--output", "json", "status"], dir);
assert_eq!(before["repository_capability"], "plain-git", "{before}");
assert_eq!(before["heddle_initialized"], Value::Bool(false), "{before}");
for args in [
vec!["context", "list"],
vec!["context", "get", "--path", "f.txt"],
vec!["context", "check"],
vec!["context", "audit"],
vec!["discuss", "list"],
] {
heddle(&args, Some(dir)).unwrap_or_else(|e| panic!("`{args:?}` should succeed: {e}"));
assert!(
!dir.join(".heddle").exists(),
"`{args:?}` bootstrapped a Heddle store in a plain Git clone"
);
}
let after = json(&["--output", "json", "status"], dir);
assert_eq!(after["repository_capability"], "plain-git", "{after}");
assert_eq!(after["heddle_initialized"], Value::Bool(false), "{after}");
let text = heddle(&["status"], Some(dir)).expect("status");
assert!(
!text.contains("Git + Heddle"),
"status must not claim `Git + Heddle` for a clone with no store: {text}"
);
}
#[test]
fn absent_store_and_empty_store_report_differently() {
let source = overlay_with_records();
let clone = clone_of(source.path());
let native = native_repo();
let absent = heddle(&["context", "list"], Some(clone.path())).expect("context list in clone");
let empty = heddle(&["context", "list"], Some(native.path())).expect("context list in native");
assert!(
absent.contains("No Heddle store here"),
"clone must report the missing store: {absent}"
);
assert!(
absent.contains("git clone"),
"clone must explain why the annotations are not here: {absent}"
);
assert!(
!absent.contains("No context annotations."),
"clone must not answer as if the store existed and was empty: {absent}"
);
assert!(
empty.contains("No context annotations."),
"an empty native store still reports zero annotations: {empty}"
);
assert!(
!empty.contains("No Heddle store here"),
"a native store is present; do not claim otherwise: {empty}"
);
assert_ne!(absent, empty, "the two branches must not collapse");
let absent_discuss =
heddle(&["discuss", "list"], Some(clone.path())).expect("discuss list in clone");
let empty_discuss =
heddle(&["discuss", "list"], Some(native.path())).expect("discuss list in native");
assert!(
absent_discuss.contains("No Heddle store here"),
"{absent_discuss}"
);
assert!(
empty_discuss.contains("(no discussions)"),
"{empty_discuss}"
);
assert_ne!(absent_discuss, empty_discuss);
}
#[test]
fn absent_store_is_distinguishable_in_json() {
let source = overlay_with_records();
let clone = clone_of(source.path());
let native = native_repo();
for (surface, items_key) in [("context", "items"), ("discuss", "discussions")] {
let absent = json(&["--output", "json", surface, "list"], clone.path());
assert_eq!(absent["store_present"], Value::Bool(false), "{absent}");
assert_eq!(absent["store_scope"], "native-heddle-only", "{absent}");
assert_eq!(absent["git_checkout"], Value::Bool(true), "{absent}");
assert_eq!(
absent["output_kind"],
format!("{surface}_list"),
"the absent-store envelope keeps the command's discriminator: {absent}"
);
assert_eq!(
absent[items_key],
Value::Array(vec![]),
"shape stays compatible with a populated list: {absent}"
);
let empty = json(&["--output", "json", surface, "list"], native.path());
assert!(
empty.get("store_present").is_none(),
"a present store does not carry the absent-store marker: {empty}"
);
assert_eq!(empty[items_key], Value::Array(vec![]), "{empty}");
}
}
#[test]
fn overlay_creation_warns_once_per_working_copy() {
let temp = TempDir::new().expect("tempdir");
let dir = temp.path();
git(&["init", "-q", "-b", "main", "."], dir);
std::fs::write(dir.join("f.txt"), "hello\n").expect("write f.txt");
git(&["add", "f.txt"], dir);
git(&["commit", "-qm", "init"], dir);
heddle(&["init"], Some(dir)).expect("heddle init");
let notice = "local to this working copy";
let first = heddle_output(
&[
"context",
"set",
"--path",
"f.txt",
"--kind",
"invariant",
"-m",
"one",
],
Some(dir),
)
.expect("context set");
let first_stderr = String::from_utf8_lossy(&first.stderr).into_owned();
assert!(
first_stderr.contains(notice) && first_stderr.contains("do not travel"),
"first overlay annotation must state the boundary: {first_stderr}"
);
for message in ["two", "three"] {
let repeat = heddle_output(
&[
"context",
"set",
"--path",
"f.txt",
"--scope",
"file",
"--kind",
"invariant",
"-m",
message,
],
Some(dir),
)
.expect("context set");
let stderr = String::from_utf8_lossy(&repeat.stderr).into_owned();
assert!(
!stderr.contains(notice),
"the notice must not repeat on every invocation: {stderr}"
);
}
let first_discuss = heddle_output(&["discuss", "open", "f.txt", "greeting", "why?"], Some(dir))
.expect("discuss open");
let discuss_stderr = String::from_utf8_lossy(&first_discuss.stderr).into_owned();
assert!(
discuss_stderr.contains(notice),
"first overlay discussion must state the boundary: {discuss_stderr}"
);
let second_discuss = heddle_output(
&["discuss", "open", "f.txt", "greeting", "still why?"],
Some(dir),
)
.expect("discuss open");
assert!(
!String::from_utf8_lossy(&second_discuss.stderr).contains(notice),
"the discuss notice must not repeat"
);
}
#[test]
fn native_creation_does_not_claim_records_are_local() {
let native = native_repo();
let output = heddle_output(
&[
"context",
"set",
"--path",
"g.txt",
"--kind",
"invariant",
"-m",
"native",
],
Some(native.path()),
)
.expect("context set");
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
!stderr.contains("local to this working copy"),
"native annotations travel with the repository; do not claim otherwise: {stderr}"
);
}
#[test]
fn help_states_the_native_only_scope() {
let context_help = heddle_help(&["help", "context"]);
assert!(
context_help.contains("Native Heddle only"),
"`heddle help context` must state the scope: {context_help}"
);
assert!(
context_help.contains("git clone"),
"`heddle help context` must say Git does not carry annotations: {context_help}"
);
let discuss_help = heddle_help(&["help", "discuss"]);
assert!(
discuss_help.contains("native Heddle only"),
"`heddle help discuss` must state the scope: {discuss_help}"
);
assert!(
discuss_help.contains("not into `refs/notes/*`"),
"`heddle help discuss` must name the storage design that was rejected: {discuss_help}"
);
let overlay_topic = heddle_help(&["help", "git-overlay"]);
assert!(
overlay_topic.contains("local to this working copy"),
"the Git Overlay pitch must be qualified: {overlay_topic}"
);
}
#[test]
fn overlay_records_leave_no_git_residue() {
let temp = overlay_with_records();
let dir = temp.path();
let status = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(dir)
.output()
.expect("git status");
assert!(
str::from_utf8(&status.stdout)
.expect("utf8")
.trim()
.is_empty(),
"annotations must not touch the Git worktree: {}",
String::from_utf8_lossy(&status.stdout)
);
let refs = std::process::Command::new("git")
.args(["for-each-ref", "--format=%(refname)"])
.current_dir(dir)
.output()
.expect("git for-each-ref");
let refs = String::from_utf8_lossy(&refs.stdout);
assert!(
!refs.contains("refs/notes"),
"context is native-only and must not be projected into notes: {refs}"
);
let fsck = std::process::Command::new("git")
.args(["fsck", "--no-progress"])
.current_dir(dir)
.output()
.expect("git fsck");
let fsck_out = format!(
"{}{}",
String::from_utf8_lossy(&fsck.stdout),
String::from_utf8_lossy(&fsck.stderr)
);
assert!(
!fsck_out.contains("dangling"),
"annotations must not leave orphan Git objects: {fsck_out}"
);
}