main
Drew Owens 0.3.3 source + crate build: BOM exported from the SOLIDWORKS assembly, build log §1–11, release script 477e64f 1mo ago
//! `swx_find_files` — a disk search for SOLIDWORKS documents.
//!
//! Deliberately independent of COM: this verb answers "what do I have?" with
//! SOLIDWORKS closed, not installed, or mid-trial. It is the first step of the
//! everyday flow, so it must never depend on the app being up.
//!
//! What it skips, and why: `~$…` files (SOLIDWORKS lock/temp shadows of open
//! documents — offering one as a path is a trap), dot- and `$`-prefixed
//! directories (VCS metadata, `$RECYCLE.BIN`, `$WINDOWS.~BT`), and
//! `node_modules` (huge, never CAD).

use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::path::PathBuf;
use std::time::{Duration, Instant, UNIX_EPOCH};

/// `.ext` -> the `type` string a hit carries. The one place the mapping lives.
pub const SW_EXTENSIONS: &[(&str, &str)] = crate::comhost::mock::DOC_TYPES;

#[derive(Debug, Clone)]
pub struct Hit {
    pub path: String,
    pub name: String,
    pub kind: &'static str,
    pub bytes: u64,
    pub modified: i64,
}

impl Hit {
    pub fn to_json(&self) -> serde_json::Value {
        serde_json::json!({
            "path": self.path,
            "name": self.name,
            "type": self.kind,
            "bytes": self.bytes,
            "modified": self.modified,
        })
    }
}

/// Map a `types:[...]` filter ("part"/"assembly"/"drawing") to extensions.
/// An empty or unrecognized filter means "all SOLIDWORKS documents" — matching
/// the Python behaviour (an empty list was falsy → all) and this function's own
/// contract, which the previous version quietly contradicted by returning [].
pub fn wanted_extensions(types: Option<&[String]>) -> Vec<&'static str> {
    let all = || SW_EXTENSIONS.iter().map(|(e, _)| *e).collect::<Vec<_>>();
    match types {
        None => all(),
        Some(list) => {
            let picked: Vec<&'static str> = SW_EXTENSIONS
                .iter()
                .filter(|(_, t)| list.iter().any(|w| w.eq_ignore_ascii_case(t)))
                .map(|(e, _)| *e)
                .collect();
            if picked.is_empty() {
                all()
            } else {
                picked
            }
        }
    }
}

fn skip_dir(name: &str) -> bool {
    name.starts_with('.') || name.starts_with('$') || name.eq_ignore_ascii_case("node_modules")
}

/// Walk `roots`, newest first. Returns (hits, truncated) — truncated is true
/// when `limit` or the time budget cut the walk short, so the caller can say so
/// instead of quietly lying about completeness.
pub fn search(
    roots: &[String],
    want: &[&'static str],
    query: &str,
    limit: usize,
    budget: Duration,
) -> (Vec<Hit>, bool) {
    let query = query.to_lowercase();
    let started = Instant::now();
    let mut truncated = false;

    // Keep only the `limit` NEWEST matches as we walk, in a bounded min-heap
    // keyed by (mtime, path): O(limit) memory, not O(total matches), while still
    // giving correct newest-first ordering — the naive "collect all, sort,
    // truncate" bought correctness at the cost of buffering an entire disk of
    // CAD files. The min-heap's smallest element is the oldest survivor, so once
    // we are at capacity every further match is compared against it and the
    // loser is dropped. `matched` counts everything seen so truncation is honest.
    let mut heap: BinaryHeap<Reverse<HeapHit>> = BinaryHeap::with_capacity(limit + 1);
    let mut matched: usize = 0;

    'roots: for root in roots {
        let root_path = PathBuf::from(root);
        if !root_path.is_dir() {
            continue;
        }
        let mut stack = vec![root_path];
        while let Some(dir) = stack.pop() {
            if started.elapsed() > budget {
                truncated = true;
                break 'roots;
            }
            let entries = match std::fs::read_dir(&dir) {
                Ok(e) => e,
                Err(_) => continue, // unreadable dir: skip it, never fail the verb
            };
            for entry in entries.flatten() {
                let name = entry.file_name().to_string_lossy().to_string();
                let file_type = match entry.file_type() {
                    Ok(t) => t,
                    Err(_) => continue,
                };
                if file_type.is_dir() {
                    if !skip_dir(&name) {
                        stack.push(entry.path());
                    }
                    continue;
                }
                let lower = name.to_lowercase();
                let kind = match SW_EXTENSIONS
                    .iter()
                    .find(|(ext, _)| lower.ends_with(ext) && want.contains(ext))
                {
                    Some((_, k)) => *k,
                    None => continue,
                };
                if name.starts_with("~$") {
                    continue;
                }
                if !query.is_empty() && !lower.contains(&query) {
                    continue;
                }
                let meta = match entry.metadata() {
                    Ok(m) => m,
                    Err(_) => continue,
                };
                let modified = meta
                    .modified()
                    .ok()
                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
                    .map(|d| d.as_secs() as i64)
                    .unwrap_or(0);
                matched += 1;
                let hit = Hit {
                    path: entry.path().to_string_lossy().to_string(),
                    name,
                    kind,
                    bytes: meta.len(),
                    modified,
                };
                heap.push(Reverse(HeapHit(hit)));
                if heap.len() > limit {
                    // Drop the current oldest — it cannot be in the newest `limit`.
                    heap.pop();
                }
            }
        }
    }
    if matched > heap.len() {
        truncated = true; // more matched than we returned — say so
    }
    let mut hits: Vec<Hit> = heap.into_iter().map(|Reverse(h)| h.0).collect();
    hits.sort_by(|a, b| b.modified.cmp(&a.modified));
    (hits, truncated)
}

