app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
Drew Owens
0.3.3 source + crate build: BOM exported from the SOLIDWORKS assembly, build log §1–11, release script
477e64f
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
//! `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);
}
}