main
Drew Owens 0.9.0: bridge data moved out of the install cache (recovery survives updates), closed-in-place records, gates route thr… 5d02ca9 20d ago
//! HOW MUCH ROOM IS LEFT, and what this sweep is about to need.
//!
//! 0.7.2 verifies every recovery file by container marker and size, and pass 24
//! found the hole that leaves: the marker sits at offset 4, so ANY prefix of a
//! real document longer than eight bytes carries it. A write that reaches 20 KB
//! of a 500 KB assembly passes the marker AND the 16 KB floor. Marker and floor
//! together catch "empty" and "not a SOLIDWORKS file"; neither catches **the
//! right file, cut short**, and the format offers nothing cheap that would — no
//! constant tail, no length field, no head/tail relationship (measured).
//!
//! So the remaining defence is not in the file. It is in the decision to START:
//! this sweep writes every open document to disk, and it must refuse to begin
//! when the volume holds less room than the session it is about to write. A
//! truncation it cannot detect afterwards is one it declines to cause.

use std::path::Path;

/// Free bytes available on the volume holding `dir`, or `None` when that cannot
/// be measured.
///
/// `None` is an honest UNKNOWN and callers must treat it as one: this bridge does
/// not read "I could not measure the disk" as "there is room". The rule this
/// project keeps relearning is that an unanswerable question is not permission.
pub fn free_bytes(dir: &Path) -> Option<u64> {
    // A TEST KNOB, and the only way to hold a nearly-full volume in a suite.
    // A comma-separated list is consumed one entry per read, the last repeating,
    // so a scenario can be "room to start, and then the disk fills" — which is
    // the case that needs re-checking inside the loop rather than once at the
    // top.
    if let Ok(v) = std::env::var("SWX_TEST_FREE_BYTES") {
        let vals: Vec<u64> = v.split(',').filter_map(|x| x.trim().parse().ok()).collect();
        if !vals.is_empty() {
            let f = crate::scratch::root().join("test-free-bytes-reads");
            let n = std::fs::read_to_string(&f)
                .ok()
                .and_then(|t| t.trim().parse::<usize>().ok())
                .unwrap_or(0);
            let _ = std::fs::write(&f, (n + 1).to_string());
            return Some(vals[n.min(vals.len() - 1)]);
        }
    }
    platform_free_bytes(dir)
}

#[cfg(windows)]
fn platform_free_bytes(dir: &Path) -> Option<u64> {
    use windows::core::HSTRING;
    use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
    // The directory need not exist yet — the sweep creates it — so ask about the
    // nearest ancestor that does.
    let mut probe = dir.to_path_buf();
    while !probe.exists() {
        match probe.parent() {
            Some(p) if p != probe => probe = p.to_path_buf(),
            _ => break,
        }
    }
    let mut free: u64 = 0;
    let h = HSTRING::from(probe.as_os_str());
    // FREE TO THE CALLER, not total free: on a volume with a quota those differ,
    // and the one that matters is what this process may actually write.
    unsafe { GetDiskFreeSpaceExW(&h, Some(&mut free), None, None) }.ok()?;
    Some(free)
}

#[cfg(not(windows))]
fn platform_free_bytes(dir: &Path) -> Option<u64> {
    // The bridge runs on Windows; this path exists so the suite measures a real
    // volume rather than a stub. `df -Pk` is POSIX-specified output: one header
    // line, then a row whose fourth field is available 1K-blocks.
    let mut probe = dir.to_path_buf();
    while !probe.exists() {
        match probe.parent() {
            Some(p) if p != probe => probe = p.to_path_buf(),
            _ => break,
        }
    }
    let out = std::process::Command::new("df").arg("-Pk").arg(&probe).output().ok()?;
    if !out.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&out.stdout);
    let row = text.lines().nth(1)?;
    let avail_kb: u64 = row.split_whitespace().nth(3)?.parse().ok()?;
    Some(avail_kb.saturating_mul(1024))
}

/// What one pathless document is assumed to cost when nothing better is known
/// and its kind is not known either.
pub const UNKNOWN_DOCUMENT_ESTIMATE: u64 = 1024 * 1024;

/// WHAT KIND OF DOCUMENT, because the three kinds are not the same size.
///
/// Measured over the 166 real documents in this container:
///
/// ```text
///          n     min        median      max
/// .SLDPRT  99    29,130     134,885     37,200,505
/// .SLDASM  38    95,492     4,139,430   16,644,232
/// .SLDDRW  29    319,803    1,958,169   32,250,713
/// ```
///
/// An assembly is thirty times the median part. Costing one as if it were a part
/// is how a per-save gate passes with far less room than the write needs.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DocKind {
    Part,
    Assembly,
    Drawing,
}

