master
Colby Knox Merge PR #433: 1.0.104: close verbs keep another AI thread's document open (closes #139) bf8eb20 5d ago
//! Which AI threads opened or drove each KiCad document (wiki #139).
//!
//! Several AI threads share one desktop KiCad. A thread that closes "everything" must not
//! close a board another thread is working on, so every verb that drives the live editor on
//! a named document leaves a note here: document -> {thread -> when}. `kicad_close_all_documents`
//! and `kicad_close` read it and keep another thread's document open unless the caller forces
//! it with a reason.
//!
//! Documents are keyed on their lower-cased file stem, because that is all KiCad's window
//! titles give (`led-strip-500 — PCB Editor`); a board and its schematic share a stem and so
//! share a key. The table is mirrored to `~/.adom/kicad-bridge-doc-users.json` so a bridge
//! restart does not forget who has what open. A note older than `MAX_AGE_SECS` no longer
//! counts, and a document that is not open any more is dropped whenever a guard looks.
//!
//! The asker of the verb being served lives in a thread-local: each request runs on its own
//! worker thread, while the process-wide `ab` caller can be overwritten by a read verb that
//! another thread sends at the same moment.

use serde_json::Value;
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};

/// A note older than this (12 h) no longer protects a document.
pub const MAX_AGE_SECS: u64 = 12 * 3600;

/// The argument keys a verb names its document with.
const PATH_KEYS: &[&str] = &["filePath", "path", "project", "boardPath", "schematicPath", "pcbPath", "file"];
const DOC_EXTS: &[&str] = &["kicad_pcb", "kicad_sch", "kicad_pro"];

/// Who is asking for the verb being served on this worker thread.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Asker {
    pub thread: String,
    pub reason: String,
}

thread_local! {
    static ASKER: RefCell<Option<Asker>> = const { RefCell::new(None) };
}

/// Restores the previous asker when dropped, so a composite verb's inner call does not leak.
pub struct AskerScope(Option<Asker>);

impl Drop for AskerScope {
    fn drop(&mut self) {
        let prev = self.0.take();
        ASKER.with(|a| *a.borrow_mut() = prev);
    }
}

/// Make `thread` the asker on this worker thread until the returned scope drops.
pub fn set_asker(thread: &str, reason: &str) -> AskerScope {
    let prev = ASKER.with(|a| a.borrow_mut().replace(Asker { thread: thread.to_string(), reason: reason.to_string() }));
    AskerScope(prev)
}

/// The asker of the verb being served here (empty when none was set).
pub fn asker() -> Asker {
    ASKER.with(|a| a.borrow().clone()).unwrap_or_default()
}

/// A thread name that identifies nobody: no header, the bridge itself, or a placeholder.
pub fn is_anonymous(thread: &str) -> bool {
    let t = thread.trim();
    t.is_empty() || t == "?" || t.eq_ignore_ascii_case(crate::progress::ANONYMOUS) || t == "kicad-bridge" || t == crate::ab::SELF_THREAD
}

/// The key of a document path (`C:/p/led-strip-500.kicad_pcb`) or of a title's name (`led-strip-500`).
pub fn doc_key(path_or_name: &str) -> Option<String> {
    let s = path_or_name.trim().replace('\\', "/");
    if s.is_empty() {
        return None;
    }
    let p = Path::new(&s);
    let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
    match ext.as_deref() {
        Some(e) if DOC_EXTS.contains(&e) => p.file_stem().map(|x| x.to_string_lossy().to_lowercase()),
        _ if !s.contains('/') => Some(s.to_lowercase()),
        _ => None,
    }
}

/// The documents a verb's arguments name (board, schematic or project paths only).
pub fn doc_keys_in(args: &Value) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    for k in PATH_KEYS {
        if let Some(s) = args.get(*k).and_then(Value::as_str) {
            let has_ext = Path::new(&s.replace('\\', "/")).extension().and_then(|e| e.to_str()).is_some_and(|e| DOC_EXTS.contains(&e.to_ascii_lowercase().as_str()));
            if let (true, Some(key)) = (has_ext, doc_key(s)) {
                if !out.contains(&key) {
                    out.push(key);
                }
            }
        }
    }
    out
}

type Table = BTreeMap<String, BTreeMap<String, u64>>;

struct Store {
    table: Option<Table>,
    path: Option<PathBuf>,
}

static STORE: Mutex<Store> = Mutex::new(Store { table: None, path: None });

fn store() -> std::sync::MutexGuard<'static, Store> {
    STORE.lock().unwrap_or_else(|e| e.into_inner())
}

fn default_path() -> PathBuf {
    let base = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
    base.join(".adom").join("kicad-bridge-doc-users.json")
}

/// Point the table at another file (tests); the next read comes from there.
pub fn set_state_path(path: Option<PathBuf>) {
    let mut s = store();
    s.path = path;
    s.table = None;
}

fn now() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

fn with_table<R>(write: bool, f: impl FnOnce(&mut Table) -> R) -> R {
    let mut s = store();
    let path = s.path.clone().unwrap_or_else(default_path);
    if s.table.is_none() {
        let loaded: Table = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
        s.table = Some(loaded);
    }
    let table = s.table.as_mut().expect("loaded above");
    let r = f(table);
    if write {
        if let Some(dir) = path.parent() {
            let _ = std::fs::create_dir_all(dir);
        }
        if let Ok(text) = serde_json::to_string(table) {
            let _ = std::fs::write(&path, text);
        }
    }
    r
}

