1use 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
35pub const SOURCE_TOOL_COMMAND: &str = "__source-tool";
37
38pub const SOURCE_TOOLS_VARIABLE: &str = "SUPERCOV_SOURCE_TOOLS";
40
41pub const SOURCE_TOOLS: &[&str] = &["biome", "cspell", "dprint", "knip", "oxlint"];
45
46pub 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
65pub 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
80fn 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
89fn 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
99pub 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
143pub fn launching_binary() -> Option<PathBuf> {
145 std::env::current_exe()
146 .ok()
147 .map(crate::workspace::simplified)
148}
149
150pub 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
164pub 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
185pub 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 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
243pub 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 let kind = kind.file_name()?.to_str()?;
251 let number = kind.strip_prefix("workspace")?;
252 (number.is_empty()
253 || number
254 .strip_prefix('-')
255 .is_some_and(|number| number.parse::<usize>().is_ok()))
256 .then(|| container.join(format!("source{number}")).join(name))
257}
258
259const BOUNDARY: &str = ".git";
266
267fn current_source(workspace: &Path, file: &Path) -> PathBuf {
270 let changed = changed_path(workspace, file);
271 if changed.is_file() {
272 changed
273 } else {
274 authored_path(workspace, file)
275 }
276}
277
278#[derive(Default)]
280struct View {
281 linked: BTreeSet<PathBuf>,
283 authored: BTreeSet<PathBuf>,
285}
286
287#[cfg(unix)]
288fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
289 use std::os::unix::fs::MetadataExt;
290 left.dev() == right.dev() && left.ino() == right.ino()
291}
292
293#[cfg(not(unix))]
296fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
297 left.len() == right.len()
298 && left.modified().is_ok()
299 && left.modified().ok() == right.modified().ok()
300}
301
302fn link_to(target: &Path, link: &Path) -> io::Result<()> {
305 crate::workspace::create_link(target, link, target.is_dir())
306}
307
308fn remove(path: &Path) -> io::Result<()> {
309 match fs::symlink_metadata(path) {
310 Ok(metadata) if metadata.file_type().is_dir() => fs::remove_dir_all(path),
311 Ok(_) => fs::remove_file(path).or_else(|_| fs::remove_dir(path)),
313 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
314 Err(error) => Err(error),
315 }
316}
317
318fn point(link: &Path, target: &Path) -> io::Result<()> {
320 if fs::read_link(link).is_ok_and(|current| current == target) {
321 return Ok(());
322 }
323 remove(link)?;
324 link_to(target, link)
325}
326
327fn link_file(from: &Path, to: &Path) -> io::Result<()> {
329 let source = fs::metadata(from)?;
330 if fs::symlink_metadata(to).is_ok_and(|current| same_file(¤t, &source)) {
331 return Ok(());
332 }
333 remove(to)?;
334 fs::hard_link(from, to).or_else(|_| fs::copy(from, to).map(|_| ()))
335}
336
337fn copy_file(from: &Path, to: &Path) -> io::Result<()> {
339 let contents = fs::read(from)?;
340 if fs::symlink_metadata(to).is_ok_and(|current| current.is_file())
341 && fs::read(to).is_ok_and(|current| current == contents)
342 {
343 return Ok(());
344 }
345 remove(to)?;
346 fs::write(to, contents)
347}
348
349fn refresh(workspace: &Path, view: &Path, dependencies: &Path) -> io::Result<View> {
354 let rewritten = rewritten_files(workspace)
355 .into_iter()
356 .map(PathBuf::from)
357 .collect::<BTreeSet<_>>();
358 let mut state = View::default();
359 fs::create_dir_all(view)?;
360 refresh_directory(
361 workspace,
362 view,
363 dependencies,
364 Path::new(""),
365 &rewritten,
366 &mut state,
367 )?;
368 Ok(state)
369}
370
371fn refresh_directory(
372 workspace: &Path,
373 view: &Path,
374 dependencies: &Path,
375 local: &Path,
376 rewritten: &BTreeSet<PathBuf>,
377 state: &mut View,
378) -> io::Result<()> {
379 let mut present = BTreeSet::new();
380 for entry in fs::read_dir(workspace.join(local))? {
381 let entry = entry?;
382 let name = entry.file_name();
383 if name == ".supercov" {
384 continue;
385 }
386 let path = local.join(&name);
387 let (from, to) = (workspace.join(&path), view.join(&path));
388 let file_type = entry.file_type()?;
389 present.insert(name.clone());
390 if name == "node_modules" {
391 let target = if local.as_os_str().is_empty() && dependencies.is_dir() {
392 dependencies.to_path_buf()
393 } else {
394 from
395 };
396 point(&to, &target)?;
397 } else if file_type.is_symlink() {
398 point(&to, &fs::read_link(&from)?)?;
399 } else if file_type.is_dir() {
400 if !fs::symlink_metadata(&to).is_ok_and(|current| current.file_type().is_dir()) {
401 remove(&to)?;
402 fs::create_dir(&to)?;
403 }
404 refresh_directory(workspace, view, dependencies, &path, rewritten, state)?;
405 } else if file_type.is_file() {
406 let authored = current_source(workspace, &path);
407 if rewritten.contains(&path) && authored.is_file() {
408 copy_file(&authored, &to)?;
409 state.authored.insert(path);
410 } else {
411 link_file(&from, &to)?;
412 state.linked.insert(path);
413 }
414 }
415 }
416 let boundary = local.as_os_str().is_empty() && !present.contains(OsStr::new(BOUNDARY));
417 if boundary && !view.join(BOUNDARY).is_dir() {
418 remove(&view.join(BOUNDARY))?;
419 fs::create_dir(view.join(BOUNDARY))?;
420 }
421 for entry in fs::read_dir(view.join(local))? {
422 let entry = entry?;
423 let kept =
424 present.contains(&entry.file_name()) || (boundary && entry.file_name() == BOUNDARY);
425 if !kept {
426 remove(&entry.path())?;
427 }
428 }
429 Ok(())
430}
431
432fn reconcile(workspace: &Path, view: &Path, state: &View) -> io::Result<usize> {
441 let mut changed = 0;
442 for path in &state.linked {
443 if fs::symlink_metadata(view.join(path)).is_err() {
444 remove(&workspace.join(path))?;
445 }
446 }
447 for path in &state.authored {
448 let Ok(text) = fs::read(view.join(path)) else {
449 continue;
450 };
451 if fs::read(current_source(workspace, path)).is_ok_and(|current| current == text) {
452 continue;
453 }
454 let kept = changed_path(workspace, path);
455 if let Some(parent) = kept.parent() {
456 fs::create_dir_all(parent)?;
457 }
458 fs::write(&kept, text)?;
459 changed += 1;
460 }
461 reconcile_directory(workspace, view, Path::new(""), state)?;
462 Ok(changed)
463}
464
465fn reconcile_directory(
466 workspace: &Path,
467 view: &Path,
468 local: &Path,
469 state: &View,
470) -> io::Result<()> {
471 for entry in fs::read_dir(view.join(local))? {
472 let entry = entry?;
473 let path = local.join(entry.file_name());
474 let (from, to) = (view.join(&path), workspace.join(&path));
475 let file_type = entry.file_type()?;
476 if path == Path::new(BOUNDARY) && fs::symlink_metadata(&to).is_err() {
477 continue;
478 }
479 if file_type.is_dir() {
480 if fs::symlink_metadata(&to).is_err() {
481 fs::create_dir(&to)?;
482 }
483 reconcile_directory(workspace, view, &path, state)?;
484 } else if file_type.is_file() && !state.authored.contains(&path) {
485 link_file(&from, &to)?;
486 } else if file_type.is_symlink()
487 && fs::symlink_metadata(&to).is_err()
488 && let Ok(target) = fs::read_link(&from)
489 {
490 link_to(&target, &to)?;
491 }
492 }
493 Ok(())
494}
495
496fn in_view(value: &OsStr, workspace: &[&Path], view: &Path) -> OsString {
498 let Some(text) = value.to_str() else {
499 return value.to_owned();
500 };
501 let mut translated = text.to_owned();
502 for workspace in workspace {
503 if let (Some(from), Some(to)) = (workspace.to_str(), view.to_str())
504 && !from.is_empty()
505 {
506 translated = translated.replace(from, to);
507 }
508 }
509 translated.into()
510}
511
512fn status(command: &mut Command) -> i32 {
513 match command.status() {
514 Ok(status) => status.code().unwrap_or(1),
515 Err(error) => {
516 eprintln!(
517 "[supercov] could not start {}: {error}",
518 command.get_program().to_string_lossy()
519 );
520 127
521 }
522 }
523}
524
525pub fn run_source_tool(tool: &Path, arguments: &[OsString]) -> i32 {
529 let run_as_is = || status(Command::new(tool).args(arguments));
530 let Some(named) = std::env::var_os("SUPERCOV_PROJECT_ROOT").map(PathBuf::from) else {
531 return run_as_is();
532 };
533 let (Ok(workspace), Ok(directory)) = (
536 crate::workspace::canonicalize_simplified(&named),
537 std::env::current_dir().and_then(crate::workspace::canonicalize_simplified),
538 ) else {
539 return run_as_is();
540 };
541 let (Ok(local), Some(view)) = (
542 directory.strip_prefix(&workspace),
543 source_view_path(&workspace),
544 ) else {
545 return run_as_is();
546 };
547 let dependencies = std::env::var_os("SUPERCOV_SOURCE_PROJECT_ROOT")
548 .map(|root| PathBuf::from(root).join("node_modules"))
549 .unwrap_or_else(|| workspace.join("node_modules"));
550 let state = match refresh(&workspace, &view, &dependencies) {
551 Ok(state) => state,
552 Err(error) => {
553 eprintln!(
554 "[supercov] {} reads the instrumented files: the project's source could not be laid out for it at {} ({error})",
555 tool.display(),
556 view.display()
557 );
558 return run_as_is();
559 }
560 };
561 let names = [workspace.as_path(), named.as_path()];
562 let mut command = Command::new(tool);
563 command.current_dir(view.join(local)).args(
564 arguments
565 .iter()
566 .map(|argument| in_view(argument, &names, &view)),
567 );
568 for (name, value) in std::env::vars_os() {
569 let name_text = name.to_string_lossy();
575 if ["PWD", "OLDPWD", "INIT_CWD", "PATH"]
576 .iter()
577 .any(|location| name_text.eq_ignore_ascii_case(location))
578 || name_text.starts_with("npm_")
579 {
580 let translated = in_view(&value, &names, &view);
581 if translated != value {
582 command.env(name, translated);
583 }
584 }
585 }
586 let code = status(&mut command);
587 if let Err(error) = reconcile(&workspace, &view, &state) {
588 eprintln!(
589 "[supercov] what {} wrote could not be brought into the workspace: {error}",
590 tool.display()
591 );
592 }
593 code
594}
595
596#[derive(Debug, Default, PartialEq, Eq)]
598pub struct SourceChanges {
599 pub applied: Vec<PathBuf>,
601 pub kept_back: Vec<PathBuf>,
604}
605
606pub fn clear_source_changes(workspace: &Path) -> io::Result<()> {
608 remove(&changed_directory(workspace))
609}
610
611pub fn apply_source_changes(root: &Path, workspace: &Path) -> io::Result<SourceChanges> {
615 let mut changes = SourceChanges::default();
616 let directory = changed_directory(workspace);
617 if directory.is_dir() {
618 apply_directory(root, workspace, &directory, Path::new(""), &mut changes)?;
619 remove(&directory)?;
620 }
621 changes.applied.sort();
622 changes.kept_back.sort();
623 Ok(changes)
624}
625
626fn apply_directory(
627 root: &Path,
628 workspace: &Path,
629 directory: &Path,
630 local: &Path,
631 changes: &mut SourceChanges,
632) -> io::Result<()> {
633 for entry in fs::read_dir(directory.join(local))? {
634 let entry = entry?;
635 let path = local.join(entry.file_name());
636 if entry.file_type()?.is_dir() {
637 apply_directory(root, workspace, directory, &path, changes)?;
638 continue;
639 }
640 let text = fs::read(entry.path())?;
641 let started_from = fs::read(authored_path(workspace, &path)).ok();
642 if started_from.as_ref() == Some(&text) {
644 continue;
645 }
646 let project = root.join(&path);
647 if started_from.is_some() && fs::read(&project).ok() == started_from {
648 fs::write(&project, text)?;
651 changes.applied.push(path);
652 } else {
653 changes.kept_back.push(path);
654 }
655 }
656 Ok(())
657}
658
659#[cfg(all(test, unix))]
660mod tests {
661 use super::*;
662 use std::sync::atomic::{AtomicU64, Ordering};
663
664 static UNIQUE: AtomicU64 = AtomicU64::new(0);
665
666 fn workspace() -> (PathBuf, PathBuf, PathBuf) {
669 let root = std::env::temp_dir().join(format!(
670 "supercov-source-view-{}-{}-{}",
671 std::process::id(),
672 std::time::SystemTime::now()
673 .duration_since(std::time::UNIX_EPOCH)
674 .unwrap()
675 .as_nanos(),
676 UNIQUE.fetch_add(1, Ordering::Relaxed)
677 ));
678 let workspace = root.join("workspaces/workspace/project");
679 fs::create_dir_all(workspace.join("src")).unwrap();
680 fs::create_dir_all(workspace.join("node_modules/.bin")).unwrap();
681 fs::create_dir_all(workspace.join(".supercov/node_modules/.authored/src")).unwrap();
682 fs::write(workspace.join("src/a.js"), "probe(); const a = 1;\n").unwrap();
683 fs::write(
684 workspace.join(".supercov/node_modules/.authored/src/a.js"),
685 "const a = 1;\n",
686 )
687 .unwrap();
688 fs::write(
689 workspace.join(".supercov/node_modules/authored-sources.json"),
690 "[\"src/a.js\"]",
691 )
692 .unwrap();
693 fs::write(workspace.join("README.md"), "read me\n").unwrap();
694 let dependencies = root.join("project/node_modules");
695 fs::create_dir_all(&dependencies).unwrap();
696 let view = source_view_path(&workspace).unwrap();
697 (workspace, view, dependencies)
698 }
699
700 #[test]
701 fn a_view_holds_each_file_as_its_author_wrote_it() {
702 let (workspace, view, dependencies) = workspace();
703 let state = refresh(&workspace, &view, &dependencies).unwrap();
704 assert_eq!(
705 fs::read_to_string(view.join("src/a.js")).unwrap(),
706 "const a = 1;\n"
707 );
708 assert!(same_file(
709 &fs::metadata(view.join("README.md")).unwrap(),
710 &fs::metadata(workspace.join("README.md")).unwrap()
711 ));
712 assert_eq!(
713 fs::read_link(view.join("node_modules")).unwrap(),
714 dependencies
715 );
716 assert!(!view.join(".supercov").exists());
717 assert_eq!(state.authored.len(), 1);
718 assert!(view.join(".git").is_dir());
719
720 fs::create_dir(view.join("reports")).unwrap();
723 fs::write(view.join("reports/lint.json"), "[]").unwrap();
724 fs::remove_file(view.join("README.md")).unwrap();
725 fs::write(view.join("README.md"), "replaced\n").unwrap();
726 let changed = reconcile(&workspace, &view, &state).unwrap();
727 assert_eq!(
728 fs::read_to_string(workspace.join("reports/lint.json")).unwrap(),
729 "[]"
730 );
731 assert_eq!(
732 fs::read_to_string(workspace.join("README.md")).unwrap(),
733 "replaced\n"
734 );
735 assert_eq!(changed, 0);
736 assert!(!workspace.join(".git").exists());
737 assert_eq!(
739 fs::read_to_string(workspace.join("src/a.js")).unwrap(),
740 "probe(); const a = 1;\n"
741 );
742
743 fs::write(view.join("src/a.js"), "const a = 1\n").unwrap();
747 assert_eq!(reconcile(&workspace, &view, &state).unwrap(), 1);
748 assert_eq!(
749 fs::read_to_string(workspace.join(".supercov/node_modules/.changed/src/a.js")).unwrap(),
750 "const a = 1\n"
751 );
752 assert_eq!(
753 fs::read_to_string(workspace.join(".supercov/node_modules/.authored/src/a.js"))
754 .unwrap(),
755 "const a = 1;\n"
756 );
757 assert_eq!(
758 fs::read_to_string(workspace.join("src/a.js")).unwrap(),
759 "probe(); const a = 1;\n"
760 );
761 assert_eq!(reconcile(&workspace, &view, &state).unwrap(), 0);
763
764 fs::remove_file(workspace.join("reports/lint.json")).unwrap();
766 let state = refresh(&workspace, &view, &dependencies).unwrap();
767 assert!(!view.join("reports/lint.json").exists());
768 assert_eq!(
770 fs::read_to_string(view.join("src/a.js")).unwrap(),
771 "const a = 1\n"
772 );
773 fs::remove_file(view.join("README.md")).unwrap();
774 reconcile(&workspace, &view, &state).unwrap();
775 assert!(!workspace.join("README.md").exists());
776 fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
777 }
778
779 #[test]
780 fn a_tools_change_to_source_is_the_projects_when_the_command_ends() {
781 let (workspace, _, dependencies) = workspace();
782 let root = dependencies.parent().unwrap().to_path_buf();
783 fs::create_dir_all(root.join("src")).unwrap();
784 fs::create_dir_all(workspace.join("lib")).unwrap();
785 for (file, authored, project, written) in [
788 (
789 "src/a.js",
790 "const a = 1;\n",
791 "const a = 1;\n",
792 "const a = 1\n",
793 ),
794 (
795 "src/b.js",
796 "const b = 2;\n",
797 "const b = 3;\n",
798 "const b = 2\n",
799 ),
800 (
801 "src/c.js",
802 "const c = 4\n",
803 "const c = 4\n",
804 "const c = 4\n",
805 ),
806 ] {
807 fs::write(authored_path(&workspace, Path::new(file)), authored).unwrap();
808 fs::write(root.join(file), project).unwrap();
809 let changed = changed_path(&workspace, Path::new(file));
810 fs::create_dir_all(changed.parent().unwrap()).unwrap();
811 fs::write(changed, written).unwrap();
812 }
813
814 let changes = apply_source_changes(&root, &workspace).unwrap();
815
816 assert_eq!(changes.applied, [PathBuf::from("src/a.js")]);
817 assert_eq!(changes.kept_back, [PathBuf::from("src/b.js")]);
818 assert_eq!(
819 fs::read_to_string(root.join("src/a.js")).unwrap(),
820 "const a = 1\n"
821 );
822 assert_eq!(
823 fs::read_to_string(root.join("src/b.js")).unwrap(),
824 "const b = 3;\n"
825 );
826 assert_eq!(
828 apply_source_changes(&root, &workspace).unwrap(),
829 SourceChanges::default()
830 );
831 fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
832 }
833
834 #[test]
835 fn a_launcher_survives_a_quote_in_a_path() {
836 let text = launcher(
837 Path::new("/opt/it's here/supercov"),
838 Path::new("/work/app/node_modules/.bin/biome"),
839 );
840 assert!(text.starts_with("#!/bin/sh\n"), "{text}");
841 assert!(
842 text.contains("exec '/opt/it'\\''s here/supercov' __source-tool '/work/app/node_modules/.bin/biome' \"$@\"\n"),
843 "{text}"
844 );
845 }
846
847 #[test]
848 fn a_windows_tool_gets_a_launcher_for_each_of_its_shims() {
849 let (workspace, ..) = workspace();
853 let tools = workspace.join("node_modules/.bin");
854 for file in ["biome", "biome.cmd", "biome.ps1", "jest.cmd"] {
855 fs::write(tools.join(file), "").unwrap();
856 }
857 let judges = BTreeSet::from(["biome".to_owned()]);
858 let binary = Path::new("C:/Program Files/supercov.exe");
859 let shim = tools.join("biome.cmd");
860 let launcher = |name: &str| launcher_on(true, &judges, binary, &tools, OsStr::new(name));
861 assert_eq!(
862 launcher("biome.cmd").unwrap(),
863 format!(
864 "@ECHO off\r\n\"C:/Program Files/supercov.exe\" __source-tool \"{}\" %*\r\n",
865 shim.display()
866 )
867 );
868 assert_eq!(
869 launcher("biome.ps1").unwrap(),
870 format!(
871 "& 'C:/Program Files/supercov.exe' __source-tool '{}' @args\r\nexit $LASTEXITCODE\r\n",
872 shim.display()
873 )
874 );
875 assert!(launcher("biome").unwrap().starts_with("#!/bin/sh\n"));
876 assert!(
877 launcher("biome")
878 .unwrap()
879 .contains(&format!("'{}'", shim.display()))
880 );
881 assert_eq!(launcher("jest.cmd"), None);
883 assert!(launcher_on(false, &judges, binary, &tools, OsStr::new("biome")).is_some());
885 assert_eq!(
886 launcher_on(false, &judges, binary, &tools, OsStr::new("biome.cmd")),
887 None
888 );
889 fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
890 }
891
892 #[test]
893 fn a_package_bin_of_its_own_gets_launchers_in_place() {
894 let (workspace, _, dependencies) = workspace();
899 let tools = dependencies.join(".bin");
900 fs::create_dir_all(&tools).unwrap();
901 fs::write(tools.join("oxlint"), "the project's own\n").unwrap();
902 fs::write(tools.join("jest"), "runs code\n").unwrap();
903 let copy = workspace.join("packages/app/node_modules/.bin");
904 fs::create_dir_all(©).unwrap();
905 fs::hard_link(tools.join("oxlint"), copy.join("oxlint")).unwrap();
906 fs::hard_link(tools.join("jest"), copy.join("jest")).unwrap();
907
908 place_launchers(&tools, ©).unwrap();
909
910 assert!(
911 fs::read_to_string(copy.join("oxlint"))
912 .unwrap()
913 .contains("__source-tool"),
914 );
915 assert_eq!(
916 fs::read_to_string(tools.join("oxlint")).unwrap(),
917 "the project's own\n"
918 );
919 assert_eq!(
920 fs::read_to_string(copy.join("jest")).unwrap(),
921 "runs code\n"
922 );
923 fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
924 }
925
926 #[test]
927 fn a_path_in_an_argument_is_the_views() {
928 let translated = in_view(
929 OsStr::new("--config=/w/workspaces/workspace/p/biome.json"),
930 &[Path::new("/w/workspaces/workspace/p")],
931 Path::new("/w/workspaces/source/p"),
932 );
933 assert_eq!(translated, "--config=/w/workspaces/source/p/biome.json");
934 }
935}