Skip to main content

supercov_engine/
source_tools.rs

1//! Tools that judge the project's source as text.
2//!
3//! A linter, a formatter or a spell checker that a test script runs reads the
4//! files of the instrumented workspace, and fails on code nobody wrote:
5//! Biome on probe calls it would format otherwise, cspell on Supercov's own
6//! names, knip on the runtime the files now import. ESLint and Prettier are
7//! Node programs, and the preload hands them the authored text of each file
8//! they read. A native program cannot be handed anything.
9//!
10//! Such a tool is started through a launcher in the workspace's own
11//! `node_modules/.bin`, which calls back into this binary. It then runs in a
12//! view of the workspace that holds every file as its author wrote it: a
13//! directory beside the workspace with a hard link to each file the run did
14//! not rewrite, and the authored text of each one it did. What the tool
15//! writes there is the workspace's too, as if it had run in it.
16
17use std::collections::BTreeSet;
18use std::ffi::{OsStr, OsString};
19use std::fs;
20use std::io;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24use crate::javascript_frontend::{authored_path, rewritten_files};
25
26/// The hidden command a launcher runs: `supercov __source-tool <tool> <args>`.
27pub const SOURCE_TOOL_COMMAND: &str = "__source-tool";
28
29/// Names added to [`SOURCE_TOOLS`], separated by commas.
30pub const SOURCE_TOOLS_VARIABLE: &str = "SUPERCOV_SOURCE_TOOLS";
31
32/// The tools measured to fail on an instrumented workspace and to pass in its
33/// source view. Anything else that reads source as text can be named in
34/// `SUPERCOV_SOURCE_TOOLS`.
35pub const SOURCE_TOOLS: &[&str] = &["biome", "cspell", "dprint", "knip", "oxlint"];
36
37/// The names a launcher is written for.
38pub fn source_tools() -> BTreeSet<String> {
39    let mut tools = SOURCE_TOOLS
40        .iter()
41        .map(|tool| (*tool).to_owned())
42        .collect::<BTreeSet<_>>();
43    if let Some(named) = std::env::var_os(SOURCE_TOOLS_VARIABLE) {
44        tools.extend(
45            named
46                .to_string_lossy()
47                .split(',')
48                .map(str::trim)
49                .filter(|name| !name.is_empty() && !name.contains(['/', '\\']))
50                .map(str::to_owned),
51        );
52    }
53    tools
54}
55
56/// The launcher that stands in for `tool` where a shell starts it.
57pub fn launcher(binary: &Path, tool: &Path) -> String {
58    let quoted = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"));
59    format!(
60        "#!/bin/sh\n# Runs {name} on the project's source as it was written; see `supercov --help`.\nexec {binary} {SOURCE_TOOL_COMMAND} {tool} \"$@\"\n",
61        name = tool
62            .file_name()
63            .unwrap_or_default()
64            .to_string_lossy()
65            .replace('\n', " "),
66        binary = quoted(binary),
67        tool = quoted(tool),
68    )
69}
70
71/// The same for `cmd.exe`, which is what npm runs a script with on Windows.
72fn command_launcher(binary: &Path, tool: &Path) -> String {
73    format!(
74        "@ECHO off\r\n\"{}\" {SOURCE_TOOL_COMMAND} \"{}\" %*\r\n",
75        binary.display(),
76        tool.display()
77    )
78}
79
80/// The same for PowerShell.
81fn powershell_launcher(binary: &Path, tool: &Path) -> String {
82    let quoted = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', "''"));
83    format!(
84        "& {} {SOURCE_TOOL_COMMAND} {} @args\r\nexit $LASTEXITCODE\r\n",
85        quoted(binary),
86        quoted(tool)
87    )
88}
89
90/// What stands in a workspace's `.bin` for the file `name` of the project's
91/// `.bin` at `tools`, when that file starts a tool that judges source.
92///
93/// On Windows a tool is three files: the shim `cmd.exe` runs, one for
94/// PowerShell and one for a POSIX shell. Each gets a launcher in its own
95/// language, and all three run the `.cmd`.
96pub fn launcher_for(
97    judges: &BTreeSet<String>,
98    binary: &Path,
99    tools: &Path,
100    name: &OsStr,
101) -> Option<String> {
102    launcher_on(cfg!(windows), judges, binary, tools, name)
103}
104
105fn launcher_on(
106    windows: bool,
107    judges: &BTreeSet<String>,
108    binary: &Path,
109    tools: &Path,
110    name: &OsStr,
111) -> Option<String> {
112    let name = name.to_str()?;
113    if windows {
114        let (tool, kind) = match name.rsplit_once('.') {
115            Some((tool, extension)) if extension.eq_ignore_ascii_case("cmd") => (tool, 1),
116            Some((tool, extension)) if extension.eq_ignore_ascii_case("ps1") => (tool, 2),
117            _ => (name, 0),
118        };
119        if !judges.contains(tool) {
120            return None;
121        }
122        let shim = tools.join(format!("{tool}.cmd"));
123        return shim.is_file().then(|| match kind {
124            1 => command_launcher(binary, &shim),
125            2 => powershell_launcher(binary, &shim),
126            _ => launcher(binary, &shim),
127        });
128    }
129    judges
130        .contains(name)
131        .then(|| launcher(binary, &tools.join(name)))
132}
133
134/// This binary, by a path a shell and `cmd.exe` can both be given.
135pub fn launching_binary() -> Option<PathBuf> {
136    std::env::current_exe()
137        .ok()
138        .map(crate::workspace::simplified)
139}
140
141/// Write a launcher where `path` is, replacing what is there. Never through
142/// it: in a workspace whose dependencies are hard links, the file there is
143/// the project's own.
144pub fn write_launcher(path: &Path, text: &str) -> io::Result<()> {
145    remove(path)?;
146    fs::write(path, text)?;
147    #[cfg(unix)]
148    {
149        use std::os::unix::fs::PermissionsExt;
150        fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
151    }
152    Ok(())
153}
154
155/// Put launchers in a `.bin` the workspace already has as a directory of its
156/// own, which is how a workspace package's dependencies are laid out: for
157/// each source tool the project's `.bin` at `tools` has, in place of the
158/// entry the copy at `to` has for it.
159pub fn place_launchers(tools: &Path, to: &Path) -> io::Result<()> {
160    let judges = source_tools();
161    let (Some(binary), Ok(entries)) = (launching_binary(), fs::read_dir(tools)) else {
162        return Ok(());
163    };
164    if !fs::symlink_metadata(to).is_ok_and(|metadata| metadata.file_type().is_dir()) {
165        return Ok(());
166    }
167    for entry in entries {
168        let name = entry?.file_name();
169        if let Some(text) = launcher_for(&judges, &binary, tools, &name) {
170            write_launcher(&to.join(&name), &text)?;
171        }
172    }
173    Ok(())
174}
175
176/// PATH for the test command with launchers first for the source tools it
177/// has: a package manager's script finds a project's own tool in the
178/// workspace's `.bin`, but `supercov -- biome check`, and a tool installed
179/// for the whole machine, are found here. A name that is not on PATH gets no
180/// launcher, so a script that asks whether a tool is installed hears what it
181/// heard.
182pub fn path_with_launchers(workspace: &Path) -> Option<OsString> {
183    let path = std::env::var_os("PATH")?;
184    let binary = launching_binary()?;
185    let directory = workspace.join(".supercov/node_modules/.tools");
186    remove(&directory).ok()?;
187    // The forms a tool is installed in, in the order the system tries them,
188    // and the launcher that answers to the name.
189    let forms: &[&str] = if cfg!(windows) {
190        &[".exe", ".cmd", ".bat"]
191    } else {
192        &[""]
193    };
194    let mut found = false;
195    for name in source_tools() {
196        let Some(tool) = std::env::split_paths(&path)
197            .flat_map(|directory| {
198                forms
199                    .iter()
200                    .map(|form| directory.join(format!("{name}{form}")))
201                    .collect::<Vec<_>>()
202            })
203            .find(|candidate| executable(candidate))
204        else {
205            continue;
206        };
207        fs::create_dir_all(&directory).ok()?;
208        if cfg!(windows) {
209            let launcher = command_launcher(&binary, &tool);
210            write_launcher(&directory.join(format!("{name}.cmd")), &launcher).ok()?;
211        } else {
212            write_launcher(&directory.join(&name), &launcher(&binary, &tool)).ok()?;
213        }
214        found = true;
215    }
216    if !found {
217        return None;
218    }
219    std::env::join_paths(std::iter::once(directory).chain(std::env::split_paths(&path))).ok()
220}
221
222#[cfg(unix)]
223fn executable(path: &Path) -> bool {
224    use std::os::unix::fs::PermissionsExt;
225    fs::metadata(path)
226        .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
227}
228
229#[cfg(not(unix))]
230fn executable(path: &Path) -> bool {
231    path.is_file()
232}
233
234/// Where the source view of a workspace is kept: beside the directory that
235/// holds the workspaces, under the same name.
236pub fn source_view_path(workspace: &Path) -> Option<PathBuf> {
237    let name = workspace.file_name()?;
238    let kind = workspace.parent()?;
239    let container = kind.parent()?;
240    (kind.file_name()? == "workspace").then(|| container.join("source").join(name))
241}
242
243/// A view is kept under the project's `.supercov`, which the project's
244/// `.gitignore` names, and a tool that honours ignore files, oxlint or
245/// dprint, then finds no file in it. Such a tool stops reading the ignore
246/// files of parent directories at the first directory that has a `.git`, so
247/// a view whose workspace has none gets an empty one. To git itself an empty
248/// `.git` is no repository, and it looks further up, as it did.
249const BOUNDARY: &str = ".git";
250
251/// What a file of the view is, to tell afterwards what the tool did to it.
252#[derive(Default)]
253struct View {
254    /// Files linked from the workspace, by their path in it.
255    linked: BTreeSet<PathBuf>,
256    /// Files that hold authored text.
257    authored: BTreeSet<PathBuf>,
258}
259
260#[cfg(unix)]
261fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
262    use std::os::unix::fs::MetadataExt;
263    left.dev() == right.dev() && left.ino() == right.ino()
264}
265
266/// Where a file's identity is not to be had, two hard links to one file are
267/// told by what they share: its length and the moment it was last written.
268#[cfg(not(unix))]
269fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
270    left.len() == right.len()
271        && left.modified().is_ok()
272        && left.modified().ok() == right.modified().ok()
273}
274
275/// A link at `link` to `target`: a symbolic link, or on Windows a junction
276/// for a directory, which needs no privilege.
277fn link_to(target: &Path, link: &Path) -> io::Result<()> {
278    crate::workspace::create_link(target, link, target.is_dir())
279}
280
281fn remove(path: &Path) -> io::Result<()> {
282    match fs::symlink_metadata(path) {
283        Ok(metadata) if metadata.file_type().is_dir() => fs::remove_dir_all(path),
284        // A link to a directory is removed as a directory on Windows.
285        Ok(_) => fs::remove_file(path).or_else(|_| fs::remove_dir(path)),
286        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
287        Err(error) => Err(error),
288    }
289}
290
291/// Make `link` a symbolic link to `target`, whatever it was.
292fn point(link: &Path, target: &Path) -> io::Result<()> {
293    if fs::read_link(link).is_ok_and(|current| current == target) {
294        return Ok(());
295    }
296    remove(link)?;
297    link_to(target, link)
298}
299
300/// Make the file at `to` the file at `from`.
301fn link_file(from: &Path, to: &Path) -> io::Result<()> {
302    let source = fs::metadata(from)?;
303    if fs::symlink_metadata(to).is_ok_and(|current| same_file(&current, &source)) {
304        return Ok(());
305    }
306    remove(to)?;
307    fs::hard_link(from, to).or_else(|_| fs::copy(from, to).map(|_| ()))
308}
309
310/// Give the file at `to` the contents of `from`, as a file of its own.
311fn copy_file(from: &Path, to: &Path) -> io::Result<()> {
312    let contents = fs::read(from)?;
313    if fs::symlink_metadata(to).is_ok_and(|current| current.is_file())
314        && fs::read(to).is_ok_and(|current| current == contents)
315    {
316        return Ok(());
317    }
318    remove(to)?;
319    fs::write(to, contents)
320}
321
322/// Bring the view of `workspace` at `view` up to date: every file and
323/// directory of the workspace, a rewritten file as its author wrote it, and
324/// nothing else. Dependencies are the project's own, so a tool the judged one
325/// starts in turn is the real one.
326fn refresh(workspace: &Path, view: &Path, dependencies: &Path) -> io::Result<View> {
327    let rewritten = rewritten_files(workspace)
328        .into_iter()
329        .map(PathBuf::from)
330        .collect::<BTreeSet<_>>();
331    let mut state = View::default();
332    fs::create_dir_all(view)?;
333    refresh_directory(
334        workspace,
335        view,
336        dependencies,
337        Path::new(""),
338        &rewritten,
339        &mut state,
340    )?;
341    Ok(state)
342}
343
344fn refresh_directory(
345    workspace: &Path,
346    view: &Path,
347    dependencies: &Path,
348    local: &Path,
349    rewritten: &BTreeSet<PathBuf>,
350    state: &mut View,
351) -> io::Result<()> {
352    let mut present = BTreeSet::new();
353    for entry in fs::read_dir(workspace.join(local))? {
354        let entry = entry?;
355        let name = entry.file_name();
356        if name == ".supercov" {
357            continue;
358        }
359        let path = local.join(&name);
360        let (from, to) = (workspace.join(&path), view.join(&path));
361        let file_type = entry.file_type()?;
362        present.insert(name.clone());
363        if name == "node_modules" {
364            let target = if local.as_os_str().is_empty() && dependencies.is_dir() {
365                dependencies.to_path_buf()
366            } else {
367                from
368            };
369            point(&to, &target)?;
370        } else if file_type.is_symlink() {
371            point(&to, &fs::read_link(&from)?)?;
372        } else if file_type.is_dir() {
373            if !fs::symlink_metadata(&to).is_ok_and(|current| current.file_type().is_dir()) {
374                remove(&to)?;
375                fs::create_dir(&to)?;
376            }
377            refresh_directory(workspace, view, dependencies, &path, rewritten, state)?;
378        } else if file_type.is_file() {
379            let authored = authored_path(workspace, &path);
380            if rewritten.contains(&path) && authored.is_file() {
381                copy_file(&authored, &to)?;
382                state.authored.insert(path);
383            } else {
384                link_file(&from, &to)?;
385                state.linked.insert(path);
386            }
387        }
388    }
389    let boundary = local.as_os_str().is_empty() && !present.contains(OsStr::new(BOUNDARY));
390    if boundary && !view.join(BOUNDARY).is_dir() {
391        remove(&view.join(BOUNDARY))?;
392        fs::create_dir(view.join(BOUNDARY))?;
393    }
394    for entry in fs::read_dir(view.join(local))? {
395        let entry = entry?;
396        let kept =
397            present.contains(&entry.file_name()) || (boundary && entry.file_name() == BOUNDARY);
398        if !kept {
399            remove(&entry.path())?;
400        }
401    }
402    Ok(())
403}
404
405/// What the tool left in the view that the workspace does not have.
406#[derive(Default)]
407struct Outcome {
408    /// Rewritten files whose authored text the tool changed.
409    changed_sources: Vec<PathBuf>,
410}
411
412/// Give the workspace what the tool wrote in its view: new files, files it
413/// replaced, and the removal of files it deleted. A file it changed in place
414/// is the workspace's own file already.
415///
416/// The authored text of a rewritten file is the exception. The workspace
417/// holds what was instrumented from it, so a formatter's or a fixer's change
418/// has nowhere to go during a measured run, and is reported instead.
419fn reconcile(workspace: &Path, view: &Path, state: &View) -> io::Result<Outcome> {
420    let mut outcome = Outcome::default();
421    for path in &state.linked {
422        if fs::symlink_metadata(view.join(path)).is_err() {
423            remove(&workspace.join(path))?;
424        }
425    }
426    for path in &state.authored {
427        let unchanged = fs::read(view.join(path))
428            .and_then(|current| Ok(current == fs::read(authored_path(workspace, path))?))
429            .unwrap_or(false);
430        if !unchanged {
431            outcome.changed_sources.push(path.clone());
432        }
433    }
434    reconcile_directory(workspace, view, Path::new(""), state)?;
435    Ok(outcome)
436}
437
438fn reconcile_directory(
439    workspace: &Path,
440    view: &Path,
441    local: &Path,
442    state: &View,
443) -> io::Result<()> {
444    for entry in fs::read_dir(view.join(local))? {
445        let entry = entry?;
446        let path = local.join(entry.file_name());
447        let (from, to) = (view.join(&path), workspace.join(&path));
448        let file_type = entry.file_type()?;
449        if path == Path::new(BOUNDARY) && fs::symlink_metadata(&to).is_err() {
450            continue;
451        }
452        if file_type.is_dir() {
453            if fs::symlink_metadata(&to).is_err() {
454                fs::create_dir(&to)?;
455            }
456            reconcile_directory(workspace, view, &path, state)?;
457        } else if file_type.is_file() && !state.authored.contains(&path) {
458            link_file(&from, &to)?;
459        } else if file_type.is_symlink()
460            && fs::symlink_metadata(&to).is_err()
461            && let Ok(target) = fs::read_link(&from)
462        {
463            link_to(&target, &to)?;
464        }
465    }
466    Ok(())
467}
468
469/// `value` with every mention of the workspace's path replaced by the view's.
470fn in_view(value: &OsStr, workspace: &[&Path], view: &Path) -> OsString {
471    let Some(text) = value.to_str() else {
472        return value.to_owned();
473    };
474    let mut translated = text.to_owned();
475    for workspace in workspace {
476        if let (Some(from), Some(to)) = (workspace.to_str(), view.to_str())
477            && !from.is_empty()
478        {
479            translated = translated.replace(from, to);
480        }
481    }
482    translated.into()
483}
484
485fn status(command: &mut Command) -> i32 {
486    match command.status() {
487        Ok(status) => status.code().unwrap_or(1),
488        Err(error) => {
489            eprintln!(
490                "[supercov] could not start {}: {error}",
491                command.get_program().to_string_lossy()
492            );
493            127
494        }
495    }
496}
497
498/// Run `tool` with `arguments` in the source view of the workspace this
499/// process was started in, and return its exit status. Outside a workspace
500/// of a run, the tool runs where it was started.
501pub fn run_source_tool(tool: &Path, arguments: &[OsString]) -> i32 {
502    let run_as_is = || status(Command::new(tool).args(arguments));
503    let Some(named) = std::env::var_os("SUPERCOV_PROJECT_ROOT").map(PathBuf::from) else {
504        return run_as_is();
505    };
506    // By paths a child process can be started in: Windows' own canonical
507    // form is one `cmd.exe` refuses as a working directory.
508    let (Ok(workspace), Ok(directory)) = (
509        crate::workspace::canonicalize_simplified(&named),
510        std::env::current_dir().and_then(crate::workspace::canonicalize_simplified),
511    ) else {
512        return run_as_is();
513    };
514    let (Ok(local), Some(view)) = (
515        directory.strip_prefix(&workspace),
516        source_view_path(&workspace),
517    ) else {
518        return run_as_is();
519    };
520    let dependencies = std::env::var_os("SUPERCOV_SOURCE_PROJECT_ROOT")
521        .map(|root| PathBuf::from(root).join("node_modules"))
522        .unwrap_or_else(|| workspace.join("node_modules"));
523    let state = match refresh(&workspace, &view, &dependencies) {
524        Ok(state) => state,
525        Err(error) => {
526            eprintln!(
527                "[supercov] {} reads the instrumented files: the project's source could not be laid out for it at {} ({error})",
528                tool.display(),
529                view.display()
530            );
531            return run_as_is();
532        }
533    };
534    let names = [workspace.as_path(), named.as_path()];
535    let mut command = Command::new(tool);
536    command.current_dir(view.join(local)).args(
537        arguments
538            .iter()
539            .map(|argument| in_view(argument, &names, &view)),
540    );
541    for (name, value) in std::env::vars_os() {
542        // Where the tool is, and where a package manager says it is. PATH
543        // too: the view's dependencies are the project's, so what the tool
544        // starts in turn is the real one. Everything else that names the
545        // workspace is Supercov's, the preload in NODE_OPTIONS included, and
546        // belongs to the workspace still.
547        let name_text = name.to_string_lossy();
548        if ["PWD", "OLDPWD", "INIT_CWD", "PATH"]
549            .iter()
550            .any(|location| name_text.eq_ignore_ascii_case(location))
551            || name_text.starts_with("npm_")
552        {
553            let translated = in_view(&value, &names, &view);
554            if translated != value {
555                command.env(name, translated);
556            }
557        }
558    }
559    let code = status(&mut command);
560    match reconcile(&workspace, &view, &state) {
561        Ok(outcome) if !outcome.changed_sources.is_empty() => {
562            let listed = outcome
563                .changed_sources
564                .iter()
565                .take(3)
566                .map(|path| path.display().to_string())
567                .collect::<Vec<_>>()
568                .join(", ");
569            eprintln!(
570                "[supercov] {} changed {} source file(s) ({listed}{}); a measured run does not apply a tool's changes to source, so run it without Supercov to keep them",
571                tool.file_name().unwrap_or_default().to_string_lossy(),
572                outcome.changed_sources.len(),
573                if outcome.changed_sources.len() > 3 {
574                    ", …"
575                } else {
576                    ""
577                },
578            );
579        }
580        Ok(_) => {}
581        Err(error) => eprintln!(
582            "[supercov] what {} wrote could not be brought into the workspace: {error}",
583            tool.display()
584        ),
585    }
586    code
587}
588
589#[cfg(all(test, unix))]
590mod tests {
591    use super::*;
592    use std::sync::atomic::{AtomicU64, Ordering};
593
594    static UNIQUE: AtomicU64 = AtomicU64::new(0);
595
596    /// A workspace with one rewritten file, one plain file, a dependency and
597    /// Supercov's own directory.
598    fn workspace() -> (PathBuf, PathBuf, PathBuf) {
599        let root = std::env::temp_dir().join(format!(
600            "supercov-source-view-{}-{}-{}",
601            std::process::id(),
602            std::time::SystemTime::now()
603                .duration_since(std::time::UNIX_EPOCH)
604                .unwrap()
605                .as_nanos(),
606            UNIQUE.fetch_add(1, Ordering::Relaxed)
607        ));
608        let workspace = root.join("workspaces/workspace/project");
609        fs::create_dir_all(workspace.join("src")).unwrap();
610        fs::create_dir_all(workspace.join("node_modules/.bin")).unwrap();
611        fs::create_dir_all(workspace.join(".supercov/node_modules/.authored/src")).unwrap();
612        fs::write(workspace.join("src/a.js"), "probe(); const a = 1;\n").unwrap();
613        fs::write(
614            workspace.join(".supercov/node_modules/.authored/src/a.js"),
615            "const a = 1;\n",
616        )
617        .unwrap();
618        fs::write(
619            workspace.join(".supercov/node_modules/authored-sources.json"),
620            "[\"src/a.js\"]",
621        )
622        .unwrap();
623        fs::write(workspace.join("README.md"), "read me\n").unwrap();
624        let dependencies = root.join("project/node_modules");
625        fs::create_dir_all(&dependencies).unwrap();
626        let view = source_view_path(&workspace).unwrap();
627        (workspace, view, dependencies)
628    }
629
630    #[test]
631    fn a_view_holds_each_file_as_its_author_wrote_it() {
632        let (workspace, view, dependencies) = workspace();
633        let state = refresh(&workspace, &view, &dependencies).unwrap();
634        assert_eq!(
635            fs::read_to_string(view.join("src/a.js")).unwrap(),
636            "const a = 1;\n"
637        );
638        assert!(same_file(
639            &fs::metadata(view.join("README.md")).unwrap(),
640            &fs::metadata(workspace.join("README.md")).unwrap()
641        ));
642        assert_eq!(
643            fs::read_link(view.join("node_modules")).unwrap(),
644            dependencies
645        );
646        assert!(!view.join(".supercov").exists());
647        assert_eq!(state.authored.len(), 1);
648        assert!(view.join(".git").is_dir());
649
650        // What a tool writes there is the workspace's: a new file, a file
651        // replaced by another, a file removed, a file changed in place.
652        fs::create_dir(view.join("reports")).unwrap();
653        fs::write(view.join("reports/lint.json"), "[]").unwrap();
654        fs::remove_file(view.join("README.md")).unwrap();
655        fs::write(view.join("README.md"), "replaced\n").unwrap();
656        let outcome = reconcile(&workspace, &view, &state).unwrap();
657        assert_eq!(
658            fs::read_to_string(workspace.join("reports/lint.json")).unwrap(),
659            "[]"
660        );
661        assert_eq!(
662            fs::read_to_string(workspace.join("README.md")).unwrap(),
663            "replaced\n"
664        );
665        assert!(outcome.changed_sources.is_empty());
666        assert!(!workspace.join(".git").exists());
667        // The instrumented file stays what was instrumented.
668        assert_eq!(
669            fs::read_to_string(workspace.join("src/a.js")).unwrap(),
670            "probe(); const a = 1;\n"
671        );
672
673        // A change to authored text has nowhere to go, and is named.
674        fs::write(view.join("src/a.js"), "const a = 1\n").unwrap();
675        let outcome = reconcile(&workspace, &view, &state).unwrap();
676        assert_eq!(outcome.changed_sources, [PathBuf::from("src/a.js")]);
677        assert_eq!(
678            fs::read_to_string(workspace.join(".supercov/node_modules/.authored/src/a.js"))
679                .unwrap(),
680            "const a = 1;\n"
681        );
682
683        // The next tool starts from the workspace again.
684        fs::remove_file(workspace.join("reports/lint.json")).unwrap();
685        let state = refresh(&workspace, &view, &dependencies).unwrap();
686        assert!(!view.join("reports/lint.json").exists());
687        assert_eq!(
688            fs::read_to_string(view.join("src/a.js")).unwrap(),
689            "const a = 1;\n"
690        );
691        fs::remove_file(view.join("README.md")).unwrap();
692        reconcile(&workspace, &view, &state).unwrap();
693        assert!(!workspace.join("README.md").exists());
694        fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
695    }
696
697    #[test]
698    fn a_launcher_survives_a_quote_in_a_path() {
699        let text = launcher(
700            Path::new("/opt/it's here/supercov"),
701            Path::new("/work/app/node_modules/.bin/biome"),
702        );
703        assert!(text.starts_with("#!/bin/sh\n"), "{text}");
704        assert!(
705            text.contains("exec '/opt/it'\\''s here/supercov' __source-tool '/work/app/node_modules/.bin/biome' \"$@\"\n"),
706            "{text}"
707        );
708    }
709
710    #[test]
711    fn a_windows_tool_gets_a_launcher_for_each_of_its_shims() {
712        // npm writes three files for a tool on Windows: one `cmd.exe` runs,
713        // one for PowerShell, one for a POSIX shell. Each is replaced in its
714        // own language, and each runs the `.cmd`.
715        let (workspace, ..) = workspace();
716        let tools = workspace.join("node_modules/.bin");
717        for file in ["biome", "biome.cmd", "biome.ps1", "jest.cmd"] {
718            fs::write(tools.join(file), "").unwrap();
719        }
720        let judges = BTreeSet::from(["biome".to_owned()]);
721        let binary = Path::new("C:/Program Files/supercov.exe");
722        let shim = tools.join("biome.cmd");
723        let launcher = |name: &str| launcher_on(true, &judges, binary, &tools, OsStr::new(name));
724        assert_eq!(
725            launcher("biome.cmd").unwrap(),
726            format!(
727                "@ECHO off\r\n\"C:/Program Files/supercov.exe\" __source-tool \"{}\" %*\r\n",
728                shim.display()
729            )
730        );
731        assert_eq!(
732            launcher("biome.ps1").unwrap(),
733            format!(
734                "& 'C:/Program Files/supercov.exe' __source-tool '{}' @args\r\nexit $LASTEXITCODE\r\n",
735                shim.display()
736            )
737        );
738        assert!(launcher("biome").unwrap().starts_with("#!/bin/sh\n"));
739        assert!(
740            launcher("biome")
741                .unwrap()
742                .contains(&format!("'{}'", shim.display()))
743        );
744        // A tool that runs code is started as it is.
745        assert_eq!(launcher("jest.cmd"), None);
746        // Elsewhere a tool is one file, started by its name.
747        assert!(launcher_on(false, &judges, binary, &tools, OsStr::new("biome")).is_some());
748        assert_eq!(
749            launcher_on(false, &judges, binary, &tools, OsStr::new("biome.cmd")),
750            None
751        );
752        fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
753    }
754
755    #[test]
756    fn a_package_bin_of_its_own_gets_launchers_in_place() {
757        // A workspace package's dependencies are a tree of their own in the
758        // workspace, hard links to the project's files where the filesystem
759        // has no clones. A launcher replaces the entry: written through it,
760        // it would have been written into the project.
761        let (workspace, _, dependencies) = workspace();
762        let tools = dependencies.join(".bin");
763        fs::create_dir_all(&tools).unwrap();
764        fs::write(tools.join("oxlint"), "the project's own\n").unwrap();
765        fs::write(tools.join("jest"), "runs code\n").unwrap();
766        let copy = workspace.join("packages/app/node_modules/.bin");
767        fs::create_dir_all(&copy).unwrap();
768        fs::hard_link(tools.join("oxlint"), copy.join("oxlint")).unwrap();
769        fs::hard_link(tools.join("jest"), copy.join("jest")).unwrap();
770
771        place_launchers(&tools, &copy).unwrap();
772
773        assert!(
774            fs::read_to_string(copy.join("oxlint"))
775                .unwrap()
776                .contains("__source-tool"),
777        );
778        assert_eq!(
779            fs::read_to_string(tools.join("oxlint")).unwrap(),
780            "the project's own\n"
781        );
782        assert_eq!(
783            fs::read_to_string(copy.join("jest")).unwrap(),
784            "runs code\n"
785        );
786        fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
787    }
788
789    #[test]
790    fn a_path_in_an_argument_is_the_views() {
791        let translated = in_view(
792            OsStr::new("--config=/w/workspaces/workspace/p/biome.json"),
793            &[Path::new("/w/workspaces/workspace/p")],
794            Path::new("/w/workspaces/source/p"),
795        );
796        assert_eq!(translated, "--config=/w/workspaces/source/p/biome.json");
797    }
798}