use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const SEEN_FILE: &str = "opencode-plugin-seen.json";
#[must_use]
pub fn seen_path(ol_dir: &Path) -> PathBuf {
ol_dir.join("state").join(SEEN_FILE)
}
#[must_use]
pub fn read_seen(ol_dir: &Path) -> BTreeMap<String, i64> {
let Ok(raw) = std::fs::read_to_string(seen_path(ol_dir)) else {
return BTreeMap::new();
};
let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(&raw) else {
return BTreeMap::new();
};
map.into_iter()
.filter_map(|(k, v)| Some((k, v.as_i64()?)))
.collect()
}
const PLUGINS_KEY: &str = "plugins";
static WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub fn record(ol_dir: &Path, surface: &str, at_ms: i64) -> std::io::Result<()> {
record_report(ol_dir, surface, at_ms, None, None, None)
}
const RELAY_KEY: &str = "relay";
const RELAY_VALUE_MAX: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelayArming {
pub relay_arming: String,
pub runtime: String,
pub at_ms: i64,
}
pub fn record_report(
ol_dir: &Path,
surface: &str,
at_ms: i64,
plugins: Option<&[String]>,
relay_arming: Option<&str>,
runtime: Option<&str>,
) -> std::io::Result<()> {
let _guard = WRITE.lock().unwrap_or_else(|e| e.into_inner());
let path = seen_path(ol_dir);
let mut file = std::fs::read_to_string(&path)
.ok()
.and_then(|raw| match serde_json::from_str(&raw) {
Ok(serde_json::Value::Object(map)) => Some(map),
_ => None,
})
.unwrap_or_default();
let newer = file
.get(surface)
.and_then(serde_json::Value::as_i64)
.is_none_or(|kept| at_ms >= kept);
if !newer {
return Ok(());
}
file.insert(surface.to_string(), serde_json::json!(at_ms));
if let Some(plugins) = plugins {
file.insert(PLUGINS_KEY.to_string(), serde_json::json!(plugins));
}
fn bounded(v: Option<&str>) -> Option<&str> {
v.filter(|v| !v.is_empty() && v.len() <= RELAY_VALUE_MAX)
}
let relay = file
.entry(RELAY_KEY.to_string())
.or_insert_with(|| serde_json::json!({}));
if !relay.is_object() {
*relay = serde_json::json!({});
}
match (bounded(relay_arming), bounded(runtime)) {
(Some(arming), Some(runtime)) => {
relay[surface] = serde_json::json!({
"relay_arming": arming,
"runtime": runtime,
"at": at_ms,
});
}
_ => {
if let Some(map) = relay.as_object_mut() {
map.remove(surface);
}
}
}
crate::fs_secure::write_json_state(&path, &file)
}
#[must_use]
pub fn relay_arming(ol_dir: &Path) -> BTreeMap<String, RelayArming> {
let Some(serde_json::Value::Object(map)) = std::fs::read_to_string(seen_path(ol_dir))
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
else {
return BTreeMap::new();
};
let Some(serde_json::Value::Object(relay)) = map.get(RELAY_KEY) else {
return BTreeMap::new();
};
relay
.iter()
.filter_map(|(surface, v)| {
Some((
surface.clone(),
RelayArming {
relay_arming: v.get("relay_arming")?.as_str()?.to_string(),
runtime: v.get("runtime")?.as_str()?.to_string(),
at_ms: v.get("at")?.as_i64()?,
},
))
})
.collect()
}
#[must_use]
pub fn reported_plugins(ol_dir: &Path) -> Option<Vec<String>> {
let raw = std::fs::read_to_string(seen_path(ol_dir)).ok()?;
let serde_json::Value::Object(map) = serde_json::from_str(&raw).ok()? else {
return None;
};
let list = map.get(PLUGINS_KEY)?.as_array()?;
Some(
list.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
)
}
#[must_use]
pub fn last_seen(seen: &BTreeMap<String, i64>) -> Option<i64> {
seen.values().copied().max()
}
#[must_use]
pub fn seen_since(seen: &BTreeMap<String, i64>, since_ms: i64) -> bool {
last_seen(seen).is_some_and(|t| t >= since_ms)
}
pub fn forget(ol_dir: &Path) {
let _ = std::fs::remove_file(seen_path(ol_dir));
}
#[must_use]
pub fn surface_of(client: Option<&str>) -> &'static str {
crate::core::envelope::normalize::opencode_surface_of(client)
}
#[must_use]
pub fn mtime_ms(path: &Path) -> Option<i64> {
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
let d = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
i64::try_from(d.as_millis()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_read_and_forget_round_trip() {
let dir = tempfile::tempdir().unwrap();
assert!(read_seen(dir.path()).is_empty());
record(dir.path(), "cli", 100).unwrap();
record(dir.path(), "acp", 300).unwrap();
record(dir.path(), "cli", 200).unwrap();
let seen = read_seen(dir.path());
assert_eq!(seen.get("cli"), Some(&200));
assert_eq!(last_seen(&seen), Some(300));
assert!(seen_since(&seen, 300));
assert!(!seen_since(&seen, 301));
forget(dir.path());
assert!(!seen_path(dir.path()).exists());
}
#[test]
fn an_older_report_never_overwrites_a_newer_one_and_the_plugin_list_is_kept() {
let dir = tempfile::tempdir().unwrap();
let list = vec!["opencode-foo@1.0.0".to_string()];
record_report(dir.path(), "cli", 500, Some(&list), None, None).unwrap();
record_report(dir.path(), "cli", 400, Some(&[]), None, None).unwrap();
record(dir.path(), "acp", 450).unwrap();
let seen = read_seen(dir.path());
assert_eq!(seen.get("cli"), Some(&500));
assert_eq!(seen.get("acp"), Some(&450));
assert!(!seen.contains_key(PLUGINS_KEY), "the list is not a surface");
assert_eq!(reported_plugins(dir.path()), Some(list));
}
#[test]
fn concurrent_reports_from_two_surfaces_both_land() {
let dir = tempfile::tempdir().unwrap();
let handles: Vec<_> = (0..16)
.map(|i| {
let ol = dir.path().to_path_buf();
std::thread::spawn(move || {
let surface = if i % 2 == 0 { "cli" } else { "acp" };
record(&ol, surface, 1000 + i).unwrap();
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
let seen = read_seen(dir.path());
assert_eq!(seen.get("cli"), Some(&1014));
assert_eq!(seen.get("acp"), Some(&1015));
}
#[test]
fn relay_arming_is_kept_per_surface_newest_wins() {
let dir = tempfile::tempdir().unwrap();
assert!(relay_arming(dir.path()).is_empty());
record_report(
dir.path(),
"desktop",
100,
None,
Some("armed_in_process"),
Some("node"),
)
.unwrap();
record_report(dir.path(), "cli", 150, None, Some("not_armed"), Some("bun")).unwrap();
record_report(
dir.path(),
"desktop",
90,
None,
Some("not_armed"),
Some("node"),
)
.unwrap();
let kept = relay_arming(dir.path());
assert_eq!(
kept.get("desktop"),
Some(&RelayArming {
relay_arming: "armed_in_process".into(),
runtime: "node".into(),
at_ms: 100,
}),
"an older report never overwrites"
);
assert_eq!(kept.get("cli").map(|r| r.runtime.as_str()), Some("bun"));
record_report(dir.path(), "desktop", 200, None, None, None).unwrap();
assert!(!relay_arming(dir.path()).contains_key("desktop"));
record_report(
dir.path(),
"desktop",
205,
None,
Some("armed_in_process"),
Some("node"),
)
.unwrap();
record_report(
dir.path(),
"desktop",
210,
None,
Some(&"x".repeat(65)),
Some("node"),
)
.unwrap();
assert!(!relay_arming(dir.path()).contains_key("desktop"));
assert_eq!(
relay_arming(dir.path())
.get("cli")
.map(|r| r.runtime.as_str()),
Some("bun"),
"another surface's evidence is kept"
);
record_report(
dir.path(),
"desktop",
300,
None,
Some("not_armed"),
Some("node"),
)
.unwrap();
assert_eq!(
relay_arming(dir.path())["desktop"].relay_arming,
"not_armed"
);
assert_eq!(relay_arming(dir.path())["desktop"].at_ms, 300);
let seen = read_seen(dir.path());
assert!(
!seen.contains_key(RELAY_KEY),
"the arming map is not a surface"
);
assert_eq!(seen.get("desktop"), Some(&300));
}
#[test]
fn surfaces_map_per_c3() {
assert_eq!(surface_of(Some("cli")), "cli");
assert_eq!(surface_of(Some("acp")), "acp");
assert_eq!(surface_of(Some("desktop")), "desktop");
assert_eq!(surface_of(Some("web")), "unknown");
assert_eq!(surface_of(None), "unknown");
}
}