use std::path::Path;
use chrono::Local;
use serde::{Deserialize, Serialize};
use crate::config::GateConfig;
use crate::log::{self, AttachLogRecord};
use crate::mode;
pub const GATES_LOG: &str = "gates.jsonl";
#[derive(Debug, Serialize, Deserialize)]
pub struct GateRecord {
pub ts: String,
pub modes: Vec<String>,
pub marker: String,
pub note: String,
pub session_id: String,
}
const NOTE_MAX_CHARS: usize = 200;
fn detect_intervention(prompt: &str, gate: &GateConfig) -> Option<(String, String)> {
let first = prompt.lines().next()?.trim();
if first.is_empty() {
return None;
}
let marker = gate
.markers
.iter()
.filter(|m| !m.trim().is_empty())
.find(|m| {
mode::build_pattern(std::slice::from_ref(*m)).is_some_and(|re| re.is_match(first))
})
.cloned()?;
Some((marker, truncate_chars(first, NOTE_MAX_CHARS)))
}
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
}
fn resolve_modes_from_attach_log(path: &Path, session_id: &str) -> Option<Vec<String>> {
log::list_jsonl(
path,
|r: &AttachLogRecord| r.session_id.as_deref() == Some(session_id),
Some(1),
)
.pop()
.map(|r| r.modes)
}
pub fn maybe_record_from_prompt(
data_dir: &Path,
prompt: &str,
session_id: Option<&str>,
gate: &GateConfig,
) {
let Some(session_id) = session_id else {
return;
};
let Some((marker, note)) = detect_intervention(prompt, gate) else {
return;
};
let attach_path = data_dir.join(log::ATTACH_LOG);
let Some(modes) = resolve_modes_from_attach_log(&attach_path, session_id) else {
return;
};
let record = GateRecord {
ts: Local::now().to_rfc3339(),
modes,
marker,
note,
session_id: session_id.to_string(),
};
log::append_jsonl(&data_dir.join(GATES_LOG), &record);
}
pub fn list_gates(path: &Path, mode: Option<&str>, limit: Option<usize>) -> Vec<GateRecord> {
log::list_jsonl(
path,
|r: &GateRecord| mode.is_none_or(|mode| r.modes.iter().any(|m| m == mode)),
limit,
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn records_intervention_with_last_attached_modes() {
let dir = tempdir().unwrap();
let data = dir.path();
fs::write(
data.join(log::ATTACH_LOG),
r#"{"ts":"t1","session_id":"sid","cwd":null,"modes":["implement"]}
{"ts":"t2","session_id":"sid","cwd":null,"modes":["implement","review"]}
"#,
)
.unwrap();
let gate = GateConfig {
markers: vec!["違う".into(), "やり直し".into()],
};
maybe_record_from_prompt(data, "そこは違う、直して", Some("sid"), &gate);
maybe_record_from_prompt(data, "続けて\n実は違う", Some("sid"), &gate); maybe_record_from_prompt(data, "実装を続けて", Some("sid"), &gate); maybe_record_from_prompt(data, "違う", None, &gate); maybe_record_from_prompt(data, "違う", Some("orphan"), &gate); maybe_record_from_prompt(data, "違う", Some("sid"), &GateConfig::default()); maybe_record_from_prompt(
data,
"notification",
Some("sid"),
&GateConfig {
markers: vec!["no".into()],
},
);
let path = data.join(GATES_LOG);
let gates = list_gates(&path, None, None);
assert_eq!(gates.len(), 1);
assert_eq!(gates[0].modes, vec!["implement", "review"]); assert_eq!(gates[0].marker, "違う");
assert_eq!(gates[0].note, "そこは違う、直して");
assert_eq!(list_gates(&path, Some("review"), None).len(), 1);
assert!(list_gates(&path, Some("other"), None).is_empty());
}
}