Skip to main content

rightkit_qa/
binary.rs

1//! Find the dev build of the app under test, wherever the build put it.
2//!
3//! `right-release dev` and `rightkit cargo build` run through the RightKit build broker, which builds
4//! into its own target root (`<managed root>/rightkit-build-control/ws/<key>/g/<gen>/<leaf>/<triple>/<profile>/`)
5//! and does not forward app env such as `MAILRIGHT_APP_BINARY` into a later `rightkit cargo test`. A
6//! journey run there could not find the executable, and every app hand-rolled discovery.
7//!
8//! [`resolve`] tries, in order, and reports which source won ([`BinarySource`]):
9//!
10//! 1. **Env override**: `RIGHTKIT_QA_APP_BINARY`, then `RIGHTKIT_QA_UI_BINARY`, then the app's own
11//!    variable named by `ui.binary_env`. A set variable that fails validation is an error, never a fall-through.
12//! 2. **Pointer file**: a gitignored file under the app root (`.rightkit-qa-app-binary`, or `ui.pointer_file`)
13//!    whose first non-empty line is the executable path (relative paths resolve against the app root).
14//!    Same strictness as the env override.
15//! 3. **Broker target root**: the newest dev-profile executable named `<bin>` under the broker workspace
16//!    whose `identity.json` `canonicalRoot` is the app's cargo workspace root. See "Broker format" below.
17//! 4. **App-root relative paths**: the existing `ui.binaries` entry.
18//!
19//! Every candidate must also be a QA build: it carries [`QA_CONTROL_MARKER`] (Latin-1 or UTF-16LE), the same
20//! bytes `right-release` scans for. A broker or app-root candidate without it is skipped with a reason; an env or
21//! pointer value without it is an error. With a single dev profile a plain dev build and the control build both
22//! write `target/<triple>/debug/<bin>`, so the marker is what tells them apart. `--build-ui` keeps a stable copy
23//! ([`stash_qa_build`]) and points `.rightkit-qa-app-binary` at it. Both that pointer file and the
24//! `<target>/rightkit-qa/` copy are build output: git-ignore them.
25//!
26//! # One fixed launch path
27//!
28//! macOS ties privacy grants to the path an app runs from, and the broker's target folders change per
29//! generation (`ws/<hash>/g/<gen>/...`). [`resolve_stashed`] (what `QaConfig::resolve_ui_binary` and the suite
30//! use) therefore ALWAYS returns the fixed stash copy under the app root, copied in place with no
31//! retained copy of the replaced build (D01), whichever source found the build. A QA launch never
32//! runs from a broker generation path.
33//!
34//! Every candidate must exist, be a regular file, be named `<bin>` (or `<bin>.exe`) and must not sit in a
35//! `release` profile directory. A shipping or release-tier artifact is never picked.
36//!
37//! # Broker format
38//!
39//! The broker's per-job receipts (`<broker root>/receipts/<id>.json`) carry only freshness counts
40//! (`compilerArtifacts`), no executable path, so they cannot name the last built artifact. What is stable
41//! is the workspace state: `ws/<key>/identity.json` (`{"schemaVersion":1,"key","canonicalRoot"}`) and the
42//! target tree `ws/<key>/g/<generation>/<leaf>/...` where `<leaf>` is `t` or `target`. Under the leaf
43//! sits `[<triple>/]<profile>/<bin>[.exe]`. Only the dev profiles `release-iterate`, `debug` and `dev` are
44//! read; the newest by modification time wins.
45use crate::util::{err, Error, Result};
46use std::path::{Path, PathBuf};
47
48/// Env var: the dev executable, for one app.
49pub const APP_BINARY_ENV: &str = "RIGHTKIT_QA_APP_BINARY";
50/// Older env var read by the suite runner; honored second.
51pub const UI_BINARY_ENV: &str = "RIGHTKIT_QA_UI_BINARY";
52/// The embedded control server's marker; keep identical to `embedded::QA_CONTROL_MARKER` and
53/// `packages/release/qa-control-scan.mjs`. Not feature-gated here: this module only scans files.
54pub const QA_CONTROL_MARKER: &str = "RIGHTKIT-QA-CONTROL-SERVER-PRESENT";
55/// Directory (under the cargo target dir) that holds the stable QA copy.
56pub const STASH_DIR: &str = ".rightkit-qa-bin";
57/// Default pointer file name, relative to the app root.
58pub const POINTER_FILE: &str = ".rightkit-qa-app-binary";
59/// Profile directories the broker lookup reads. `release` is never in this list.
60pub const DEV_PROFILES: [&str; 3] = ["release-iterate", "debug", "dev"];
61
62/// Which source produced the path.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum BinarySource {
65    /// The named env var.
66    Env(String),
67    /// The pointer file at this path.
68    PointerFile(PathBuf),
69    /// The broker target root workspace directory (`ws/<key>`).
70    BrokerTarget(PathBuf),
71    /// The configured app-root relative path.
72    AppRoot,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ResolvedBinary {
77    pub path: PathBuf,
78    pub source: BinarySource,
79}
80
81/// Inputs to [`resolve`]. `env` is injectable so tests never read the process environment.
82pub struct BinaryQuery<'a> {
83    /// Cargo target name (executable stem), e.g. `mailright`.
84    pub bin: &'a str,
85    pub app_root: &'a Path,
86    /// Cargo workspace roots to match against the broker's `canonicalRoot`, first match wins per entry.
87    pub workspace_roots: Vec<PathBuf>,
88    /// `<managed root>/rightkit-build-control`; `None` skips the broker step.
89    pub broker_root: Option<PathBuf>,
90    /// The app-specific env var name (`ui.binary_env`).
91    pub extra_env: Option<&'a str>,
92    /// Pointer file path, relative to the app root unless absolute.
93    pub pointer_file: Option<&'a str>,
94    /// The configured `ui.binaries` path for this platform, relative to the app root.
95    pub app_relative: Option<&'a str>,
96    pub env: &'a dyn Fn(&str) -> Option<String>,
97}
98
99/// The broker root under a managed root.
100pub fn broker_root_of(managed_root: &Path) -> PathBuf {
101    managed_root.join("rightkit-build-control")
102}
103
104pub fn resolve(q: &BinaryQuery) -> Result<ResolvedBinary> {
105    if q.bin.trim().is_empty() {
106        return err("binary name is empty");
107    }
108    // 1. Env override.
109    let mut names = vec![APP_BINARY_ENV.to_string(), UI_BINARY_ENV.to_string()];
110    if let Some(n) = q.extra_env.filter(|n| !n.trim().is_empty()) {
111        names.push(n.to_string());
112    }
113    for name in names {
114        if let Some(v) = (q.env)(&name)
115            .map(|v| v.trim().to_string())
116            .filter(|v| !v.is_empty())
117        {
118            let path = absolute(q.app_root, &v);
119            validate(&path, q.bin).map_err(|e| Error(format!("{name}: {}", e.0)))?;
120            require_qa(&path).map_err(|e| Error(format!("{name}: {}", e.0)))?;
121            return Ok(ResolvedBinary {
122                path,
123                source: BinarySource::Env(name),
124            });
125        }
126    }
127    // 2. Pointer file.
128    let pointer = absolute(q.app_root, q.pointer_file.unwrap_or(POINTER_FILE));
129    if let Ok(text) = std::fs::read_to_string(&pointer) {
130        if let Some(line) = text.lines().map(str::trim).find(|l| !l.is_empty()) {
131            let path = absolute(q.app_root, line);
132            validate(&path, q.bin)
133                .map_err(|e| Error(format!("pointer file {}: {}", pointer.display(), e.0)))?;
134            require_qa(&path)
135                .map_err(|e| Error(format!("pointer file {}: {}", pointer.display(), e.0)))?;
136            return Ok(ResolvedBinary {
137                path,
138                source: BinarySource::PointerFile(pointer),
139            });
140        }
141    }
142    // 3. Broker target root.
143    let mut notes = Vec::new();
144    let mut not_qa: Option<PathBuf> = None;
145    if let Some(broker) = &q.broker_root {
146        let (found, skipped) = from_broker(broker, &q.workspace_roots, q.bin);
147        if let Some((path, ws)) = found {
148            return Ok(ResolvedBinary {
149                path,
150                source: BinarySource::BrokerTarget(ws),
151            });
152        }
153        if let Some(first) = skipped.first() {
154            not_qa = Some(first.clone());
155        }
156        notes.push(format!(
157            "no QA dev build of {} under {} (skipped without the control marker: {})",
158            q.bin,
159            broker.display(),
160            skipped.len()
161        ));
162    }
163    // 4. App-root relative.
164    if let Some(rel) = q.app_relative {
165        let path = absolute(q.app_root, rel);
166        match validate(&path, q.bin) {
167            Ok(()) => match has_qa_marker(&path) {
168                Ok(true) => {
169                    return Ok(ResolvedBinary {
170                        path,
171                        source: BinarySource::AppRoot,
172                    })
173                }
174                _ => {
175                    notes.push(format!("{} lacks the control marker", path.display()));
176                    not_qa.get_or_insert(path);
177                }
178            },
179            Err(e) => notes.push(e.0),
180        }
181    }
182    if let Some(found) = not_qa {
183        return err(format!("{}; {}", not_qa_message(&found), notes.join("; ")));
184    }
185    err(format!(
186        "no dev binary for {}: set {APP_BINARY_ENV}, write a path into {}, or build it ({})",
187        q.bin,
188        pointer.display(),
189        notes.join("; ")
190    ))
191}
192
193fn not_qa_message(path: &Path) -> String {
194    format!(
195        "found {} but it isn't a test-mode/control build; run the suite with --build-ui or build with the app's QA feature",
196        path.display()
197    )
198}
199
200fn require_qa(path: &Path) -> Result<()> {
201    match has_qa_marker(path) {
202        Ok(true) => Ok(()),
203        Ok(false) => err(not_qa_message(path)),
204        Err(e) => err(format!("cannot read {}: {e}", path.display())),
205    }
206}
207
208/// True when the file contains [`QA_CONTROL_MARKER`] as Latin-1/UTF-8 bytes or as UTF-16LE (Windows
209/// wide string). Streamed in chunks, so a large executable is never held in memory.
210pub fn has_qa_marker(path: &Path) -> std::io::Result<bool> {
211    use std::io::Read;
212    let narrow = QA_CONTROL_MARKER.as_bytes().to_vec();
213    let wide: Vec<u8> = QA_CONTROL_MARKER.bytes().flat_map(|b| [b, 0]).collect();
214    let keep = wide.len() - 1;
215    let find = |hay: &[u8], pat: &[u8]| hay.windows(pat.len()).any(|w| w == pat);
216    let mut f = std::fs::File::open(path)?;
217    let mut buf = vec![0u8; 1 << 20];
218    let mut carry: Vec<u8> = Vec::new();
219    loop {
220        let n = f.read(&mut buf)?;
221        if n == 0 {
222            return Ok(false);
223        }
224        carry.extend_from_slice(&buf[..n]);
225        if find(&carry, &narrow) || find(&carry, &wide) {
226            return Ok(true);
227        }
228        if carry.len() > keep {
229            carry.drain(..carry.len() - keep);
230        }
231    }
232}
233
234/// Exists, a regular file, named `<bin>` or `<bin>.exe`, and not in a `release` profile directory.
235pub fn validate(path: &Path, bin: &str) -> Result<()> {
236    crate::installed::refuse_installed(path, "app binary")?;
237    let meta =
238        std::fs::metadata(path).map_err(|_| Error(format!("{} does not exist", path.display())))?;
239    if !meta.is_file() {
240        return err(format!("{} is not a file", path.display()));
241    }
242    if !name_matches(path, bin) {
243        return err(format!("{} is not named {bin}", path.display()));
244    }
245    if path
246        .parent()
247        .and_then(|p| p.file_name())
248        .is_some_and(|n| n == "release")
249    {
250        return err(format!("{} is a release-tier artifact", path.display()));
251    }
252    Ok(())
253}
254
255fn name_matches(path: &Path, bin: &str) -> bool {
256    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
257        return false;
258    };
259    let exe = format!("{bin}.exe");
260    name == bin || name == exe || (cfg!(windows) && name.eq_ignore_ascii_case(&exe))
261}
262
263fn absolute(root: &Path, text: &str) -> PathBuf {
264    let p = Path::new(text);
265    if p.is_absolute() {
266        p.to_path_buf()
267    } else {
268        root.join(p)
269    }
270}
271
272fn norm(text: &str) -> String {
273    let t = text.strip_prefix(r"\\?\").unwrap_or(text);
274    let t = t.replace('\\', "/");
275    let t = t.trim_end_matches('/').to_string();
276    if cfg!(windows) {
277        t.to_lowercase()
278    } else {
279        t
280    }
281}
282
283fn norm_path(p: &Path) -> String {
284    let real = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
285    norm(&real.to_string_lossy())
286}
287
288/// Newest QA dev-profile `<bin>` in any broker workspace whose canonical root is one of `roots`.
289/// Returns the executable and its `ws/<key>` directory, plus the valid candidates skipped for
290/// lacking the control marker (newest first).
291fn from_broker(
292    broker: &Path,
293    roots: &[PathBuf],
294    bin: &str,
295) -> (Option<(PathBuf, PathBuf)>, Vec<PathBuf>) {
296    let wanted: Vec<String> = roots.iter().map(|r| norm_path(r)).collect();
297    let mut all: Vec<(std::time::SystemTime, PathBuf, PathBuf)> = Vec::new();
298    let Ok(rd) = std::fs::read_dir(broker.join("ws")) else {
299        return (None, vec![]);
300    };
301    for entry in rd.flatten() {
302        let ws = entry.path();
303        let Ok(text) = std::fs::read_to_string(ws.join("identity.json")) else {
304            continue;
305        };
306        let Ok(id) = serde_json::from_str::<serde_json::Value>(&text) else {
307            continue;
308        };
309        let Some(root) = id.get("canonicalRoot").and_then(|v| v.as_str()) else {
310            continue;
311        };
312        if !wanted.contains(&norm_path(Path::new(root))) {
313            continue;
314        }
315        for file in broker_candidates(&ws, bin) {
316            if validate(&file, bin).is_err() {
317                continue;
318            }
319            let Ok(t) = std::fs::metadata(&file).and_then(|m| m.modified()) else {
320                continue;
321            };
322            all.push((t, file, ws.clone()));
323        }
324    }
325    all.sort_by_key(|a| std::cmp::Reverse(a.0));
326    let mut skipped = Vec::new();
327    for (_, file, ws) in all {
328        if has_qa_marker(&file).unwrap_or(false) {
329            return (Some((file, ws)), skipped);
330        }
331        skipped.push(file);
332    }
333    (None, skipped)
334}
335
336fn subdirs(dir: &Path) -> Vec<PathBuf> {
337    std::fs::read_dir(dir)
338        .map(|rd| {
339            rd.flatten()
340                .map(|e| e.path())
341                .filter(|p| p.is_dir())
342                .collect()
343        })
344        .unwrap_or_default()
345}
346
347fn is_dev_profile(p: &Path) -> bool {
348    p.file_name()
349        .and_then(|n| n.to_str())
350        .is_some_and(|n| DEV_PROFILES.contains(&n))
351}
352
353/// `g/<gen>/<leaf>/[<triple>/]<dev profile>/<bin>[.exe]` under one workspace.
354fn broker_candidates(ws: &Path, bin: &str) -> Vec<PathBuf> {
355    let mut out = Vec::new();
356    for generation in subdirs(&ws.join("g")) {
357        for leaf in subdirs(&generation) {
358            if !matches!(
359                leaf.file_name().and_then(|n| n.to_str()),
360                Some("t" | "target")
361            ) {
362                continue;
363            }
364            for child in subdirs(&leaf) {
365                let profiles = if is_dev_profile(&child) {
366                    vec![child]
367                } else {
368                    subdirs(&child)
369                };
370                for profile in profiles.into_iter().filter(|p| is_dev_profile(p)) {
371                    for name in [bin.to_string(), format!("{bin}.exe")] {
372                        let f = profile.join(name);
373                        if f.is_file() {
374                            out.push(f);
375                        }
376                    }
377                }
378            }
379        }
380    }
381    out
382}
383
384/// True when `path` already sits under the fixed stash `<app_root>/<STASH_DIR>`.
385pub fn is_stashed(path: &Path, app_root: &Path) -> bool {
386    let stash = app_root.join(STASH_DIR);
387    path.starts_with(&stash)
388        || match (path.canonicalize(), stash.canonicalize()) {
389            (Ok(p), Ok(s)) => p.starts_with(s),
390            _ => false,
391        }
392}
393
394/// [`resolve`], then ALWAYS the fixed stash: whichever source wins (env, pointer, broker target or an
395/// app-root path), the returned path is the stash copy `<app_root>/.rightkit-qa-bin/<triple>/<bin>`, copied
396/// in place with no retained copy of the replaced build ([`stash_qa_build`], D01). A QA launch
397/// therefore never runs from a broker generation path (`ws/<hash>/g/<gen>/...`, a new folder on every
398/// generation) or from `target/debug`, so macOS privacy grants keep one path. `source` still names
399/// where the build was found. The pointer file is not rewritten (a fresh build is picked up on the
400/// next run).
401pub fn resolve_stashed(q: &BinaryQuery) -> Result<ResolvedBinary> {
402    let found = resolve(q)?;
403    if is_stashed(&found.path, q.app_root) {
404        return Ok(found);
405    }
406    let path = stash_binary(&found.path, q.app_root, None, true)?;
407    Ok(ResolvedBinary {
408        path,
409        source: found.source,
410    })
411}
412
413/// After a successful `--build-ui`: copy the built QA executable (and a sibling `.pdb`) to
414/// `<app_root>/<STASH_DIR>/<triple-or-host>/<name>` in place (staging copy, then rename, retaining
415/// no copy of the replaced build), and point `<app_root>/.rightkit-qa-app-binary` (or
416/// `pointer_file`) at the copy.
417/// A later plain dev build that overwrites `debug/<bin>` then cannot displace it. The built file must
418/// carry the control marker.
419pub fn stash_qa_build(exe: &Path, app_root: &Path, pointer_file: Option<&str>) -> Result<PathBuf> {
420    let dest = stash_binary(exe, app_root, Some(pointer_file), true)?;
421    Ok(dest)
422}
423
424/// The stash copy itself. `pointer` is `Some(<pointer file override>)` to write the pointer file, `None` to leave
425/// it alone; `require_marker` keeps the QA control-marker check (an explicitly named UI binary skips it, as
426/// before, and passes `false`). The caller has already refused installed apps.
427pub fn stash_binary(
428    exe: &Path,
429    app_root: &Path,
430    pointer: Option<Option<&str>>,
431    require_marker: bool,
432) -> Result<PathBuf> {
433    if require_marker {
434        require_qa(exe)?;
435    }
436    let name = exe
437        .file_name()
438        .ok_or_else(|| Error(format!("{} has no file name", exe.display())))?;
439    let profile = exe
440        .parent()
441        .ok_or_else(|| Error("artifact has no profile dir".into()))?;
442    // `<target>/[<triple>/]<profile>/<name>`: a parent dir name with `-` is the target triple.
443    let up_name = profile
444        .parent()
445        .and_then(|p| p.file_name())
446        .and_then(|n| n.to_str())
447        .unwrap_or("");
448    let triple = if up_name.contains('-') {
449        up_name
450    } else {
451        "host"
452    };
453    // Under the app root, not the cargo target dir: the broker's target tree is garbage-collected and
454    // its generation folder changes, so nothing may run from it.
455    let dir = app_root.join(STASH_DIR).join(triple);
456    std::fs::create_dir_all(&dir)
457        .map_err(|e| Error(format!("cannot create {}: {e}", dir.display())))?;
458    let dest = dir.join(name);
459    crate::inplace::replace_file_in_place(exe, &dest)?;
460    let stem = exe.file_stem().and_then(|n| n.to_str()).unwrap_or("");
461    for pdb in [stem.to_string(), stem.replace('-', "_")] {
462        let from = profile.join(format!("{pdb}.pdb"));
463        if from.is_file() {
464            crate::inplace::replace_file_in_place(&from, &dir.join(format!("{pdb}.pdb")))?;
465        }
466    }
467    if let Some(pointer_file) = pointer {
468        let pointer = absolute(app_root, pointer_file.unwrap_or(POINTER_FILE));
469        let tmp = pointer.with_extension(format!("tmp-{}", crate::util::new_id()));
470        std::fs::write(&tmp, format!("{}\n", dest.display()))
471            .and_then(|_| std::fs::rename(&tmp, &pointer))
472            .map_err(|e| Error(format!("cannot write {}: {e}", pointer.display())))?;
473    }
474    Ok(dest)
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use std::time::{Duration, SystemTime};
481
482    fn tmp(tag: &str) -> PathBuf {
483        let d = std::env::temp_dir().join(format!("rkqa-binary-{tag}-{}", crate::util::new_id()));
484        std::fs::create_dir_all(&d).unwrap();
485        d
486    }
487
488    /// A fixture executable that carries the control marker.
489    fn touch(p: &Path, age_s: u64) {
490        write_exe(p, age_s, true);
491    }
492
493    fn write_exe(p: &Path, age_s: u64, marker: bool) {
494        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
495        let mut bytes = b"MZ....padding".to_vec();
496        if marker {
497            bytes.extend_from_slice(QA_CONTROL_MARKER.as_bytes());
498        }
499        std::fs::write(p, bytes).unwrap();
500        let t = SystemTime::now() - Duration::from_secs(age_s);
501        std::fs::File::options()
502            .write(true)
503            .open(p)
504            .unwrap()
505            .set_modified(t)
506            .unwrap();
507    }
508
509    fn exe(name: &str) -> String {
510        if cfg!(windows) {
511            format!("{name}.exe")
512        } else {
513            name.to_string()
514        }
515    }
516
517    /// A fixture broker with one workspace for `root`.
518    fn broker(base: &Path, root: &Path) -> PathBuf {
519        let ws = base.join("rightkit-build-control/ws/abc123");
520        std::fs::create_dir_all(&ws).unwrap();
521        let id = serde_json::json!({"schemaVersion":1,"key":"abc123","canonicalRoot":root.to_string_lossy()});
522        std::fs::write(ws.join("identity.json"), id.to_string()).unwrap();
523        ws
524    }
525
526    fn query<'a>(
527        bin: &'a str,
528        app: &'a Path,
529        broker: Option<PathBuf>,
530        env: &'a dyn Fn(&str) -> Option<String>,
531    ) -> BinaryQuery<'a> {
532        BinaryQuery {
533            bin,
534            app_root: app,
535            workspace_roots: vec![app.to_path_buf()],
536            broker_root: broker,
537            extra_env: Some("MYAPP_APP_BINARY"),
538            pointer_file: None,
539            app_relative: Some("target/debug/myapp"),
540            env,
541        }
542    }
543
544    #[test]
545    fn broker_picks_newest_dev_profile_and_never_release() {
546        let base = tmp("broker");
547        let app = base.join("app");
548        std::fs::create_dir_all(&app).unwrap();
549        let ws = broker(&base, &app);
550        let old = ws
551            .join("g/001/t/x86_64-pc-windows-msvc/release-iterate")
552            .join(exe("myapp"));
553        let new = ws
554            .join("g/002/t/x86_64-pc-windows-msvc/debug")
555            .join(exe("myapp"));
556        let rel = ws
557            .join("g/002/t/x86_64-pc-windows-msvc/release")
558            .join(exe("myapp"));
559        touch(&old, 500);
560        touch(&new, 100);
561        touch(&rel, 1); // newest, but release tier
562        let env = |_: &str| None;
563        let got = resolve(&query(
564            "myapp",
565            &app,
566            Some(base.join("rightkit-build-control")),
567            &env,
568        ))
569        .unwrap();
570        assert_eq!(got.path, new);
571        assert_eq!(got.source, BinarySource::BrokerTarget(ws));
572    }
573
574    #[test]
575    fn broker_ignores_other_workspaces_and_other_bins() {
576        let base = tmp("other");
577        let app = base.join("app");
578        let other = base.join("other");
579        std::fs::create_dir_all(&app).unwrap();
580        std::fs::create_dir_all(&other).unwrap();
581        let ws = broker(&base, &other);
582        touch(&ws.join("g/001/target/debug").join(exe("myapp")), 10);
583        let ws2 = base.join("rightkit-build-control/ws/def456");
584        std::fs::create_dir_all(&ws2).unwrap();
585        let id = serde_json::json!({"canonicalRoot": app.to_string_lossy()});
586        std::fs::write(ws2.join("identity.json"), id.to_string()).unwrap();
587        touch(&ws2.join("g/001/target/debug").join(exe("helper")), 10);
588        let env = |_: &str| None;
589        let e = resolve(&query(
590            "myapp",
591            &app,
592            Some(base.join("rightkit-build-control")),
593            &env,
594        ))
595        .unwrap_err();
596        assert!(e.0.contains("no dev binary for myapp"), "{}", e.0);
597    }
598
599    #[test]
600    fn env_beats_pointer_beats_broker_beats_app_root() {
601        let base = tmp("order");
602        let app = base.join("app");
603        let ws = broker(&base, &app);
604        let from_broker = ws.join("g/001/t/debug").join(exe("myapp"));
605        touch(&from_broker, 10);
606        let rel = app.join("target/debug/myapp");
607        touch(&rel, 10);
608        touch(&app.join("target/debug/myapp.exe"), 10);
609        let b = Some(base.join("rightkit-build-control"));
610
611        let none = |_: &str| None;
612        let got = resolve(&query("myapp", &app, b.clone(), &none)).unwrap();
613        assert!(matches!(got.source, BinarySource::BrokerTarget(_)));
614
615        let pointed = base.join("pointed").join(exe("myapp"));
616        touch(&pointed, 10);
617        std::fs::write(app.join(POINTER_FILE), format!("\n{}\n", pointed.display())).unwrap();
618        let got = resolve(&query("myapp", &app, b.clone(), &none)).unwrap();
619        assert_eq!(got.path, pointed);
620        assert!(matches!(got.source, BinarySource::PointerFile(_)));
621
622        let envd = base.join("envd").join(exe("myapp"));
623        touch(&envd, 10);
624        let p = envd.to_string_lossy().into_owned();
625        let env = move |k: &str| (k == "MYAPP_APP_BINARY").then(|| p.clone());
626        let got = resolve(&query("myapp", &app, b.clone(), &env)).unwrap();
627        assert_eq!(got.path, envd);
628        assert_eq!(got.source, BinarySource::Env("MYAPP_APP_BINARY".into()));
629
630        // With no broker and no pointer, the app-root path wins.
631        std::fs::remove_file(app.join(POINTER_FILE)).unwrap();
632        let got = resolve(&query("myapp", &app, None, &none)).unwrap();
633        assert_eq!(got.source, BinarySource::AppRoot);
634    }
635
636    #[test]
637    fn invalid_override_is_an_error_not_a_fall_through() {
638        let base = tmp("bad");
639        let app = base.join("app");
640        std::fs::create_dir_all(&app).unwrap();
641        let wrong = base.join("wrong").join(exe("helper"));
642        touch(&wrong, 1);
643        let p = wrong.to_string_lossy().into_owned();
644        let env = move |k: &str| (k == APP_BINARY_ENV).then(|| p.clone());
645        let e = resolve(&query("myapp", &app, None, &env)).unwrap_err();
646        assert!(e.0.contains("is not named myapp"), "{}", e.0);
647        let missing = |k: &str| (k == APP_BINARY_ENV).then(|| "nope/myapp".to_string());
648        assert!(resolve(&query("myapp", &app, None, &missing))
649            .unwrap_err()
650            .0
651            .contains("does not exist"));
652    }
653
654    #[test]
655    fn validate_rejects_directories_and_release_dirs() {
656        let base = tmp("val");
657        let dir = base.join("myapp");
658        std::fs::create_dir_all(&dir).unwrap();
659        assert!(validate(&dir, "myapp")
660            .unwrap_err()
661            .0
662            .contains("not a file"));
663        let rel = base.join("target/release").join(exe("myapp"));
664        touch(&rel, 1);
665        assert!(validate(&rel, "myapp")
666            .unwrap_err()
667            .0
668            .contains("release-tier"));
669        let ok = base.join("target/release-iterate").join(exe("myapp"));
670        touch(&ok, 1);
671        assert!(validate(&ok, "myapp").is_ok());
672    }
673
674    #[test]
675    fn marker_scan_finds_narrow_wide_and_chunk_straddling_bytes() {
676        let base = tmp("marker");
677        let narrow = base.join("n.exe");
678        write_exe(&narrow, 1, true);
679        assert!(has_qa_marker(&narrow).unwrap());
680        let none = base.join("none.exe");
681        write_exe(&none, 1, false);
682        assert!(!has_qa_marker(&none).unwrap());
683        let wide = base.join("w.exe");
684        let mut b = vec![7u8; 100];
685        b.extend(QA_CONTROL_MARKER.bytes().flat_map(|c| [c, 0]));
686        std::fs::write(&wide, b).unwrap();
687        assert!(has_qa_marker(&wide).unwrap());
688        // The marker straddles the 1 MiB read boundary.
689        let big = base.join("big.exe");
690        let mut b = vec![1u8; (1 << 20) - 10];
691        b.extend_from_slice(QA_CONTROL_MARKER.as_bytes());
692        std::fs::write(&big, b).unwrap();
693        assert!(has_qa_marker(&big).unwrap());
694        // A truncated marker is not a match.
695        let part = base.join("part.exe");
696        std::fs::write(&part, &QA_CONTROL_MARKER.as_bytes()[..20]).unwrap();
697        assert!(!has_qa_marker(&part).unwrap());
698    }
699
700    #[test]
701    fn broker_skips_non_qa_newest_and_takes_the_qa_one() {
702        let base = tmp("skip");
703        let app = base.join("app");
704        std::fs::create_dir_all(&app).unwrap();
705        let ws = broker(&base, &app);
706        let qa = ws.join("g/001/t/debug").join(exe("myapp"));
707        let plain = ws.join("g/002/t/debug").join(exe("myapp"));
708        write_exe(&qa, 500, true);
709        write_exe(&plain, 5, false); // newest, but a plain dev build
710        let env = |_: &str| None;
711        let b = Some(base.join("rightkit-build-control"));
712        let got = resolve(&query("myapp", &app, b.clone(), &env)).unwrap();
713        assert_eq!(got.path, qa);
714        // Only the plain build exists: fail closed with the instructive message.
715        std::fs::remove_file(&qa).unwrap();
716        let e = resolve(&query("myapp", &app, b, &env)).unwrap_err();
717        assert!(
718            e.0.contains("but it isn't a test-mode/control build"),
719            "{}",
720            e.0
721        );
722        assert!(e.0.contains("--build-ui"), "{}", e.0);
723    }
724
725    #[test]
726    fn env_and_pointer_without_marker_are_errors() {
727        let base = tmp("nomarker");
728        let app = base.join("app");
729        std::fs::create_dir_all(&app).unwrap();
730        let plain = base.join("p").join(exe("myapp"));
731        write_exe(&plain, 1, false);
732        let p = plain.to_string_lossy().into_owned();
733        let env = move |k: &str| (k == APP_BINARY_ENV).then(|| p.clone());
734        assert!(resolve(&query("myapp", &app, None, &env))
735            .unwrap_err()
736            .0
737            .contains("isn't a test-mode/control build"));
738        std::fs::write(app.join(POINTER_FILE), plain.to_string_lossy().as_bytes()).unwrap();
739        let none = |_: &str| None;
740        let e = resolve(&query("myapp", &app, None, &none)).unwrap_err();
741        assert!(
742            e.0.contains("pointer file") && e.0.contains("isn't a test-mode/control build"),
743            "{}",
744            e.0
745        );
746    }
747
748    #[test]
749    fn app_root_candidate_without_marker_is_refused() {
750        let base = tmp("approot");
751        let app = base.join("app");
752        write_exe(&app.join("target/debug").join(exe("myapp")), 1, false);
753        write_exe(&app.join("target/debug/myapp"), 1, false);
754        let none = |_: &str| None;
755        let e = resolve(&query("myapp", &app, None, &none)).unwrap_err();
756        assert!(e.0.contains("isn't a test-mode/control build"), "{}", e.0);
757    }
758
759    #[test]
760    fn resolve_stashed_never_returns_a_broker_generation_path() {
761        let base = tmp("stashed");
762        let app = base.join("app");
763        std::fs::create_dir_all(&app).unwrap();
764        let ws = broker(&base, &app);
765        let gen1 = ws.join("g/001/t/debug").join(exe("myapp"));
766        touch(&gen1, 10);
767        let env = |_: &str| None;
768        let b = Some(base.join("rightkit-build-control"));
769        let q = query("myapp", &app, b.clone(), &env);
770        assert!(matches!(
771            resolve(&q).unwrap().source,
772            BinarySource::BrokerTarget(_)
773        ));
774        let got = resolve_stashed(&q).unwrap();
775        assert!(matches!(got.source, BinarySource::BrokerTarget(_)));
776        assert!(is_stashed(&got.path, &app));
777        assert!(got.path.starts_with(app.join(STASH_DIR)));
778        assert!(!got.path.starts_with(&ws));
779        // A new generation lands in a new folder; the launch path stays the same and no copy of the replaced build is kept.
780        let gen2 = ws.join("g/002/t/debug").join(exe("myapp"));
781        std::fs::create_dir_all(gen2.parent().unwrap()).unwrap();
782        std::fs::write(
783            &gen2,
784            [b"MZ-gen2".as_slice(), QA_CONTROL_MARKER.as_bytes()].concat(),
785        )
786        .unwrap();
787        let later = resolve_stashed(&query("myapp", &app, b, &env)).unwrap();
788        assert_eq!(later.path, got.path);
789        assert!(
790            !crate::inplace::previous_path(&got.path).exists(),
791            "no retained .previous copy (D01)"
792        );
793        // The app-root source is stashed too, and an env override is no exception.
794        let rel = app.join("target/debug/myapp");
795        touch(&rel, 5);
796        let envd = base.join("envd").join(exe("myapp"));
797        touch(&envd, 5);
798        let p = envd.to_string_lossy().into_owned();
799        let env2 = move |k: &str| (k == "MYAPP_APP_BINARY").then(|| p.clone());
800        let from_env = resolve_stashed(&query("myapp", &app, None, &env2)).unwrap();
801        assert!(from_env.path.starts_with(app.join(STASH_DIR)));
802        assert_eq!(
803            from_env.source,
804            BinarySource::Env("MYAPP_APP_BINARY".into())
805        );
806        // The installed-app refusal from 0.6.3 still applies to the resolved source.
807        assert!(is_stashed(&from_env.path, &app) && !is_stashed(&envd, &app));
808    }
809
810    #[test]
811    fn stash_copies_atomically_and_writes_the_pointer() {
812        let base = tmp("stash");
813        let app = base.join("app");
814        std::fs::create_dir_all(&app).unwrap();
815        let built = base
816            .join("target/x86_64-pc-windows-msvc/debug")
817            .join(exe("myapp"));
818        write_exe(&built, 5, true);
819        std::fs::write(built.with_extension("pdb"), b"pdb").unwrap();
820        let got = stash_qa_build(&built, &app, None).unwrap();
821        assert_eq!(
822            got,
823            app.join(".rightkit-qa-bin/x86_64-pc-windows-msvc")
824                .join(exe("myapp"))
825        );
826        assert!(has_qa_marker(&got).unwrap());
827        assert!(got.with_extension("pdb").is_file());
828        // Stashing a different build replaces it in place and keeps no `.previous` (D01).
829        write_exe(&built, 3, true);
830        std::fs::write(
831            &built,
832            [b"MZ-second".as_slice(), QA_CONTROL_MARKER.as_bytes()].concat(),
833        )
834        .unwrap();
835        let again = stash_qa_build(&built, &app, None).unwrap();
836        assert_eq!(again, got);
837        let prev = crate::inplace::previous_path(&got);
838        assert!(!prev.exists(), "the earlier stash is not retained (D01)");
839        // A later plain dev build overwrites the original; the stashed copy and pointer still win.
840        write_exe(&built, 1, false);
841        let none = |_: &str| None;
842        let r = resolve(&query("myapp", &app, None, &none)).unwrap();
843        assert_eq!(r.path, got);
844        assert!(matches!(r.source, BinarySource::PointerFile(_)));
845        // No triple directory: stored under `host`.
846        let plain_layout = base.join("t2/debug").join(exe("myapp"));
847        write_exe(&plain_layout, 1, true);
848        assert!(stash_qa_build(&plain_layout, &app, None)
849            .unwrap()
850            .starts_with(app.join(".rightkit-qa-bin/host")));
851        // A build without the marker is refused.
852        let bad = base.join("t3/debug").join(exe("myapp"));
853        write_exe(&bad, 1, false);
854        assert!(stash_qa_build(&bad, &app, None).is_err());
855    }
856}