impl DocKind {
    /// From a FILE NAME'S EXTENSION, and nothing else. `None` when the extension
    /// is not one of the three.
    ///
    /// 0.7.4 substring-matched the whole hint, so a recovery folder called
    /// `…\Drawings\` reclassified every file under it as a drawing and a genuine
    /// median-sized part (134,885 bytes) was refused against the 256 KB drawing
    /// floor. A directory name is not evidence about a document.
    ///
    /// It returns `Option` because **the classifier must be able to say "I do not
    /// know"**. When it could not, every failure of the hint chain resolved to
    /// `Part` — the LOWEST floor on both sides — so an unreadable type produced
    /// the most permissive answer available. The two callers want opposite
    /// defaults for an unknown (the estimate wants generous, the verification
    /// wants strict), and neither can express that against a total function.
    pub fn from_extension(name: &str) -> Option<DocKind> {
        // The FILE's extension: a directory may contain dots, and everything
        // before the last separator is somebody's folder name, not this
        // document's kind.
        let file = name.rsplit(['/', '\\']).next().unwrap_or(name);
        let ext = file.rsplit_once('.').map(|(_, e)| e)?.to_ascii_lowercase();
        match ext.as_str() {
            "sldprt" => Some(DocKind::Part),
            "sldasm" => Some(DocKind::Assembly),
            "slddrw" => Some(DocKind::Drawing),
            _ => None,
        }
    }

    /// The general hint: an extension, and only an extension.
    pub fn from_hint(hint: &str) -> Option<DocKind> {
        Self::from_extension(hint)
    }

    /// A type NAME as the backends report it (`type` on the real one, `docType`
    /// on the simulator). Exact, not substring: this is a closed vocabulary.
    pub fn from_type_name(name: &str) -> Option<DocKind> {
        match name.trim().to_ascii_lowercase().as_str() {
            "part" => Some(DocKind::Part),
            "assembly" => Some(DocKind::Assembly),
            "drawing" => Some(DocKind::Drawing),
            _ => None,
        }
    }

    /// A TITLE: its extension if it has one, else the prefix SOLIDWORKS gives an
    /// unsaved document — `Assem1`, `Part3`, `Draw2`.
    ///
    /// The default title of a new assembly is `Assem1`, which 0.7.4 classified as
    /// a PART and costed at 256 KB — where having no hint at all would have cost
    /// the generic 1 MB, and the truth is an 8 MB assembly floor. A hint that
    /// makes the answer worse than no hint is worse than useless, and this is the
    /// common case rather than a corner.
    pub fn from_title(title: &str) -> Option<DocKind> {
        if let Some(k) = Self::from_extension(title) {
            return Some(k);
        }
        let t = title.trim().to_ascii_lowercase();
        if t.starts_with("assem") {
            Some(DocKind::Assembly)
        } else if t.starts_with("draw") {
            Some(DocKind::Drawing)
        } else if t.starts_with("part") {
            Some(DocKind::Part)
        } else {
            None
        }
    }

    /// The extension a recovery file of this kind is given.
    pub fn extension(self) -> &'static str {
        match self {
            DocKind::Part => "SLDPRT",
            DocKind::Assembly => "SLDASM",
            DocKind::Drawing => "SLDDRW",
        }
    }

    /// The name of this kind as the backends report it.
    pub fn type_name(self) -> &'static str {
        match self {
            DocKind::Part => "part",
            DocKind::Assembly => "assembly",
            DocKind::Drawing => "drawing",
        }
    }

    /// The least this kind of document is assumed to cost when its size cannot be
    /// measured. Each sits above the measured MEDIAN for its kind, because an
    /// estimate that is too low starts a write that cannot finish, and one that
    /// is too high refuses a sweep that would have worked.
    pub fn estimate_floor(self) -> u64 {
        match self {
            DocKind::Part => 256 * 1024,
            DocKind::Assembly => 8 * 1024 * 1024,
            DocKind::Drawing => 4 * 1024 * 1024,
        }
    }

    /// THE FLOOR TO APPLY WHEN THE KIND IS NOT KNOWN: the highest of the three.
    ///
    /// 0.7.4's comment argued the reverse — that a low default was right because
    /// "too high a floor refuses to close a real document". Both halves are true
    /// and the conclusion was backwards. A too-high floor REFUSES, loudly, naming
    /// the file and the number; the user loses nothing and runs it again. A
    /// too-low floor CLOSES a document against a file nobody checked. For a
    /// verification, refusing is the direction to fail in.
    pub fn unknown_kind_floor() -> u64 {
        DocKind::Drawing.min_recovery_bytes()
    }

    /// The least a written recovery file of this kind may be and still count.
    /// Each sits just BELOW the smallest real document of its kind measured here
    /// (29,130 / 95,492 / 319,803), so it can never reject a genuine document
    /// while still catching a write that stopped early.
    ///
    /// **THE PART FLOOR IS TIGHT, and the first live close said so.** On
    /// 2026-09-17 the sweep rescued four documents at 25,773-26,047 bytes each —
    /// an EMPTY part is about 25.8 KB, which clears the 24 KB floor by roughly
    /// 1.2 KB. That is the intended margin and not an accident: the floor exists
    /// to catch a write that stopped early, and an empty part is the smallest
    /// thing a real save can legitimately produce. But it is 5%, so anyone raising
    /// this number is raising it INTO real documents, and must measure a genuinely
    /// empty part on the target edition before touching it.
    pub fn min_recovery_bytes(self) -> u64 {
        match self {
            DocKind::Part => 24 * 1024,
            DocKind::Assembly => 80 * 1024,
            DocKind::Drawing => 256 * 1024,
        }
    }
}

