#![cfg(unix)]
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn make_script(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();
path
}
struct MockBins {
_dir: TempDir,
pub gh: PathBuf,
pub git: PathBuf,
}
impl MockBins {
fn no_pr() -> Self {
let dir = TempDir::new().unwrap();
let gh = make_script(
dir.path(),
"gh",
r#"
if echo "$*" | grep -q -- "--version"; then
echo 'gh version 2.x'; exit 0
fi
exit 1
"#,
);
let git = make_script(
dir.path(),
"git",
r#"echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa""#,
);
MockBins { _dir: dir, gh, git }
}
}
#[derive(Debug, serde::Deserialize)]
struct Decision {
decision: String,
message: String,
}
fn fire_with_nonexistent_manifest(
nonexistent_manifest: &Path,
transcript: &Path,
cwd: &Path,
mock: &MockBins,
) -> (i32, Decision) {
let mut args: Vec<String> = vec![
"loop-check".to_string(),
"--state".to_string(),
nonexistent_manifest.to_str().unwrap().to_string(),
"--transcript".to_string(),
transcript.to_str().unwrap().to_string(),
"--cwd".to_string(),
cwd.to_str().unwrap().to_string(),
"--now".to_string(),
"2026-06-05T12:00:00Z".to_string(),
format!("--gh-bin={}", mock.gh.display()),
format!("--git-bin={}", mock.git.display()),
"--global-settings".to_string(),
"/nonexistent/global-settings.yaml".to_string(),
];
args.push("--global-events".to_string());
args.push("/dev/null".to_string());
let (code, json_str) = fno_agents::loopcheck::run_loop_check_capture(&args);
let d: Decision = serde_json::from_str(&json_str)
.unwrap_or_else(|_| panic!("non-JSON output (code={code}): {json_str}"));
(code, d)
}
#[test]
fn missing_manifest_allows_exit_zero() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path();
fs::create_dir_all(cwd.join(".fno")).unwrap();
fs::write(cwd.join(".fno/settings.yaml"), "# isolated test settings\n").unwrap();
let nonexistent = cwd.join("target-state.md");
assert!(
!nonexistent.exists(),
"pre-condition: manifest must not exist"
);
let transcript = cwd.join("transcript.jsonl");
let user_msg = serde_json::json!({"message": {"role": "user", "content": "go"}});
fs::write(
&transcript,
serde_json::to_string(&user_msg).unwrap() + "\n",
)
.unwrap();
let mock = MockBins::no_pr();
let (code, d) = fire_with_nonexistent_manifest(&nonexistent, &transcript, cwd, &mock);
assert_eq!(code, 0, "missing manifest must exit 0 (allow), got {code}");
assert_eq!(
d.decision, "allow",
"missing manifest must produce decision=allow, got {:?}",
d.decision
);
assert!(
d.message.contains("missing manifest"),
"message must mention 'missing manifest'; got: {:?}",
d.message
);
}
#[test]
fn missing_manifest_with_absent_transcript_allows_exit() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path();
fs::create_dir_all(cwd.join(".fno")).unwrap();
fs::write(cwd.join(".fno/settings.yaml"), "# isolated test settings\n").unwrap();
let nonexistent_manifest = cwd.join("target-state.md");
let nonexistent_transcript = cwd.join("transcript.jsonl");
let mock = MockBins::no_pr();
let mut args: Vec<String> = vec![
"loop-check".to_string(),
"--state".to_string(),
nonexistent_manifest.to_str().unwrap().to_string(),
"--transcript".to_string(),
nonexistent_transcript.to_str().unwrap().to_string(),
"--cwd".to_string(),
cwd.to_str().unwrap().to_string(),
"--now".to_string(),
"2026-06-05T12:00:00Z".to_string(),
format!("--gh-bin={}", mock.gh.display()),
format!("--git-bin={}", mock.git.display()),
"--global-settings".to_string(),
"/nonexistent/global-settings.yaml".to_string(),
"--global-events".to_string(),
"/dev/null".to_string(),
];
let (code, json_str) = fno_agents::loopcheck::run_loop_check_capture(&args);
assert_eq!(
code, 0,
"missing manifest + missing transcript must exit 0; json={json_str}"
);
let d: Decision =
serde_json::from_str(&json_str).unwrap_or_else(|_| panic!("non-JSON output: {json_str}"));
assert_eq!(d.decision, "allow");
}