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 (kind.file_name()? == "workspace").then(|| container.join("source").join(name))
250}
251
252const BOUNDARY: &str = ".git";
259
260fn 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#[derive(Default)]
273struct View {
274 linked: BTreeSet<PathBuf>,
276 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#[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
295fn 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 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
311fn 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
320fn 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(¤t, &source)) {
324 return Ok(());
325 }
326 remove(to)?;
327 fs::hard_link(from, to).or_else(|_| fs::copy(from, to).map(|_| ()))
328}
329
330fn 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
342fn 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
425fn 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
489fn 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
518pub 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 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 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#[derive(Debug, Default, PartialEq, Eq)]
591pub struct SourceChanges {
592 pub applied: Vec<PathBuf>,
594 pub kept_back: Vec<PathBuf>,
597}
598
599pub fn clear_source_changes(workspace: &Path) -> io::Result<()> {
601 remove(&changed_directory(workspace))
602}
603
604pub 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 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 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 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 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 assert_eq!(
732 fs::read_to_string(workspace.join("src/a.js")).unwrap(),
733 "probe(); const a = 1;\n"
734 );
735
736 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 assert_eq!(reconcile(&workspace, &view, &state).unwrap(), 0);
756
757 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 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 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 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 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 assert_eq!(launcher("jest.cmd"), None);
876 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 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(©).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, ©).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}