master
John Lauer 1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati… 0009b41 14d ago
//! kicad_search_library: find symbols and footprints across every registered library
//! (global and project sym-lib-table / fp-lib-table, URIs expanded the way KiCad expands
//! them) by name, keyword and description, ranked. Library contents are cached per file
//! by modification time, so a second search over the stock libraries is fast.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;

use serde_json::{json, Value};

use crate::libraries::{expand_kicad_uri, list_pretty, norm_path, LibCtx, LibTable};

#[derive(Clone, Debug, PartialEq)]
pub struct LibRef {
    pub kind: &'static str,
    pub nickname: String,
    pub path: PathBuf,
    pub scope: &'static str,
}

#[derive(Clone, Debug)]
pub struct Entry {
    pub name: String,
    pub description: String,
    pub keywords: String,
}

/// Every enabled library of `kind` ("symbol" or "footprint"): the project's table first
/// (it wins on a nickname clash, as in KiCad), then the global table.
pub fn registered(ctx: &LibCtx, project_dir: Option<&Path>, kind: &'static str) -> (Vec<LibRef>, Vec<String>) {
    let mut out: Vec<LibRef> = Vec::new();
    let mut skipped: Vec<String> = Vec::new();
    let table_name = if kind == "symbol" { "sym-lib-table" } else { "fp-lib-table" };
    let mut tables: Vec<(PathBuf, &'static str)> = Vec::new();
    if let Some(p) = project_dir {
        tables.push((p.join(table_name), "project"));
    }
    if let Some(g) = if kind == "symbol" { ctx.sym_lib_table() } else { ctx.fp_lib_table() } {
        tables.push((g, "global"));
    }
    for (t, scope) in tables {
        if !t.is_file() {
            continue;
        }
        let Ok(table) = LibTable::parse_file_resolved(&t) else {
            skipped.push(format!("{} (unreadable)", norm_path(&t)));
            continue;
        };
        for e in table.entries.iter().filter(|e| !e.disabled) {
            if out.iter().any(|r| r.nickname == e.name) {
                continue;
            }
            let uri = match project_dir {
                Some(p) => e.uri.replace("${KIPRJMOD}", &norm_path(p)),
                None => e.uri.clone(),
            };
            match expand_kicad_uri(&uri, ctx) {
                Some(p) => out.push(LibRef { kind, nickname: e.name.clone(), path: PathBuf::from(p), scope }),
                None => skipped.push(format!("{} ({})", e.name, e.uri)),
            }
        }
    }
    (out, skipped)
}

static CACHE: Mutex<Option<HashMap<PathBuf, (SystemTime, Vec<Entry>)>>> = Mutex::new(None);

fn mtime(p: &Path) -> Option<SystemTime> {
    std::fs::metadata(p).and_then(|m| m.modified()).ok()
}

/// The quoted value after `(property "<key>" ` in a block, if present.
fn property_in(block: &str, key: &str) -> String {
    let pat = format!("(property \"{key}\" \"");
    let Some(i) = block.find(&pat) else { return String::new() };
    let rest = &block[i + pat.len()..];
    let mut out = String::new();
    let mut esc = false;
    for c in rest.chars() {
        if esc {
            out.push(c);
            esc = false;
        } else if c == '\\' {
            esc = true;
        } else if c == '"' {
            break;
        } else {
            out.push(c);
        }
    }
    out
}

/// `(descr "...")` / `(tags "...")` near the top of a footprint file.
fn atom_in(text: &str, key: &str) -> String {
    let pat = format!("({key} \"");
    let Some(i) = text.find(&pat) else { return String::new() };
    text[i + pat.len()..].split('"').next().unwrap_or("").to_string()
}

/// Symbols of a .kicad_sym with their description and keywords.
pub fn symbol_entries(text: &str) -> Vec<Entry> {
    let (blocks, _) = crate::libraries::extract_symbols_tolerant(text);
    blocks
        .into_iter()
        .map(|(name, block)| Entry { description: property_in(&block, "Description"), keywords: property_in(&block, "ki_keywords"), name })
        .collect()
}

fn entries_for(r: &LibRef) -> Vec<Entry> {
    let key = r.path.clone();
    let stamp = if r.kind == "symbol" { mtime(&r.path) } else { mtime(&r.path) };
    if let (Some(t), Ok(g)) = (stamp, CACHE.lock()) {
        if let Some((ct, e)) = g.as_ref().and_then(|m| m.get(&key)) {
            if *ct == t {
                return e.clone();
            }
        }
    }
    let entries: Vec<Entry> = if r.kind == "symbol" {
        std::fs::read_to_string(&r.path).map(|t| symbol_entries(&t)).unwrap_or_default()
    } else {
        list_pretty(&r.path)
            .into_iter()
            .map(|name| {
                // descr and tags sit in the first lines; read a bounded head only.
                let mut head = String::new();
                if let Ok(mut f) = std::fs::File::open(r.path.join(format!("{name}.kicad_mod"))) {
                    use std::io::Read;
                    let mut buf = vec![0u8; 4096];
                    if let Ok(n) = f.read(&mut buf) {
                        head = String::from_utf8_lossy(&buf[..n]).to_string();
                    }
                }
                Entry { description: atom_in(&head, "descr"), keywords: atom_in(&head, "tags"), name }
            })
            .collect()
    };
    if let (Some(t), Ok(mut g)) = (stamp, CACHE.lock()) {
        g.get_or_insert_with(HashMap::new).insert(key, (t, entries.clone()));
    }
    entries
}

/// Rank one entry against the query tokens; None when a token matches nowhere.
pub fn score(q: &str, tokens: &[String], nickname: &str, e: &Entry) -> Option<f64> {
    let name = e.name.to_lowercase();
    let lib = nickname.to_lowercase();
    let kw = e.keywords.to_lowercase();
    let desc = e.description.to_lowercase();
    let mut s = 0.0;
    for t in tokens {
        let mut hit = false;
        if name.contains(t.as_str()) {
            s += 15.0;
            hit = true;
        }
        if kw.split_whitespace().any(|k| k == t) {
            s += 10.0;
            hit = true;
        } else if kw.contains(t.as_str()) {
            s += 6.0;
            hit = true;
        }
        if desc.contains(t.as_str()) {
            s += 4.0;
            hit = true;
        }
        if lib.contains(t.as_str()) {
            s += 3.0;
            hit = true;
        }
        if !hit {
            return None;
        }
    }
    let ql = q.to_lowercase();
    let full = format!("{lib}:{name}");
    if name == ql || full == ql {
        s += 100.0;
    } else if name.starts_with(&ql) {
        s += 50.0;
    } else if name.contains(&ql) {
        s += 25.0;
    }
    // shorter names first among equals: "R" before "R_Pack08"
    s -= (name.len() as f64) * 0.01;
    Some(s)
}

/// Search `libs` for `query`. Returns (ranked hits, libraries scanned, entries scanned).
pub fn search(libs: &[LibRef], query: &str, limit: usize) -> (Vec<Value>, usize, usize) {
    let tokens: Vec<String> = query.to_lowercase().split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).map(str::to_string).collect();
    let mut hits: Vec<(f64, Value)> = Vec::new();
    let mut scanned = 0;
    let mut libs_scanned = 0;
    for r in libs {
        if !(r.path.is_file() || r.path.is_dir()) {
            continue;
        }
        libs_scanned += 1;
        for e in entries_for(r) {
            scanned += 1;
            if let Some(s) = score(query, &tokens, &r.nickname, &e) {
                hits.push((s, json!({
                    "kind": r.kind, "libId": format!("{}:{}", r.nickname, e.name), "library": r.nickname, "name": e.name,
                    "description": e.description, "keywords": e.keywords, "scope": r.scope, "libraryPath": norm_path(&r.path),
                    "score": (s * 10.0).round() / 10.0,
                })));
            }
        }
    }
    hits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap().then_with(|| a.1["libId"].as_str().cmp(&b.1["libId"].as_str())));
    (hits.into_iter().take(limit.max(1)).map(|(_, v)| v).collect(), libs_scanned, scanned)
}

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

    fn repo(rel: &str) -> PathBuf {
        Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)
    }

    #[test]
    fn project_tables_expand_kiprjmod_and_rank_hits() {
        let d = std::env::temp_dir().join(format!("kc-libsearch-{}-{}", std::process::id(), crate::schematic::new_uuid()));
        std::fs::create_dir_all(&d).unwrap();
        let sym = norm_path(&repo("pcm/src/adom-sample-lib/symbols"));
        let fp = norm_path(&repo("pcm/src/adom-sample-lib/footprints"));
        std::fs::write(d.join("sym-lib-table"), format!("(sym_lib_table (version 7) (lib (name \"Sample\") (type \"KiCad\") (uri \"{sym}/Adom_Sample.kicad_sym\") (options \"\") (descr \"\")) (lib (name \"Off\") (type \"KiCad\") (uri \"${{KIPRJMOD}}/off.kicad_sym\") (options \"\") (descr \"\") (disabled)))")).unwrap();
        std::fs::write(d.join("fp-lib-table"), format!("(fp_lib_table (version 7) (lib (name \"SampleFp\") (type \"KiCad\") (uri \"{fp}/Adom_Sample.pretty\") (options \"\") (descr \"\")) (lib (name \"Local\") (type \"KiCad\") (uri \"${{KIPRJMOD}}/Local.pretty\") (options \"\") (descr \"\")))")).unwrap();
        std::fs::create_dir_all(d.join("Local.pretty")).unwrap();
        std::fs::write(d.join("Local.pretty/MyPad.kicad_mod"), "(footprint \"MyPad\" (version 20260206) (descr \"test pad for resistor probing\") (tags \"probe\"))").unwrap();
        let ctx = LibCtx::default();
        let (syms, skipped) = registered(&ctx, Some(&d), "symbol");
        assert_eq!(syms.len(), 1, "the disabled row is left out");
        assert!(skipped.is_empty());
        let (fps, _) = registered(&ctx, Some(&d), "footprint");
        assert_eq!(fps.len(), 2);
        assert_eq!(fps[1].path, PathBuf::from(norm_path(&d.join("Local.pretty"))));

        let (hits, libs, scanned) = search(&syms, "resistor", 10);
        assert_eq!(libs, 1);
        assert!(scanned >= 10);
        assert_eq!(hits[0]["libId"], "Sample:AdomSample_R", "{hits:?}");
        let (hits, _, _) = search(&syms, "AdomSample_C", 10);
        assert_eq!(hits[0]["name"], "AdomSample_C", "exact name first");
        let mut all = syms.clone();
        all.extend(fps.clone());
        let (hits, _, _) = search(&all, "soic 8", 5);
        assert!(hits.iter().any(|h| h["kind"] == "footprint" && h["name"].as_str().unwrap().contains("SOIC-8")), "{hits:?}");
        let (hits, _, _) = search(&fps, "probe", 5);
        assert_eq!(hits[0]["libId"], "Local:MyPad");
        assert_eq!(hits[0]["scope"], "project");
        let (none, _, _) = search(&all, "zzzz-no-such-thing", 5);
        assert!(none.is_empty());
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn scoring_requires_every_token() {
        let e = Entry { name: "LM358".into(), description: "Dual operational amplifier".into(), keywords: "opamp dual".into() };
        assert!(score("dual opamp", &["dual".into(), "opamp".into()], "Amplifier_Operational", &e).is_some());
        assert!(score("quad opamp", &["quad".into(), "opamp".into()], "Amplifier_Operational", &e).is_none());
        let exact = score("lm358", &["lm358".into()], "X", &e).unwrap();
        let loose = score("amplifier", &["amplifier".into()], "X", &e).unwrap();
        assert!(exact > loose);
    }
}