/// A `Hit` ordered by (modified, path) — a TOTAL order so the min-heap that
/// keeps the newest matches is deterministic across equal mtimes.
struct HeapHit(Hit);

impl PartialEq for HeapHit {
    fn eq(&self, other: &Self) -> bool {
        self.cmp(other) == std::cmp::Ordering::Equal
    }
}
impl Eq for HeapHit {}
impl PartialOrd for HeapHit {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}
impl Ord for HeapHit {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.0
            .modified
            .cmp(&other.0.modified)
            .then_with(|| self.0.path.cmp(&other.0.path))
    }
}

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

    struct Tmp(PathBuf);
    impl Drop for Tmp {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    fn fixture(tag: &str, names: &[&str]) -> Tmp {
        let dir = std::env::temp_dir().join(format!("swx-ff-{}-{tag}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        for n in names {
            let p = dir.join(n);
            if let Some(parent) = p.parent() {
                std::fs::create_dir_all(parent).unwrap();
            }
            std::fs::write(p, b"x").unwrap();
        }
        Tmp(dir)
    }

    fn paths(hits: &[Hit]) -> Vec<String> {
        hits.iter().map(|h| h.name.clone()).collect()
    }

    #[test]
    fn finds_documents_and_skips_lock_files_and_noise() {
        let t = fixture("basic", &["a.sldprt", "b.sldasm", "~$b.sldasm", "c.txt"]);
        let want = wanted_extensions(None);
        let (hits, truncated) =
            search(&[t.0.to_string_lossy().to_string()], &want, "", 200, Duration::from_secs(5));
        assert_eq!(hits.len(), 2, "{:?}", paths(&hits));
        assert!(!truncated);
    }

    #[test]
    fn honors_type_filter_query_and_limit() {
        let t = fixture("filters", &["alpha.sldprt", "beta.sldasm", "gamma.slddrw"]);
        let root = vec![t.0.to_string_lossy().to_string()];

        let want = wanted_extensions(Some(&["assembly".to_string()]));
        let (hits, _) = search(&root, &want, "", 200, Duration::from_secs(5));
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].kind, "assembly");

        let all = wanted_extensions(None);
        let (hits, _) = search(&root, &all, "alph", 200, Duration::from_secs(5));
        assert_eq!(paths(&hits), vec!["alpha.sldprt"]);

        let (hits, truncated) = search(&root, &all, "", 2, Duration::from_secs(5));
        assert_eq!(hits.len(), 2);
        assert!(truncated, "hitting the limit must be reported, not hidden");
    }

    #[test]
    fn descends_into_subdirs_but_not_into_dot_dollar_or_node_modules() {
        let t = fixture(
            "walk",
            &[
                "sub/deep.sldprt",
                ".git/hidden.sldprt",
                "$RECYCLE.BIN/trash.sldprt",
                "node_modules/pkg.sldprt",
            ],
        );
        let want = wanted_extensions(None);
        let (hits, _) =
            search(&[t.0.to_string_lossy().to_string()], &want, "", 200, Duration::from_secs(5));
        assert_eq!(paths(&hits), vec!["deep.sldprt"]);
    }

    #[test]
    fn newest_first_survives_the_limit_even_across_subdirs() {
        // The regression this guards: the newest file lives in a subdir the walk
        // reaches LAST, and the limit is smaller than the total. It must still be
        // returned — truncation happens after the mtime sort, not during the walk.
        let t = fixture("newest", &["old1.sldprt", "old2.sldprt", "deep/newest.sldprt"]);
        let set_mtime = |rel: &str, secs: u64| {
            let f = std::fs::OpenOptions::new().write(true).open(t.0.join(rel)).unwrap();
            f.set_modified(UNIX_EPOCH + Duration::from_secs(secs)).unwrap();
        };
        set_mtime("old1.sldprt", 1_000);
        set_mtime("old2.sldprt", 2_000);
        set_mtime("deep/newest.sldprt", 9_000);

        let want = wanted_extensions(None);
        let (hits, truncated) =
            search(&[t.0.to_string_lossy().to_string()], &want, "", 1, Duration::from_secs(5));
        assert_eq!(hits.len(), 1);
        assert!(truncated);
        assert_eq!(hits[0].name, "newest.sldprt", "the newest file must win the single slot");
    }

    #[test]
    fn an_empty_or_unmatched_types_filter_means_all_documents() {
        let t = fixture("emptytypes", &["a.sldprt", "b.sldasm", "c.slddrw"]);
        let root = vec![t.0.to_string_lossy().to_string()];
        let empty: Vec<String> = vec![];
        let (hits, _) =
            search(&root, &wanted_extensions(Some(&empty)), "", 200, Duration::from_secs(5));
        assert_eq!(hits.len(), 3, "an empty types list must not filter everything out");
        let bogus = vec!["gerber".to_string()];
        let (hits, _) =
            search(&root, &wanted_extensions(Some(&bogus)), "", 200, Duration::from_secs(5));
        assert_eq!(hits.len(), 3, "an unrecognized type must fall back to all, not none");
    }

    #[test]
    fn a_missing_root_is_skipped_not_fatal() {
        let want = wanted_extensions(None);
        let (hits, truncated) = search(
            &["/no/such/place/at/all".to_string()],
            &want,
            "",
            200,
            Duration::from_secs(5),
        );
        assert!(hits.is_empty());
        assert!(!truncated);
    }
}