use crate::config::manifest::{Manifest, OverflowPolicy};
use crate::config::per_repo_providers::PerRepoProviders;
use crate::config::version::{self, Version};
use crate::config::workflow::Workflow;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const INHERITED_GIT_ENV: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_PREFIX",
"GIT_COMMON_DIR",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
];
fn scrub_git_env(cmd: &mut Command) {
for var in INHERITED_GIT_ENV {
cmd.env_remove(var);
}
}
fn lernie_bin() -> PathBuf {
crate::test_support::lernie_binary()
}
fn scaffold(dest: &Path) -> String {
let home = TempDir::new().unwrap();
let mut cmd = Command::new(lernie_bin());
scrub_git_env(&mut cmd);
let out = cmd
.arg("new")
.arg(dest)
.env("LERNIE_HOME", home.path())
.env("GIT_AUTHOR_NAME", "lernie-test")
.env("GIT_AUTHOR_EMAIL", "test@example.invalid")
.env("GIT_COMMITTER_NAME", "lernie-test")
.env("GIT_COMMITTER_EMAIL", "test@example.invalid")
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "core.hooksPath")
.env("GIT_CONFIG_VALUE_0", "/dev/null")
.output()
.expect("invoke lernie binary");
assert!(
out.status.success(),
"lernie new failed: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn scaffolded() -> (TempDir, PathBuf) {
let holder = TempDir::new().unwrap();
let dest = holder.path().join("ws");
let stdout = scaffold(&dest);
assert_eq!(stdout, dest.display().to_string(), "stdout must echo path");
(holder, dest)
}
fn show_control(ws: &Path, path: &str) -> String {
let mut cmd = Command::new("git");
scrub_git_env(&mut cmd);
let out = cmd
.arg("-C")
.arg(ws.join("repo.git"))
.args(["show", &format!("config/default:{path}")])
.output()
.expect("spawn git show");
assert!(
out.status.success(),
"git show {path}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap()
}
fn parse_origin(path: &str) -> PathBuf {
PathBuf::from(format!("config/default:{path}"))
}
#[test]
fn version_file_is_one() {
let (_holder, ws) = scaffolded();
let raw = show_control(&ws, "version");
assert_eq!(raw.trim(), "1");
assert_eq!(
Version::parse(&raw, &parse_origin("version")).unwrap(),
Version(version::SUPPORTED)
);
}
#[test]
fn providers_yaml_is_roles_only_and_validates() {
let (_holder, ws) = scaffolded();
let raw = show_control(&ws, "providers.yaml");
let per_repo = PerRepoProviders::parse(&raw, &parse_origin("providers.yaml")).unwrap();
assert!(per_repo.roles.contains_key("worker"));
assert!(per_repo.roles.contains_key("compactor"));
assert_eq!(per_repo.roles["worker"].provider, "anthropic");
assert_eq!(per_repo.roles["worker"].model, "claude-sonnet-5");
}
#[test]
fn manifest_yaml_is_role_keyed_per_arch_5_2() {
let (_holder, ws) = scaffolded();
let raw = show_control(&ws, "manifest.yaml");
let manifest = Manifest::parse(&raw, &parse_origin("manifest.yaml")).unwrap();
assert!(manifest.roles.contains_key("worker"));
assert!(manifest.roles.contains_key("compactor"));
let worker = &manifest.roles["worker"];
assert!(worker.budget_tokens > 0);
assert!(worker.pinned.iter().any(|p| p == "goal.md"));
assert!(worker.pinned.iter().any(|p| p == "soul.md"));
assert!(
!worker.order.iter().any(|p| p.starts_with("steps/")),
"worker.order must not reference steps/** (§5.2 amended)"
);
assert_eq!(worker.overflow, OverflowPolicy::DropOldestSummaries);
assert_eq!(
manifest.roles["compactor"].overflow,
OverflowPolicy::Truncate
);
}
#[test]
fn workflow_yaml_validates() {
let (_holder, ws) = scaffolded();
let raw = show_control(&ws, "workflow.yaml");
Workflow::parse(&raw, &parse_origin("workflow.yaml")).unwrap();
}
#[test]
fn souls_live_in_the_config_commit() {
let (_holder, ws) = scaffolded();
assert!(show_control(&ws, "souls/worker.md").contains("# Worker"));
assert!(!show_control(&ws, "souls/compactor.md").is_empty());
}
#[test]
fn workspace_has_exactly_one_ref_and_no_main() {
let (_holder, ws) = scaffolded();
let repo = ws.join("repo.git");
assert!(repo.is_dir());
let mut cmd = Command::new("git");
scrub_git_env(&mut cmd);
let out = cmd
.arg("-C")
.arg(&repo)
.args(["for-each-ref", "--format=%(refname)"])
.output()
.unwrap();
assert!(out.status.success());
assert_eq!(
String::from_utf8(out.stdout).unwrap().trim(),
"refs/heads/config/default"
);
let mut bare = Command::new("git");
scrub_git_env(&mut bare);
let out = bare
.arg("-C")
.arg(&repo)
.args(["rev-parse", "--is-bare-repository"])
.output()
.unwrap();
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "true");
}
#[test]
fn config_commit_is_an_orphan_root_with_one_commit() {
let (_holder, ws) = scaffolded();
let repo = ws.join("repo.git");
let mut log = Command::new("git");
scrub_git_env(&mut log);
let log_out = log
.arg("-C")
.arg(&repo)
.args(["log", "--oneline", "config/default"])
.output()
.unwrap();
assert!(log_out.status.success(), "git log failed");
let text = String::from_utf8(log_out.stdout).unwrap();
assert_eq!(text.lines().count(), 1, "expected one commit, got:\n{text}");
assert!(text.contains("config: init [config/default]"));
}
#[test]
fn no_stray_files_or_checkouts_remain() {
let (_holder, ws) = scaffolded();
let entries: Vec<String> = std::fs::read_dir(&ws)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(entries, vec!["repo.git".to_string()], "got {entries:?}");
}
#[test]
fn no_args_uses_harness_root_with_auto_id() {
let home = TempDir::new().unwrap();
let mut cmd = Command::new(lernie_bin());
scrub_git_env(&mut cmd);
let out = cmd
.arg("new")
.env("LERNIE_HOME", home.path())
.env("GIT_AUTHOR_NAME", "lernie-test")
.env("GIT_AUTHOR_EMAIL", "test@example.invalid")
.env("GIT_COMMITTER_NAME", "lernie-test")
.env("GIT_COMMITTER_EMAIL", "test@example.invalid")
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "core.hooksPath")
.env("GIT_CONFIG_VALUE_0", "/dev/null")
.output()
.expect("invoke lernie binary");
assert!(
out.status.success(),
"lernie new (no args) failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let printed = String::from_utf8(out.stdout).unwrap().trim().to_string();
let printed_path = PathBuf::from(&printed);
assert!(printed_path.starts_with(home.path().join("workspaces")));
assert!(printed_path.join("repo.git").is_dir());
}
#[test]
fn binary_refuses_non_empty_destination() {
let holder = TempDir::new().unwrap();
let dest = holder.path().join("occupied");
std::fs::create_dir(&dest).unwrap();
std::fs::write(dest.join("preexisting"), b"x").unwrap();
let home = TempDir::new().unwrap();
let mut cmd = Command::new(lernie_bin());
scrub_git_env(&mut cmd);
let out = cmd
.arg("new")
.arg(&dest)
.env("LERNIE_HOME", home.path())
.output()
.unwrap();
assert!(
!out.status.success(),
"binary should refuse non-empty destination"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("not empty"), "unexpected stderr: {stderr}");
}
#[test]
fn binary_accepts_existing_empty_destination() {
let holder = TempDir::new().unwrap();
let dest = holder.path().join("preexisting-empty");
std::fs::create_dir(&dest).unwrap();
let stdout = scaffold(&dest);
assert_eq!(stdout, dest.display().to_string());
assert!(dest.join("repo.git").is_dir());
}