/// The safety factor between what this sweep will write and the room it insists
/// on having first.
///
/// SaveAs3 does not write in place: a document is built and flushed, the old copy
/// of a re-saved file can still be on the volume while the new one lands, and the
/// estimate itself is only an estimate. Two times the estimate is the margin, and
/// it is applied to every check — the one before the sweep starts and the one
/// before each save.
pub const HEADROOM_FACTOR: u64 = 2;

/// WHAT THIS SWEEP IS ABOUT TO WRITE.
///
/// A document with a readable path costs about what it costs on disk now. One
/// with no path, or whose path this process cannot stat, costs an ESTIMATE that
/// never falls below the floor for its KIND. Unknowns are rounded UP, because the
/// direction of a wrong estimate decides whether this refuses a sweep it could
/// have done (a nuisance) or starts one it cannot finish (a truncated file it
/// cannot detect).
pub struct WriteEstimate {
    /// Total bytes this sweep is expected to write.
    pub total: u64,
    /// The largest single document, which is the room the NEXT save needs.
    pub largest: u64,
    /// How many documents had a size this process could actually read.
    pub measured: usize,
    /// How many were estimated instead.
    pub estimated: usize,
}

/// Estimate from paths alone, inferring each document's kind from its extension.
/// A pathless document has no extension to read, so it costs the generic unknown.
pub fn estimate_writes(paths: &[String]) -> WriteEstimate {
    let docs: Vec<(String, Option<DocKind>)> = paths
        .iter()
        .map(|p| {
            let kind = if p.trim().is_empty() { None } else { DocKind::from_hint(p) };
            (p.clone(), kind)
        })
        .collect();
    estimate_writes_typed(&docs)
}

/// Estimate when the caller KNOWS each document's kind — which the sweep does,
/// because SOLIDWORKS told it.
///
/// The rule that matters is MONOTONICITY: **a document this process could measure
/// must never lower the assumed cost of one it could not.** 0.7.3 costed an
/// unknown at twice the largest measured document, so a single 29 KB part beside
/// a pathless assembly dropped that assembly's assumed cost from 1 MB to 58 KB —
/// learning something small about the session made the guess about the rest of it
/// smaller, and `largest` also gates the re-check before every save. The fix is
/// one `max` against a floor that depends only on the KIND.
pub fn estimate_writes_typed(docs: &[(String, Option<DocKind>)]) -> WriteEstimate {
    let sizes: Vec<Option<u64>> = docs
        .iter()
        .map(|(p, _)| {
            if p.trim().is_empty() {
                None
            } else {
                std::fs::metadata(p).ok().map(|m| m.len())
            }
        })
        .collect();
    let biggest_known = sizes.iter().flatten().copied().max().unwrap_or(0);
    let neighbour_cost = biggest_known.saturating_mul(2);
    let mut total = 0u64;
    let mut largest = 0u64;
    let mut measured = 0usize;
    let mut estimated = 0usize;
    for (s, (_, kind)) in sizes.iter().zip(docs.iter()) {
        let cost = match s {
            Some(n) => {
                measured += 1;
                *n
            }
            None => {
                estimated += 1;
                // NEVER BELOW THE KIND'S FLOOR, whatever the neighbours weigh.
                let floor = kind.map(|k| k.estimate_floor()).unwrap_or(UNKNOWN_DOCUMENT_ESTIMATE);
                neighbour_cost.max(floor)
            }
        };
        total = total.saturating_add(cost);
        largest = largest.max(cost);
    }
    WriteEstimate { total, largest, measured, estimated }
}