use std::time::{Duration, Instant};
use super::extract::Pair;
use crate::agent_exec::{AgentKind, GateResult, dispatch_gate};
use difflore_core::cloud::session_mined::{SessionMinedCandidate, SessionMinedCandidateArgs};
pub(crate) const GATE_PROMPT_PREAMBLE: &str = "You are a code-review-rules librarian.";
const PROMPT_MAX_CHARS: usize = 30_000;
const MAX_EXISTING_RULES_IN_PROMPT: usize = 24;
const FALLBACK_GATE_AGENTS: [AgentKind; 3] = [
AgentKind::ClaudeCode,
AgentKind::GeminiCli,
AgentKind::Codex,
];
const GATE_AGENT_TIMEOUT: Duration = Duration::from_secs(30);
const GATE_TOTAL_TIMEOUT: Duration = Duration::from_secs(90);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExistingRule {
pub rule_id: String,
pub title: String,
pub body_snippet: String,
pub file_patterns: Vec<String>,
pub source_repo: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GateArgs<'a> {
pub session_id: &'a str,
pub source_repo: &'a str,
pub pairs: &'a [Pair],
pub existing_rules: &'a [ExistingRule],
pub gate_model: &'a str,
pub client_name: &'a str,
pub ts_ms: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateMode {
Session,
Correction,
ManualLearn,
Recipe,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateVerdict {
Keep { candidate: SessionMinedCandidate },
Merge {
gate_model: String,
rule_id: String,
title: Option<String>,
updated_body: String,
file_patterns: Vec<String>,
},
Skip { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GateError {
#[error("session-mine gate received no conversation pairs")]
EmptyInput,
#[error("session-mine gate dispatch failed: {message}")]
Dispatch { message: String },
#[error("session-mine gate parse failed: {reason}")]
ParseFailure { reason: String, raw: String },
#[error("session-mine gate produced invalid candidate: {reason}")]
InvalidCandidate { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateDispatchFailureClass {
Persistent,
Transient,
}
impl GateError {
pub fn dispatch_failure_class(&self) -> Option<GateDispatchFailureClass> {
match self {
Self::Dispatch { message } => Some(classify_dispatch_failure(message)),
_ => None,
}
}
}
pub async fn run_gate(args: GateArgs<'_>) -> Result<GateVerdict, GateError> {
run_gate_with_mode(args, GateMode::Session).await
}
pub async fn run_gate_with_mode(
args: GateArgs<'_>,
mode: GateMode,
) -> Result<GateVerdict, GateError> {
if args.pairs.is_empty() {
return Err(GateError::EmptyInput);
}
let prompt = build_prompt_with_mode(args.pairs, args.existing_rules, mode);
let mut dispatch_errors = Vec::new();
let started = Instant::now();
for agent in gate_agent_candidates(args.client_name) {
let Some(time_budget) = gate_dispatch_budget(started.elapsed()) else {
dispatch_errors.push(format!(
"time budget exhausted after {}s",
GATE_TOTAL_TIMEOUT.as_secs()
));
break;
};
let result: GateResult = dispatch_gate(agent, &prompt, time_budget).await;
if let Some(verdict) = verdict_from_gate_result(agent, result, &args, &mut dispatch_errors)
{
return verdict;
}
}
Err(GateError::Dispatch {
message: if dispatch_errors.is_empty() {
"no compatible gate agents were configured".to_owned()
} else {
format!("all gate agents failed: {}", dispatch_errors.join("; "))
},
})
}
fn gate_dispatch_budget(elapsed: Duration) -> Option<Duration> {
let remaining = GATE_TOTAL_TIMEOUT.checked_sub(elapsed)?;
if remaining.is_zero() {
return None;
}
Some(remaining.min(GATE_AGENT_TIMEOUT))
}
fn verdict_from_gate_result(
agent: AgentKind,
result: GateResult,
args: &GateArgs<'_>,
dispatch_errors: &mut Vec<String>,
) -> Option<Result<GateVerdict, GateError>> {
if result.errored {
dispatch_errors.push(format!(
"{}: {}",
agent.label(),
dispatch_error_message(result)
));
return None;
}
let parsed = match parse_gate_json(&result.stdout) {
Ok(parsed) => parsed,
Err(err @ GateError::ParseFailure { .. }) => {
dispatch_errors.push(format!("{}: {err}", agent.label()));
return None;
}
Err(err) => return Some(Err(err)),
};
let gate_model = format!("{}:gate", agent.label());
Some(parsed_to_verdict(parsed, args, &gate_model))
}
fn dispatch_error_message(result: GateResult) -> String {
if result.error_message.is_empty() {
"agent CLI reported error with no message".to_owned()
} else {
result.error_message
}
}
fn classify_dispatch_failure(message: &str) -> GateDispatchFailureClass {
let lower = message.to_ascii_lowercase();
let persistent_markers = [
"is it installed and on path",
"not found",
"no such file",
"failed to spawn",
"permission denied",
"unauthorized",
"forbidden",
"missing required scope",
"scope_missing",
"billing",
"payment required",
"quota",
"rate limit",
"rate_limit",
];
let persistent_cap_markers = [
"usage cap",
"spend cap",
"monthly cap",
"hard cap",
"billing cap",
"credit cap",
];
if persistent_markers
.iter()
.any(|marker| lower.contains(marker))
|| persistent_cap_markers
.iter()
.any(|marker| lower.contains(marker))
{
GateDispatchFailureClass::Persistent
} else {
GateDispatchFailureClass::Transient
}
}
fn gate_agent_candidates(client_name: &str) -> Vec<AgentKind> {
let mut agents = Vec::with_capacity(FALLBACK_GATE_AGENTS.len());
if let Some(client_agent) = AgentKind::from_client_name(client_name) {
push_gate_agent(&mut agents, client_agent);
}
for agent in FALLBACK_GATE_AGENTS {
push_gate_agent(&mut agents, agent);
}
agents
}
fn push_gate_agent(agents: &mut Vec<AgentKind>, agent: AgentKind) {
if agents.contains(&agent) {
return;
}
agents.push(agent);
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GateJson {
verdict: String,
rule_id: Option<String>,
title: Option<String>,
body: Option<String>,
file_patterns: Vec<String>,
reason: Option<String>,
}
#[cfg(test)]
fn build_prompt(pairs: &[Pair], existing_rules: &[ExistingRule]) -> String {
build_prompt_with_mode(pairs, existing_rules, GateMode::Session)
}
fn build_prompt_with_mode(
pairs: &[Pair],
existing_rules: &[ExistingRule],
mode: GateMode,
) -> String {
let mut out = String::with_capacity(PROMPT_MAX_CHARS / 2);
out.push_str(GATE_PROMPT_PREAMBLE);
out.push(' ');
out.push_str(mode.opening_sentence());
out.push_str("\n\n");
out.push_str("EXISTING RULES (do not duplicate):\n");
if existing_rules.is_empty() {
out.push_str("- (none yet)\n");
} else {
for rule in existing_rules.iter().take(MAX_EXISTING_RULES_IN_PROMPT) {
let snippet = rule.body_snippet.trim();
let snippet_short = truncate_chars(snippet, 200);
let title = truncate_chars(rule.title.trim(), 120);
out.push_str("- ");
out.push_str(rule.rule_id.trim());
out.push_str(": ");
out.push_str(&title);
if let Some(source_repo) = rule
.source_repo
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
out.push_str(" [source_repo: ");
out.push_str(source_repo);
out.push(']');
}
if !rule.file_patterns.is_empty() {
out.push_str(" [file_patterns: ");
out.push_str(&rule.file_patterns.join(", "));
out.push(']');
}
if !snippet_short.is_empty() {
out.push_str(" — ");
out.push_str(&snippet_short);
}
out.push('\n');
}
}
out.push('\n');
let mut rendered_pairs: Vec<String> = pairs.iter().map(|p| mode.render_pair(p)).collect();
let body_budget = PROMPT_MAX_CHARS.saturating_sub(out.chars().count() + 1_200); let mut session_block = String::new();
while !rendered_pairs.is_empty() {
let candidate_len: usize = rendered_pairs.iter().map(|s| s.chars().count()).sum();
if candidate_len <= body_budget {
break;
}
rendered_pairs.remove(0);
}
if rendered_pairs.is_empty() {
if let Some(last) = pairs.last() {
let rendered = mode.render_pair(last);
let truncated = truncate_chars(&rendered, body_budget);
session_block.push_str(&truncated);
}
} else {
for rendered in &rendered_pairs {
session_block.push_str(rendered);
}
}
out.push_str(mode.session_label());
out.push('\n');
out.push_str(&session_block);
out.push('\n');
if let Some(extra) = mode.extra_instruction() {
out.push_str(extra);
out.push_str("\n\n");
}
out.push_str(
"DECISION CRITERIA:\n\
- KEEP if the activity contains a non-obvious, reusable rule (file_pattern + behavior).\n\
- MERGE <existing-id> if it strengthens or refines an existing rule.\n\
- SKIP if it's one-off / generic / obvious / already covered.\n\
\n\
RESPOND WITH STRICT JSON (no prose, no markdown fence):\n\
{ \"verdict\": \"KEEP\" | \"MERGE\" | \"SKIP\",\n\
\"rule_id\": \"<existing id if MERGE, else null>\",\n\
\"title\": \"<≤120 chars, only if KEEP/MERGE>\",\n\
\"body\": \"<≤2000 chars rule body, only if KEEP/MERGE>\",\n\
\"file_patterns\": [\"<glob1>\", \"...\"],\n\
\"reason\": \"<short justification, only if SKIP>\" }\n",
);
if out.chars().count() > PROMPT_MAX_CHARS {
out = truncate_chars(&out, PROMPT_MAX_CHARS);
}
out
}
impl GateMode {
pub const fn label(self) -> &'static str {
match self {
Self::Session => "session",
Self::Correction => "correction",
Self::ManualLearn => "manual-learn",
Self::Recipe => "recipe",
}
}
const fn opening_sentence(self) -> &'static str {
match self {
Self::Session => {
"Decide whether the following short session contains a reusable, transferable rule about how to write code in this team's repo."
}
Self::Correction => {
"Decide whether the user's correction reveals a reusable, transferable rule about how future code should be written in this team's repo."
}
Self::ManualLearn => {
"Decide whether this user-requested learning capture contains a reusable, transferable rule about how future code should be written in this team's repo."
}
Self::Recipe => {
"Decide whether this session contains a reusable local operation recipe: repeatable steps, commands, checks, or repo workflow that future agents should follow."
}
}
}
const fn session_label(self) -> &'static str {
match self {
Self::Session => "SESSION (prompt + final assistant text only — tool calls stripped):",
Self::Correction => "CORRECTION EVENT (assistant action followed by user correction):",
Self::ManualLearn => "USER-REQUESTED LEARNING WINDOW:",
Self::Recipe => "LOCAL OPERATION RECIPE WINDOW:",
}
}
const fn extra_instruction(self) -> Option<&'static str> {
match self {
Self::Session => None,
Self::Correction => Some(
"For correction events, rewrite past-incident phrasing into a forward-looking rule. Prefer \"Future code should...\" over \"The assistant just...\". KEEP only when the correction is durable and repo-relevant; SKIP one-off preferences or transient frustration.",
),
Self::ManualLearn => Some(
"This was explicitly triggered by the user. Still deduplicate and SKIP obvious or one-off notes, but preserve durable user-provided constraints as forward-looking rules.",
),
Self::Recipe => Some(
"For recipe mining, KEEP only repeatable local workflow knowledge: commands, verification steps, deployment/release chores, repo setup, or operational sequences. Write the body as a short recipe with steps. SKIP coding style rules, one-off debugging details, and user corrections.",
),
}
}
fn render_pair(self, pair: &Pair) -> String {
match self {
Self::Correction => format!(
"ASSISTANT_ACTION: {}\nUSER_CORRECTION: {}\n",
pair.assistant_text.trim(),
pair.user_prompt.trim(),
),
Self::Session | Self::ManualLearn | Self::Recipe => format!(
"USER: {}\nASSISTANT: {}\n",
pair.user_prompt.trim(),
pair.assistant_text.trim(),
),
}
}
}
fn parse_gate_json(raw: &str) -> Result<GateJson, GateError> {
let cleaned = strip_markdown_fence(raw.trim());
let body = locate_json_object(&cleaned).ok_or_else(|| GateError::ParseFailure {
reason: "no JSON object found in agent output".to_owned(),
raw: truncate_chars(raw, 400),
})?;
let value: serde_json::Value =
serde_json::from_str(&body).map_err(|e| GateError::ParseFailure {
reason: format!("invalid JSON: {e}"),
raw: truncate_chars(raw, 400),
})?;
let verdict = value
.get("verdict")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| GateError::ParseFailure {
reason: "missing 'verdict' field".to_owned(),
raw: truncate_chars(raw, 400),
})?
.to_owned();
let rule_id = optional_string(&value, "rule_id");
let title = optional_string(&value, "title");
let body_field = optional_string(&value, "body");
let reason = optional_string(&value, "reason");
let file_patterns: Vec<String> = value
.get("file_patterns")
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::trim))
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
Ok(GateJson {
verdict,
rule_id,
title,
body: body_field,
file_patterns,
reason,
})
}
fn optional_string(value: &serde_json::Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
fn strip_markdown_fence(s: &str) -> String {
let trimmed = s.trim();
if let Some(rest) = trimmed.strip_prefix("```") {
let after_lang = rest.find('\n').map_or("", |idx| &rest[idx + 1..]);
let stripped = after_lang
.trim_end()
.strip_suffix("```")
.unwrap_or(after_lang)
.trim_end();
return stripped.to_owned();
}
trimmed.to_owned()
}
fn locate_json_object(s: &str) -> Option<String> {
let trimmed = s.trim();
let start = trimmed.find('{')?;
let bytes = trimmed.as_bytes();
let mut depth: i32 = 0;
let mut in_string = false;
let mut escape = false;
for (i, &b) in bytes.iter().enumerate().skip(start) {
if in_string {
if escape {
escape = false;
} else if b == b'\\' {
escape = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => in_string = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(trimmed[start..=i].to_owned());
}
}
_ => {}
}
}
None
}
fn parsed_to_verdict(
parsed: GateJson,
args: &GateArgs<'_>,
gate_model: &str,
) -> Result<GateVerdict, GateError> {
let verdict_uc = parsed.verdict.to_ascii_uppercase();
match verdict_uc.as_str() {
"KEEP" => {
let title = parsed.title.ok_or_else(|| GateError::InvalidCandidate {
reason: "KEEP verdict missing title".to_owned(),
})?;
let body = parsed.body.ok_or_else(|| GateError::InvalidCandidate {
reason: "KEEP verdict missing body".to_owned(),
})?;
if parsed.file_patterns.is_empty() {
return Err(GateError::InvalidCandidate {
reason: "KEEP verdict missing file_patterns".to_owned(),
});
}
let source_repo = difflore_core::infra::git::RepoScope::canonical(args.source_repo)
.ok_or_else(|| GateError::InvalidCandidate {
reason: format!("non-canonical source_repo: {}", args.source_repo),
})?;
let candidate = SessionMinedCandidate::try_new(SessionMinedCandidateArgs {
session_id: args.session_id.to_owned(),
ts_ms: args.ts_ms,
source_repo,
title,
body,
file_patterns: parsed.file_patterns,
gate_model: gate_model.to_owned(),
gate_verdict: "KEEP".to_owned(),
})
.map_err(|e| GateError::InvalidCandidate {
reason: e.to_string(),
})?;
Ok(GateVerdict::Keep { candidate })
}
"MERGE" => {
let rule_id = parsed.rule_id.ok_or_else(|| GateError::InvalidCandidate {
reason: "MERGE verdict missing rule_id".to_owned(),
})?;
if !args
.existing_rules
.iter()
.any(|rule| rule.rule_id == rule_id)
{
return Err(GateError::InvalidCandidate {
reason: format!("MERGE verdict references unknown rule_id: {rule_id}"),
});
}
let updated_body = parsed.body.ok_or_else(|| GateError::InvalidCandidate {
reason: "MERGE verdict missing body".to_owned(),
})?;
Ok(GateVerdict::Merge {
gate_model: gate_model.to_owned(),
rule_id,
title: parsed.title,
updated_body,
file_patterns: parsed.file_patterns,
})
}
"SKIP" => Ok(GateVerdict::Skip {
reason: parsed
.reason
.unwrap_or_else(|| "no reason given".to_owned()),
}),
other => Err(GateError::ParseFailure {
reason: format!("unknown verdict '{other}'"),
raw: String::new(),
}),
}
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
if s.chars().count() <= max_chars {
return s.to_owned();
}
let mut out: String = s.chars().take(max_chars.saturating_sub(1)).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn pair(user: &str, assistant: &str) -> Pair {
Pair {
user_prompt: user.to_owned(),
assistant_text: assistant.to_owned(),
}
}
fn args<'a>(pairs: &'a [Pair], existing: &'a [ExistingRule]) -> GateArgs<'a> {
GateArgs {
session_id: "sess_test",
source_repo: "owner/repo",
pairs,
existing_rules: existing,
gate_model: "claude-code:gate",
client_name: "claude-code",
ts_ms: 1_714_000_000_000,
}
}
#[test]
fn gate_candidates_prefer_current_client_and_leave_codex_as_fallback() {
let claude = gate_agent_candidates("claude-code");
assert_eq!(claude.first(), Some(&AgentKind::ClaudeCode));
assert_eq!(claude.last(), Some(&AgentKind::Codex));
assert_eq!(
claude,
vec![
AgentKind::ClaudeCode,
AgentKind::GeminiCli,
AgentKind::Codex,
]
);
let codex = gate_agent_candidates("codex");
assert_eq!(codex.first(), Some(&AgentKind::Codex));
assert_eq!(
codex,
vec![
AgentKind::Codex,
AgentKind::ClaudeCode,
AgentKind::GeminiCli,
]
);
}
#[test]
fn gate_dispatch_budget_caps_each_agent_and_total_chain() {
assert_eq!(
gate_dispatch_budget(Duration::ZERO),
Some(GATE_AGENT_TIMEOUT)
);
assert_eq!(
gate_dispatch_budget(Duration::from_secs(89)),
Some(Duration::from_secs(1))
);
assert_eq!(gate_dispatch_budget(GATE_TOTAL_TIMEOUT), None);
}
#[test]
fn dispatch_failure_class_marks_only_persistent_gate_failures() {
assert_eq!(
classify_dispatch_failure(
"all gate agents failed: codex: failed to spawn codex (is it installed and on PATH?)"
),
GateDispatchFailureClass::Persistent
);
assert_eq!(
classify_dispatch_failure("claude-code failed: 403 Forbidden: missing required scope"),
GateDispatchFailureClass::Persistent
);
assert_eq!(
classify_dispatch_failure("all gate agents failed: codex timed out after 30s"),
GateDispatchFailureClass::Transient
);
assert_eq!(
classify_dispatch_failure("claude-code failed: server at capacity, retry later"),
GateDispatchFailureClass::Transient
);
assert_eq!(
classify_dispatch_failure("codex: capture transport closed"),
GateDispatchFailureClass::Transient
);
assert_eq!(
classify_dispatch_failure("gemini: capability is temporarily unavailable"),
GateDispatchFailureClass::Transient
);
assert_eq!(
classify_dispatch_failure("codex failed: usage cap reached for this billing period"),
GateDispatchFailureClass::Persistent
);
assert_eq!(
classify_dispatch_failure("codex: session-mine gate parse failed: expected value"),
GateDispatchFailureClass::Transient
);
}
#[test]
fn parse_failure_is_recorded_and_allows_next_agent() {
let pairs = vec![pair("u", "a")];
let existing = Vec::new();
let gate_args = args(&pairs, &existing);
let mut dispatch_errors = Vec::new();
let non_json = GateResult {
stdout: "login required before running the agent".to_owned(),
stderr: String::new(),
errored: false,
error_message: String::new(),
};
assert!(
verdict_from_gate_result(AgentKind::Codex, non_json, &gate_args, &mut dispatch_errors)
.is_none()
);
assert!(
dispatch_errors
.iter()
.any(|msg| msg.contains("codex") && msg.contains("parse failed")),
"parse failure should be retained for final diagnostics: {dispatch_errors:?}"
);
let valid_skip = GateResult {
stdout: r#"{"verdict":"SKIP","reason":"covered"}"#.to_owned(),
stderr: String::new(),
errored: false,
error_message: String::new(),
};
let verdict = verdict_from_gate_result(
AgentKind::ClaudeCode,
valid_skip,
&gate_args,
&mut dispatch_errors,
)
.expect("valid fallback should produce a verdict")
.expect("valid fallback should succeed");
assert_eq!(
verdict,
GateVerdict::Skip {
reason: "covered".to_owned()
}
);
}
#[test]
fn prompt_includes_existing_rules_section_with_ids_and_titles() {
let rules = vec![
ExistingRule {
rule_id: "rule-1".to_owned(),
title: "Prefer typed deserialization".to_owned(),
body_snippet: "Use serde structs instead of Value::as_str.".to_owned(),
file_patterns: vec!["src/**/*.rs".to_owned()],
source_repo: Some("owner/repo".to_owned()),
},
ExistingRule {
rule_id: "rule-2".to_owned(),
title: "Hard-deny dbg!".to_owned(),
body_snippet: "Workspace forbids debug macros in committed code.".to_owned(),
file_patterns: vec!["**/*.rs".to_owned()],
source_repo: None,
},
];
let pairs = vec![pair("hi", "hello")];
let prompt = build_prompt(&pairs, &rules);
assert!(prompt.contains("EXISTING RULES"), "section header present");
assert!(prompt.contains("rule-1: Prefer typed deserialization"));
assert!(prompt.contains("rule-2: Hard-deny dbg!"));
assert!(prompt.contains("SESSION ("));
assert!(prompt.contains("DECISION CRITERIA"));
assert!(prompt.contains("STRICT JSON"));
}
#[test]
fn prompt_uses_none_yet_placeholder_when_no_existing_rules() {
let pairs = vec![pair("u", "a")];
let prompt = build_prompt(&pairs, &[]);
assert!(prompt.contains("- (none yet)"), "explicit no-rules marker");
}
#[test]
fn prompt_renders_pairs_in_order_with_user_assistant_labels() {
let pairs = vec![pair("first q", "first a"), pair("second q", "second a")];
let prompt = build_prompt(&pairs, &[]);
let first_idx = prompt.find("first q").expect("first q present");
let second_idx = prompt.find("second q").expect("second q present");
assert!(first_idx < second_idx, "pairs in chronological order");
assert!(prompt.contains("USER: first q"));
assert!(prompt.contains("ASSISTANT: first a"));
}
#[test]
fn correction_prompt_renders_assistant_action_before_user_correction() {
let pairs = vec![pair(
"Not that way; keep the null branch.",
"I removed the null branch from the handler.",
)];
let prompt = build_prompt_with_mode(&pairs, &[], GateMode::Correction);
assert!(prompt.contains("CORRECTION EVENT"));
assert!(prompt.contains("ASSISTANT_ACTION: I removed the null branch"));
assert!(prompt.contains("USER_CORRECTION: Not that way"));
assert!(prompt.contains("forward-looking rule"));
}
#[test]
fn manual_learn_prompt_marks_user_requested_capture() {
let pairs = vec![pair("Remember to use generated clients.", "manual note")];
let prompt = build_prompt_with_mode(&pairs, &[], GateMode::ManualLearn);
assert!(prompt.contains("USER-REQUESTED LEARNING WINDOW"));
assert!(prompt.contains("explicitly triggered by the user"));
}
#[test]
fn recipe_prompt_marks_local_operation_window() {
let pairs = vec![pair(
"Ship the release.",
"Run pnpm typecheck, pnpm test, then deploy with the release script.",
)];
let prompt = build_prompt_with_mode(&pairs, &[], GateMode::Recipe);
assert!(prompt.contains("LOCAL OPERATION RECIPE WINDOW"));
assert!(prompt.contains("repeatable local workflow knowledge"));
assert!(prompt.contains("short recipe with steps"));
assert!(!prompt.contains("USER_CORRECTION"));
}
#[test]
fn prompt_drops_oldest_pairs_when_over_budget() {
let mut pairs: Vec<Pair> = Vec::new();
for i in 0..400 {
let body = "x".repeat(200);
pairs.push(pair(&format!("user-{i}"), &body));
}
let prompt = build_prompt(&pairs, &[]);
assert!(prompt.chars().count() <= PROMPT_MAX_CHARS);
assert!(prompt.contains("user-399"));
assert!(!prompt.contains("user-0\n"));
}
#[test]
fn prompt_caps_existing_rules_at_max_for_budget() {
let mut rules = Vec::new();
for i in 0..(MAX_EXISTING_RULES_IN_PROMPT + 10) {
rules.push(ExistingRule {
rule_id: format!("rule-{i}"),
title: format!("title-{i}"),
body_snippet: format!("snippet-{i}"),
file_patterns: Vec::new(),
source_repo: None,
});
}
let pairs = vec![pair("u", "a")];
let prompt = build_prompt(&pairs, &rules);
assert!(prompt.contains("rule-0:"));
assert!(prompt.contains(&format!("rule-{}:", MAX_EXISTING_RULES_IN_PROMPT - 1)));
assert!(!prompt.contains(&format!("rule-{MAX_EXISTING_RULES_IN_PROMPT}:")));
}
#[test]
fn parse_keep_minimal_shape() {
let raw = r#"{"verdict":"KEEP","title":"Always validate","body":"Validate before enqueue.","file_patterns":["src/**/*.rs"]}"#;
let parsed = parse_gate_json(raw).expect("parses");
assert_eq!(parsed.verdict, "KEEP");
assert_eq!(parsed.title.as_deref(), Some("Always validate"));
assert_eq!(parsed.body.as_deref(), Some("Validate before enqueue."));
assert_eq!(parsed.file_patterns, vec!["src/**/*.rs"]);
assert!(parsed.rule_id.is_none());
}
#[test]
fn parse_merge_shape_carries_rule_id() {
let raw = r#"{"verdict":"MERGE","rule_id":"rule-7","title":"Refine X","body":"Updated body","file_patterns":[]}"#;
let parsed = parse_gate_json(raw).expect("parses");
assert_eq!(parsed.verdict, "MERGE");
assert_eq!(parsed.rule_id.as_deref(), Some("rule-7"));
assert_eq!(parsed.body.as_deref(), Some("Updated body"));
}
#[test]
fn parse_skip_shape_carries_reason() {
let raw = r#"{"verdict":"SKIP","reason":"one-off bug fix"}"#;
let parsed = parse_gate_json(raw).expect("parses");
assert_eq!(parsed.verdict, "SKIP");
assert_eq!(parsed.reason.as_deref(), Some("one-off bug fix"));
}
#[test]
fn parse_tolerates_markdown_json_fence() {
let raw = "```json\n{\"verdict\":\"SKIP\",\"reason\":\"covered\"}\n```";
let parsed = parse_gate_json(raw).expect("parses through fence");
assert_eq!(parsed.verdict, "SKIP");
assert_eq!(parsed.reason.as_deref(), Some("covered"));
}
#[test]
fn parse_tolerates_plain_markdown_fence() {
let raw = "```\n{\"verdict\":\"SKIP\",\"reason\":\"x\"}\n```";
let parsed = parse_gate_json(raw).expect("parses");
assert_eq!(parsed.verdict, "SKIP");
}
#[test]
fn parse_tolerates_prose_before_json() {
let raw =
"Sure, here's my answer:\n{\"verdict\":\"SKIP\",\"reason\":\"too narrow\"}\nThanks!";
let parsed = parse_gate_json(raw).expect("parses through prose");
assert_eq!(parsed.verdict, "SKIP");
assert_eq!(parsed.reason.as_deref(), Some("too narrow"));
}
#[test]
fn parse_treats_null_optional_fields_as_none() {
let raw =
r#"{"verdict":"KEEP","rule_id":null,"title":"T","body":"B","file_patterns":["a.rs"]}"#;
let parsed = parse_gate_json(raw).expect("parses");
assert!(parsed.rule_id.is_none());
assert_eq!(parsed.title.as_deref(), Some("T"));
}
#[test]
fn parse_rejects_malformed_payload_with_clean_error() {
let raw = "this is not JSON at all";
let err = parse_gate_json(raw).unwrap_err();
match err {
GateError::ParseFailure { reason, .. } => {
assert!(
reason.contains("no JSON object"),
"expected 'no JSON object' diagnostic, got: {reason}"
);
}
other => panic!("expected ParseFailure, got {other:?}"),
}
}
#[test]
fn parse_rejects_missing_verdict_field() {
let raw = r#"{"title":"T","body":"B"}"#;
let err = parse_gate_json(raw).unwrap_err();
match err {
GateError::ParseFailure { reason, .. } => {
assert!(
reason.contains("verdict"),
"diagnostic mentions verdict: {reason}"
);
}
other => panic!("expected ParseFailure, got {other:?}"),
}
}
#[test]
fn parse_rejects_invalid_json_body() {
let raw = r#"{"verdict":"KEEP""#;
let err = parse_gate_json(raw).unwrap_err();
assert!(matches!(err, GateError::ParseFailure { .. }));
}
#[test]
fn keep_verdict_builds_session_mined_candidate() {
let parsed = GateJson {
verdict: "KEEP".to_owned(),
rule_id: None,
title: Some("Validate before enqueue".to_owned()),
body: Some(
"Session-mined candidates must validate before reaching the outbox.".to_owned(),
),
file_patterns: vec!["crates/**/*.rs".to_owned()],
reason: None,
};
let pairs = vec![pair("u", "a")];
let a = args(&pairs, &[]);
let verdict = parsed_to_verdict(parsed, &a, "codex:gate").expect("verdict");
match verdict {
GateVerdict::Keep { candidate } => {
assert_eq!(candidate.source_repo, "owner/repo");
assert_eq!(candidate.session_id, "sess_test");
assert_eq!(candidate.gate_verdict, "KEEP");
assert_eq!(candidate.gate_model, "codex:gate");
assert!(candidate.requires_human_approval);
assert_eq!(candidate.file_patterns, vec!["crates/**/*.rs"]);
}
other => panic!("expected Keep, got {other:?}"),
}
}
#[test]
fn merge_verdict_carries_rule_id_body_and_scope_evidence() {
let parsed = GateJson {
verdict: "MERGE".to_owned(),
rule_id: Some("rule-42".to_owned()),
title: Some("Extended".to_owned()),
body: Some("Refined body".to_owned()),
file_patterns: vec!["src/session.rs".to_owned()],
reason: None,
};
let pairs = vec![pair("u", "a")];
let existing = vec![ExistingRule {
rule_id: "rule-42".to_owned(),
title: "Existing".to_owned(),
body_snippet: "Existing body".to_owned(),
file_patterns: vec!["src/session.rs".to_owned()],
source_repo: Some("owner/repo".to_owned()),
}];
let a = args(&pairs, &existing);
let verdict = parsed_to_verdict(parsed, &a, "codex:gate").expect("verdict");
assert_eq!(
verdict,
GateVerdict::Merge {
gate_model: "codex:gate".to_owned(),
rule_id: "rule-42".to_owned(),
title: Some("Extended".to_owned()),
updated_body: "Refined body".to_owned(),
file_patterns: vec!["src/session.rs".to_owned()],
}
);
}
#[test]
fn merge_verdict_rejects_unknown_rule_id() {
let parsed = GateJson {
verdict: "MERGE".to_owned(),
rule_id: Some("missing-rule".to_owned()),
title: Some("Extended".to_owned()),
body: Some("Refined body".to_owned()),
file_patterns: vec!["src/session.rs".to_owned()],
reason: None,
};
let pairs = vec![pair("u", "a")];
let existing = vec![ExistingRule {
rule_id: "rule-42".to_owned(),
title: "Existing".to_owned(),
body_snippet: "Existing body".to_owned(),
file_patterns: vec!["src/session.rs".to_owned()],
source_repo: Some("owner/repo".to_owned()),
}];
let a = args(&pairs, &existing);
let err = parsed_to_verdict(parsed, &a, "codex:gate").unwrap_err();
assert!(matches!(err, GateError::InvalidCandidate { .. }));
}
#[test]
fn skip_verdict_falls_back_to_default_reason() {
let parsed = GateJson {
verdict: "SKIP".to_owned(),
rule_id: None,
title: None,
body: None,
file_patterns: vec![],
reason: None,
};
let pairs = vec![pair("u", "a")];
let a = args(&pairs, &[]);
let verdict = parsed_to_verdict(parsed, &a, "codex:gate").expect("verdict");
assert_eq!(
verdict,
GateVerdict::Skip {
reason: "no reason given".to_owned(),
}
);
}
#[test]
fn keep_missing_title_or_body_is_invalid_candidate() {
let parsed = GateJson {
verdict: "KEEP".to_owned(),
rule_id: None,
title: None,
body: Some("body".to_owned()),
file_patterns: vec!["a.rs".to_owned()],
reason: None,
};
let pairs = vec![pair("u", "a")];
let a = args(&pairs, &[]);
let err = parsed_to_verdict(parsed, &a, "codex:gate").unwrap_err();
assert!(matches!(err, GateError::InvalidCandidate { .. }));
}
#[test]
fn keep_with_empty_file_patterns_is_invalid_candidate() {
let parsed = GateJson {
verdict: "KEEP".to_owned(),
rule_id: None,
title: Some("T".to_owned()),
body: Some("B".to_owned()),
file_patterns: vec![],
reason: None,
};
let pairs = vec![pair("u", "a")];
let a = args(&pairs, &[]);
let err = parsed_to_verdict(parsed, &a, "codex:gate").unwrap_err();
match err {
GateError::InvalidCandidate { reason } => {
assert!(reason.contains("file_patterns"));
}
other => panic!("expected InvalidCandidate, got {other:?}"),
}
}
#[test]
fn unknown_verdict_string_is_parse_failure() {
let parsed = GateJson {
verdict: "REJECT".to_owned(),
rule_id: None,
title: None,
body: None,
file_patterns: vec![],
reason: None,
};
let pairs = vec![pair("u", "a")];
let a = args(&pairs, &[]);
let err = parsed_to_verdict(parsed, &a, "codex:gate").unwrap_err();
assert!(matches!(err, GateError::ParseFailure { .. }));
}
#[test]
fn gate_agent_candidates_prefers_current_client_for_claude_code_hooks() {
assert_eq!(
gate_agent_candidates("claude-code"),
vec![
AgentKind::ClaudeCode,
AgentKind::GeminiCli,
AgentKind::Codex,
],
);
}
#[test]
fn gate_agent_candidates_fall_back_when_client_has_no_gate4agent_tool() {
assert_eq!(
gate_agent_candidates("cursor"),
vec![
AgentKind::ClaudeCode,
AgentKind::GeminiCli,
AgentKind::Codex,
],
);
}
#[test]
fn gate_agent_candidates_skips_agents_without_headless_cli() {
assert_eq!(
gate_agent_candidates("windsurf"),
vec![
AgentKind::ClaudeCode,
AgentKind::GeminiCli,
AgentKind::Codex,
],
);
}
#[tokio::test]
async fn run_gate_rejects_empty_input_without_spawning() {
let a = args(&[], &[]);
let err = run_gate(a).await.unwrap_err();
assert_eq!(err, GateError::EmptyInput);
}
#[test]
fn existing_rule_shape_clones_and_compares_cheaply() {
let r = ExistingRule {
rule_id: "rule-1".to_owned(),
title: "Prefer typed parse".to_owned(),
body_snippet: "..".to_owned(),
file_patterns: vec!["**/*.rs".to_owned()],
source_repo: Some("owner/repo".to_owned()),
};
assert_eq!(r.clone(), r);
}
}