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)
}
pub fn record_report(
ol_dir: &Path,
surface: &str,
at_ms: i64,
plugins: Option<&[String]>,
) -> 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));
}
crate::fs_secure::write_json_state(&path, &file)
}
#[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)).unwrap();
record_report(dir.path(), "cli", 400, Some(&[])).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 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");
}
}