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