/// Record that `thread` drove the documents `args` names. Anonymous callers leave no note.
pub fn note(thread: &str, args: &Value) {
    let keys = doc_keys_in(args);
    if keys.is_empty() || is_anonymous(thread) {
        return;
    }
    let t = now();
    with_table(true, |tab| {
        for k in keys {
            tab.entry(k).or_default().insert(thread.to_string(), t);
        }
        tab.retain(|_, users| {
            users.retain(|_, at| t.saturating_sub(*at) < MAX_AGE_SECS);
            !users.is_empty()
        });
    });
}

/// The other named threads that opened or drove this document recently, most recent first.
pub fn others(name: &str, me: &str) -> Vec<String> {
    let Some(key) = doc_key(name) else { return Vec::new() };
    let t = now();
    with_table(false, |tab| {
        let mut v: Vec<(&String, &u64)> = tab.get(&key).map(|u| u.iter().filter(|(th, at)| th.as_str() != me && !is_anonymous(th) && t.saturating_sub(**at) < MAX_AGE_SECS).collect()).unwrap_or_default();
        v.sort_by(|a, b| b.1.cmp(a.1));
        v.into_iter().map(|(th, _)| th.clone()).collect()
    })
}

/// Forget a document (it was closed).
pub fn forget(name: &str) {
    if let Some(key) = doc_key(name) {
        with_table(true, |tab| {
            tab.remove(&key);
        });
    }
}

/// Forget every document that is not among `open` (closed some other way, by hand or by a crash).
pub fn keep_only(open: &[String]) {
    let keys: Vec<String> = open.iter().filter_map(|n| doc_key(n)).collect();
    with_table(true, |tab| tab.retain(|k, _| keys.contains(k)));
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    static TEST_LOCK: Mutex<()> = Mutex::new(());

    fn fresh(tag: &str) -> (std::sync::MutexGuard<'static, ()>, PathBuf) {
        let g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let p = std::env::temp_dir().join(format!("kicad-doc-users-{tag}-{}.json", std::process::id()));
        let _ = std::fs::remove_file(&p);
        set_state_path(Some(p.clone()));
        (g, p)
    }

    #[test]
    fn keys_come_from_document_paths_and_titles() {
        assert_eq!(doc_key("C:\\boards\\LED-Strip-500.kicad_pcb").as_deref(), Some("led-strip-500"));
        assert_eq!(doc_key("C:/p/x.kicad_sch").as_deref(), Some("x"));
        assert_eq!(doc_key("led-strip-500").as_deref(), Some("led-strip-500"));
        assert_eq!(doc_key("C:/p/notes.txt"), None);
        let keys = doc_keys_in(&json!({"filePath": "C:/a/b.kicad_pcb", "path": "C:/a/b.kicad_sch", "project": "C:/a/out.step", "x": "C:/a/c.kicad_pcb"}));
        assert_eq!(keys, vec!["b".to_string()]);
    }

    #[test]
    fn another_threads_document_is_reported_and_mine_is_not() {
        let (_g, p) = fresh("others");
        note("LED strip 500mm aiflow", &json!({"filePath": "C:/w/led-strip-500.kicad_pcb"}));
        note("noah check", &json!({"filePath": "C:/w/am32-esc-v1-aiflow-check.kicad_pcb"}));
        assert_eq!(others("led-strip-500", "noah check"), vec!["LED strip 500mm aiflow".to_string()]);
        assert!(others("am32-esc-v1-aiflow-check", "noah check").is_empty());
        assert!(others("led-strip-500", "LED strip 500mm aiflow").is_empty());
        // an anonymous asker sees every named thread as someone else
        assert_eq!(others("am32-esc-v1-aiflow-check", "?"), vec!["noah check".to_string()]);
        // it survives a bridge restart
        set_state_path(Some(p.clone()));
        assert_eq!(others("led-strip-500", "noah check"), vec!["LED strip 500mm aiflow".to_string()]);
        forget("led-strip-500");
        assert!(others("led-strip-500", "noah check").is_empty());
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn anonymous_callers_leave_no_note_and_closed_documents_drop_out() {
        let (_g, p) = fresh("anon");
        note("?", &json!({"filePath": "C:/w/a.kicad_pcb"}));
        note("kicad-bridge", &json!({"filePath": "C:/w/a.kicad_pcb"}));
        assert!(others("a", "me").is_empty());
        note("t1", &json!({"filePath": "C:/w/a.kicad_pcb"}));
        note("t2", &json!({"filePath": "C:/w/b.kicad_sch"}));
        keep_only(&["b".to_string()]);
        assert!(others("a", "me").is_empty());
        assert_eq!(others("b", "me"), vec!["t2".to_string()]);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn an_old_note_no_longer_counts() {
        let (_g, p) = fresh("age");
        let old = now() - MAX_AGE_SECS - 5;
        std::fs::write(&p, json!({"a": {"t-old": old, "t-new": now()}}).to_string()).unwrap();
        set_state_path(Some(p.clone()));
        assert_eq!(others("a", "me"), vec!["t-new".to_string()]);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn the_asker_is_per_thread_and_restored_after_an_inner_call() {
        let _outer = set_asker("thread A", "close mine");
        {
            let _inner = set_asker("thread B", "");
            assert_eq!(asker().thread, "thread B");
        }
        assert_eq!(asker(), Asker { thread: "thread A".into(), reason: "close mine".into() });
        let other = std::thread::spawn(asker).join().unwrap();
        assert_eq!(other, Asker::default());
    }
}