1use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use crate::checker;
25use crate::config::Config;
26
27const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ProbeStatus {
38 Ok,
40 Degraded,
42 Down,
44 Skipped,
46}
47
48impl ProbeStatus {
49 #[must_use]
55 const fn severity(self) -> u8 {
56 match self {
57 Self::Skipped => 0,
58 Self::Ok => 1,
59 Self::Degraded => 2,
60 Self::Down => 3,
61 }
62 }
63
64 #[must_use]
65 pub const fn worst(self, other: Self) -> Self {
66 if other.severity() > self.severity() {
67 other
68 } else {
69 self
70 }
71 }
72
73 #[must_use]
74 const fn wire(self) -> checker::ProbeStatus {
75 match self {
76 Self::Ok => checker::ProbeStatus::Ok,
77 Self::Degraded => checker::ProbeStatus::Degraded,
78 Self::Down => checker::ProbeStatus::Down,
79 Self::Skipped => checker::ProbeStatus::Skipped,
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Probe {
87 pub key: String,
89 pub status: ProbeStatus,
90 pub detail: String,
92 pub engine: String,
94 pub blames_key: bool,
96}
97
98impl Probe {
99 fn new(
100 key: impl Into<String>,
101 engine: &str,
102 status: ProbeStatus,
103 detail: impl Into<String>,
104 ) -> Self {
105 Self {
106 key: key.into(),
107 status,
108 detail: detail.into(),
109 engine: engine.to_string(),
110 blames_key: false,
111 }
112 }
113
114 #[must_use]
119 const fn blaming_key(mut self) -> Self {
120 self.blames_key = true;
121 self
122 }
123
124 #[must_use]
125 pub fn into_wire(self) -> checker::ConfigProbe {
126 checker::ConfigProbe {
127 key: self.key,
128 status: self.status.wire() as i32,
129 detail: self.detail,
130 engine: self.engine,
131 blames_key: self.blames_key,
132 }
133 }
134}
135
136pub async fn probe_config(config: &Config, workspace_root: &Path) -> Vec<Probe> {
142 let (harper, languagetool, vale, proselint, spell) = tokio::join!(
143 probe_harper(config),
144 probe_languagetool(config),
145 probe_vale(config),
146 probe_proselint(config),
147 probe_spell_language(config, workspace_root),
148 );
149
150 let mut out = Vec::new();
151 out.extend(harper);
152 out.extend(languagetool);
153 out.extend(vale);
154 out.extend(proselint);
155 out.extend(spell);
156 out
157}
158
159async fn probe_harper(config: &Config) -> Vec<Probe> {
165 const ENGINE: &str = "harper";
166 if !config.engines.harper.enabled {
167 return vec![Probe::new(
168 "engines.harper",
169 ENGINE,
170 ProbeStatus::Skipped,
171 "Harper is switched off.",
172 )];
173 }
174
175 let linters = config.engines.harper.linters.clone();
176 let unknown: Vec<String> = tokio::task::spawn_blocking(move || {
179 if linters.is_empty() {
180 return Vec::new();
181 }
182 let dict = harper_core::spell::FstDictionary::curated();
183 let group =
184 harper_core::linting::LintGroup::new_curated(dict, harper_core::Dialect::American);
185 let mut unknown: Vec<String> = linters
186 .keys()
187 .filter(|name| !group.contains_key(name))
188 .cloned()
189 .collect();
190 unknown.sort();
191 unknown
192 })
193 .await
194 .unwrap_or_default();
195
196 let mut probes = vec![Probe::new(
197 "engines.harper",
198 ENGINE,
199 ProbeStatus::Ok,
200 "Harper is built in and always available.",
201 )];
202 for name in unknown {
203 probes.push(
204 Probe::new(
205 format!("engines.harper.linters.{name}"),
206 ENGINE,
207 ProbeStatus::Down,
208 format!(
209 "Harper has no linter called \"{name}\", so this setting does nothing. \
210 Rule names are case-sensitive and spelled like LongSentences."
211 ),
212 )
213 .blaming_key(),
214 );
215 }
216 probes
217}
218
219async fn probe_languagetool(config: &Config) -> Vec<Probe> {
227 const ENGINE: &str = "languagetool";
228 let lt = &config.engines.languagetool;
229 if !lt.enabled {
230 return vec![Probe::new(
231 "engines.languagetool",
232 ENGINE,
233 ProbeStatus::Skipped,
234 "LanguageTool is switched off.",
235 )];
236 }
237
238 let blame_url = |why: String| {
241 vec![
242 Probe::new(
243 "engines.languagetool",
244 ENGINE,
245 ProbeStatus::Down,
246 why.clone(),
247 ),
248 Probe::new("engines.languagetool.url", ENGINE, ProbeStatus::Down, why),
249 ]
250 };
251
252 if let Err(why) = crate::engines::usable_languagetool_url(<.url) {
253 return blame_url(why);
254 }
255
256 let endpoint = format!("{}/v2/languages", lt.url.trim_end_matches('/'));
257 let entries = match languagetool_languages(&endpoint).await {
258 Ok(entries) => entries,
259 Err(why) => return blame_url(why),
260 };
261
262 let served: BTreeSet<String> = entries
263 .iter()
264 .flat_map(|entry| [entry.code.to_lowercase(), entry.long_code.to_lowercase()])
265 .collect();
266
267 let reached = format!(
268 "LanguageTool answered at {}, serving {} languages.",
269 lt.url,
270 entries.len()
271 );
272 let mut probes = vec![
273 Probe::new(
274 "engines.languagetool",
275 ENGINE,
276 ProbeStatus::Ok,
277 reached.clone(),
278 ),
279 Probe::new("engines.languagetool.url", ENGINE, ProbeStatus::Ok, reached),
280 ];
281
282 if !serves(&served, &config.engines.spell_language) {
286 probes.push(Probe::new(
287 "engines.languagetool",
288 ENGINE,
289 ProbeStatus::Degraded,
290 format!(
291 "LanguageTool is up at {} but does not serve \"{}\", so it will not report \
292 anything for this workspace.",
293 lt.url, config.engines.spell_language
294 ),
295 ));
296 }
297
298 if let Some(mother) = <.mother_tongue
299 && !serves(&served, mother)
300 {
301 probes.push(Probe::new(
302 "engines.languagetool.mother_tongue",
303 ENGINE,
304 ProbeStatus::Degraded,
305 format!(
306 "This server does not serve \"{mother}\", so false-friend detection will \
307 not run."
308 ),
309 ));
310 }
311
312 probes
313}
314
315fn serves(served: &BTreeSet<String>, tag: &str) -> bool {
321 if tag.is_empty() {
322 return true;
323 }
324 let tag = tag.to_lowercase();
325 if served.contains(&tag) {
326 return true;
327 }
328 let base = tag.split('-').next().unwrap_or(&tag);
329 served.contains(base)
330}
331
332async fn languagetool_languages(endpoint: &str) -> Result<Vec<LanguageEntry>, String> {
334 let client = reqwest::Client::builder()
335 .connect_timeout(PROBE_TIMEOUT)
336 .timeout(PROBE_TIMEOUT)
337 .build()
338 .map_err(|e| format!("Could not build an HTTP client to reach LanguageTool: {e}"))?;
339
340 let response = client.get(endpoint).send().await.map_err(|e| {
341 format!(
342 "Could not reach {endpoint}: {}. Start the server, or set \
343 engines.languagetool.enabled to false.",
344 terse_reqwest_error(&e)
345 )
346 })?;
347
348 if !response.status().is_success() {
349 return Err(format!(
350 "{endpoint} answered {}. Check that engines.languagetool.url points at a \
351 LanguageTool server.",
352 response.status()
353 ));
354 }
355
356 response.json::<Vec<LanguageEntry>>().await.map_err(|e| {
357 format!(
358 "{endpoint} answered, but not with a LanguageTool language list ({e}). Check \
359 that engines.languagetool.url points at a LanguageTool server."
360 )
361 })
362}
363
364#[derive(serde::Deserialize)]
365struct LanguageEntry {
366 code: String,
367 #[serde(rename = "longCode")]
368 long_code: String,
369}
370
371async fn probe_vale(config: &Config) -> Vec<Probe> {
376 const ENGINE: &str = "vale";
377 if !config.engines.vale.enabled {
378 return vec![Probe::new(
379 "engines.vale",
380 ENGINE,
381 ProbeStatus::Skipped,
382 "Vale is switched off.",
383 )];
384 }
385
386 let mut probes = match binary_version("vale", &["--version"]).await {
387 Ok(version) => vec![Probe::new(
388 "engines.vale",
389 ENGINE,
390 ProbeStatus::Ok,
391 format!("Found {version} on PATH."),
392 )],
393 Err(why) => {
394 return vec![Probe::new("engines.vale", ENGINE, ProbeStatus::Down, why)];
395 }
396 };
397
398 if let Some(path) = &config.engines.vale.config {
399 probes.push(readable_file(
400 "engines.vale.config",
401 ENGINE,
402 path,
403 "Vale config",
404 ));
405 }
406 probes
407}
408
409async fn probe_proselint(config: &Config) -> Vec<Probe> {
411 const ENGINE: &str = "proselint";
412 if !config.engines.proselint.enabled {
413 return vec![Probe::new(
414 "engines.proselint",
415 ENGINE,
416 ProbeStatus::Skipped,
417 "Proselint is switched off.",
418 )];
419 }
420
421 let mut probes = match binary_version("proselint", &["--version"]).await {
422 Ok(version) => vec![Probe::new(
423 "engines.proselint",
424 ENGINE,
425 ProbeStatus::Ok,
426 format!("Found {version} on PATH."),
427 )],
428 Err(why) => {
429 return vec![Probe::new(
430 "engines.proselint",
431 ENGINE,
432 ProbeStatus::Down,
433 why,
434 )];
435 }
436 };
437
438 if let Some(path) = &config.engines.proselint.config {
439 probes.push(readable_file(
440 "engines.proselint.config",
441 ENGINE,
442 path,
443 "Proselint config",
444 ));
445 }
446 probes
447}
448
449async fn probe_spell_language(config: &Config, workspace_root: &Path) -> Vec<Probe> {
455 let tag = config.engines.spell_language.clone();
456 if tag.is_empty() {
457 return vec![Probe::new(
458 "engines.spell_language",
459 "",
460 ProbeStatus::Down,
461 "engines.spell_language is empty. Set it to a BCP-47 tag such as en-US.",
462 )];
463 }
464
465 let base = tag.split('-').next().unwrap_or(&tag).to_lowercase();
466 let mut servers: Vec<String> = Vec::new();
467 if config.engines.harper.enabled && base == "en" {
468 servers.push("Harper".to_string());
469 }
470 if config.engines.languagetool.enabled {
471 servers.push("LanguageTool".to_string());
472 }
473
474 if config.engines.hunspell.enabled {
475 let search = config.engines.hunspell.search_paths.clone();
476 let overrides = config.engines.hunspell.dictionary_paths.clone();
477 let wanted = tag.clone();
478 let root = workspace_root.to_path_buf();
479 let resolved = tokio::task::spawn_blocking(move || {
480 let mut registry = crate::packs::PackRegistry::new();
481 for path in &search {
482 registry = registry.with_search_path(absolute_against(&root, path));
483 }
484 for (language, path) in &overrides {
485 registry = registry.with_override(language, absolute_against(&root, path));
486 }
487 registry.resolve(&wanted).is_ok()
488 })
489 .await
490 .unwrap_or(false);
491 if resolved {
492 servers.push("Hunspell".to_string());
493 }
494 }
495
496 if servers.is_empty() {
497 return vec![Probe::new(
498 "engines.spell_language",
499 "",
500 ProbeStatus::Down,
501 format!(
502 "Nothing enabled here checks \"{tag}\". Harper reads English only; enable \
503 LanguageTool or install a Hunspell dictionary for it."
504 ),
505 )];
506 }
507
508 vec![Probe::new(
509 "engines.spell_language",
510 "",
511 ProbeStatus::Ok,
512 format!("\"{tag}\" is checked by {}.", servers.join(" and ")),
513 )]
514}
515
516fn absolute_against(root: &Path, value: &str) -> PathBuf {
517 let path = Path::new(value);
518 if path.is_absolute() {
519 path.to_path_buf()
520 } else {
521 root.join(path)
522 }
523}
524
525fn readable_file(key: &str, engine: &str, path: &str, what: &str) -> Probe {
527 match std::fs::metadata(path) {
528 Ok(meta) if meta.is_dir() => Probe::new(
529 key,
530 engine,
531 ProbeStatus::Down,
532 format!("{path} is a directory, not a {what} file."),
533 ),
534 Ok(_) => match std::fs::File::open(path) {
535 Ok(_) => Probe::new(key, engine, ProbeStatus::Ok, format!("Read {path}.")),
536 Err(e) => Probe::new(
537 key,
538 engine,
539 ProbeStatus::Down,
540 format!("{path} exists but could not be opened: {e}"),
541 ),
542 },
543 Err(_) => Probe::new(
544 key,
545 engine,
546 ProbeStatus::Down,
547 format!("No {what} at {path}."),
548 ),
549 }
550}
551
552async fn binary_version(binary: &str, args: &[&str]) -> Result<String, String> {
558 let mut command = tokio::process::Command::new(binary);
559 command
560 .args(args)
561 .stdin(std::process::Stdio::null())
562 .stdout(std::process::Stdio::piped())
563 .stderr(std::process::Stdio::piped());
564
565 let run = tokio::time::timeout(PROBE_TIMEOUT, command.output()).await;
566 match run {
567 Ok(Ok(output)) => {
568 let text = if output.stdout.is_empty() {
571 String::from_utf8_lossy(&output.stderr).to_string()
572 } else {
573 String::from_utf8_lossy(&output.stdout).to_string()
574 };
575 let line = text.lines().next().unwrap_or("").trim().to_string();
576 if line.is_empty() {
577 Ok(binary.to_string())
578 } else {
579 Ok(line)
580 }
581 }
582 Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Err(format!(
583 "{binary} is not on PATH. Install it, or set engines.{binary}.enabled to false."
584 )),
585 Ok(Err(e)) => Err(format!("Could not run {binary}: {e}")),
586 Err(_) => Err(format!(
587 "{binary} did not answer within {}s.",
588 PROBE_TIMEOUT.as_secs()
589 )),
590 }
591}
592
593fn terse_reqwest_error(error: &reqwest::Error) -> String {
598 let mut source: &dyn std::error::Error = error;
599 let mut last = error.to_string();
600 while let Some(next) = source.source() {
601 last = next.to_string();
602 source = next;
603 }
604 last
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use std::collections::HashMap;
611 use std::io::Write;
612
613 fn config_from(yaml: &str) -> Config {
614 Config::parse_text(yaml, Path::new("/tmp"), "yaml").expect("fixture parses")
615 }
616
617 fn find<'a>(probes: &'a [Probe], key: &str) -> Option<&'a Probe> {
618 probes.iter().find(|p| p.key == key)
619 }
620
621 async fn fake_languagetool(codes: &[(&str, &str)]) -> (String, tokio::task::JoinHandle<()>) {
627 let body = format!(
628 "[{}]",
629 codes
630 .iter()
631 .map(|(code, long)| format!(
632 r#"{{"name":"Test","code":"{code}","longCode":"{long}"}}"#
633 ))
634 .collect::<Vec<_>>()
635 .join(",")
636 );
637 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
638 .await
639 .expect("bind an ephemeral port");
640 let url = format!("http://{}", listener.local_addr().expect("local addr"));
641 let handle = tokio::spawn(async move {
642 while let Ok((mut socket, _)) = listener.accept().await {
643 use tokio::io::{AsyncReadExt, AsyncWriteExt};
644 let mut buffer = vec![0u8; 2048];
645 let read = socket.read(&mut buffer).await.unwrap_or(0);
646 let request = String::from_utf8_lossy(&buffer[..read]).to_string();
647 let response = if request.contains("/v2/languages") {
648 format!(
649 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
650 Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
651 body.len()
652 )
653 } else {
654 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
655 .to_string()
656 };
657 let _ = socket.write_all(response.as_bytes()).await;
658 let _ = socket.shutdown().await;
659 }
660 });
661 (url, handle)
662 }
663
664 #[test]
665 fn the_worst_of_two_outcomes_is_the_one_that_should_worry_the_reader() {
666 assert_eq!(ProbeStatus::Ok.worst(ProbeStatus::Down), ProbeStatus::Down);
667 assert_eq!(ProbeStatus::Down.worst(ProbeStatus::Ok), ProbeStatus::Down);
668 assert_eq!(
669 ProbeStatus::Degraded.worst(ProbeStatus::Ok),
670 ProbeStatus::Degraded
671 );
672 assert_eq!(
673 ProbeStatus::Down.worst(ProbeStatus::Degraded),
674 ProbeStatus::Down
675 );
676 assert_eq!(ProbeStatus::Skipped.worst(ProbeStatus::Ok), ProbeStatus::Ok);
679 }
680
681 #[test]
682 fn a_server_offering_a_bare_tag_serves_the_regional_one() {
683 let served: BTreeSet<String> = ["en", "de-de"].iter().map(|s| (*s).to_string()).collect();
684 assert!(serves(&served, "en-US"));
685 assert!(serves(&served, "de-DE"));
686 assert!(!serves(&served, "fr"));
687 assert!(serves(&served, ""));
689 }
690
691 #[tokio::test]
692 async fn a_disabled_engine_is_skipped_and_not_called_broken() {
693 let config = config_from("engines:\n vale: false\n proselint: false\n");
694 let probes = probe_config(&config, Path::new("/tmp")).await;
695 assert_eq!(
696 find(&probes, "engines.vale").map(|p| p.status),
697 Some(ProbeStatus::Skipped)
698 );
699 assert_eq!(
700 find(&probes, "engines.proselint").map(|p| p.status),
701 Some(ProbeStatus::Skipped)
702 );
703 }
704
705 #[tokio::test]
706 async fn harper_is_always_available_because_it_is_built_in() {
707 let config = config_from("engines:\n harper: true\n");
708 let probes = probe_harper(&config).await;
709 assert_eq!(
710 find(&probes, "engines.harper").map(|p| p.status),
711 Some(ProbeStatus::Ok)
712 );
713 }
714
715 #[tokio::test]
716 async fn a_misspelled_harper_linter_is_reported_against_its_own_key() {
717 let mut config = config_from("engines:\n harper: true\n");
718 config
719 .engines
720 .harper
721 .linters
722 .insert("LongSentance".to_string(), false);
723 let probes = probe_harper(&config).await;
724 let probe = find(&probes, "engines.harper.linters.LongSentance")
725 .expect("the unknown linter is reported");
726 assert_eq!(probe.status, ProbeStatus::Down);
727 assert!(probe.detail.contains("LongSentance"));
728 }
729
730 #[tokio::test]
731 async fn a_real_harper_linter_is_not_reported() {
732 let mut config = config_from("engines:\n harper: true\n");
733 let mut linters = HashMap::new();
734 linters.insert("LongSentences".to_string(), false);
735 config.engines.harper.linters = linters;
736 let probes = probe_harper(&config).await;
737 assert!(
738 !probes
739 .iter()
740 .any(|p| p.key.starts_with("engines.harper.linters.")),
741 "a linter Harper does have should produce nothing: {probes:#?}"
742 );
743 }
744
745 #[tokio::test]
746 async fn a_languagetool_url_that_is_not_a_url_is_reported_without_a_request() {
747 let config = config_from(
748 "engines:\n languagetool:\n enabled: true\n url: \"localhost:8010\"\n",
749 );
750 let probes = probe_languagetool(&config).await;
751 assert_eq!(
752 find(&probes, "engines.languagetool.url").map(|p| p.status),
753 Some(ProbeStatus::Down)
754 );
755 }
756
757 #[tokio::test]
758 async fn a_languagetool_that_answers_is_reported_with_what_it_serves() {
759 let (url, server) = fake_languagetool(&[("en", "en-US")]).await;
760 let config = config_from(&format!(
761 "engines:\n languagetool:\n enabled: true\n url: \"{url}\"\n \
762 spell_language: \"en-US\"\n"
763 ));
764 let probes = probe_languagetool(&config).await;
765 server.abort();
766
767 let block = find(&probes, "engines.languagetool").expect("the block is reported");
768 assert_eq!(block.status, ProbeStatus::Ok);
769 assert!(
770 block.detail.contains("serving 1 languages"),
771 "{}",
772 block.detail
773 );
774 assert_eq!(
775 find(&probes, "engines.languagetool.url").map(|p| p.status),
776 Some(ProbeStatus::Ok)
777 );
778 }
779
780 #[tokio::test]
781 async fn a_languagetool_up_but_without_the_workspace_language_is_degraded() {
782 let (url, server) = fake_languagetool(&[("de", "de-DE")]).await;
783 let config = config_from(&format!(
784 "engines:\n languagetool:\n enabled: true\n url: \"{url}\"\n \
785 spell_language: \"en-US\"\n"
786 ));
787 let probes = probe_languagetool(&config).await;
788 server.abort();
789
790 assert!(
794 probes
795 .iter()
796 .any(|p| p.key == "engines.languagetool" && p.status == ProbeStatus::Degraded),
797 "{probes:#?}"
798 );
799 }
800
801 #[tokio::test]
802 async fn a_languagetool_that_refuses_the_connection_says_so_against_the_url() {
803 let port = {
805 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
806 listener.local_addr().unwrap().port()
807 };
808 let config = config_from(&format!(
809 "engines:\n languagetool:\n enabled: true\n \
810 url: \"http://127.0.0.1:{port}\"\n"
811 ));
812 let probes = probe_languagetool(&config).await;
813 let probe = find(&probes, "engines.languagetool.url").expect("the url is reported");
814 assert_eq!(probe.status, ProbeStatus::Down);
815 assert!(probe.detail.contains("v2/languages"), "{}", probe.detail);
816 }
817
818 #[test]
819 fn a_config_path_that_does_not_exist_names_the_path() {
820 let probe = readable_file(
821 "engines.vale.config",
822 "vale",
823 "/definitely/not/here/.vale.ini",
824 "Vale config",
825 );
826 assert_eq!(probe.status, ProbeStatus::Down);
827 assert!(probe.detail.contains("/definitely/not/here/.vale.ini"));
828 }
829
830 #[test]
831 fn a_path_with_spaces_is_reported_whole() {
832 let probe = readable_file(
835 "engines.vale.config",
836 "vale",
837 "C:/Program Files/vale/.vale.ini",
838 "Vale config",
839 );
840 assert_eq!(probe.status, ProbeStatus::Down);
841 assert!(
842 probe.detail.contains("C:/Program Files/vale/.vale.ini"),
843 "{}",
844 probe.detail
845 );
846 }
847
848 #[test]
849 fn a_path_with_non_ascii_is_reported_whole() {
850 let probe = readable_file(
851 "engines.vale.config",
852 "vale",
853 "/tmp/café—ü/.vale.ini",
854 "Vale config",
855 );
856 assert_eq!(probe.status, ProbeStatus::Down);
857 assert!(probe.detail.contains("café—ü"), "{}", probe.detail);
858 }
859
860 #[tokio::test]
861 async fn a_config_whose_paths_are_odd_still_answers() {
862 let config = config_from(
865 "engines:\n vale:\n enabled: true\n config: \"C:/Program Files/x y/.vale.ini\"\n",
866 );
867 let probes = probe_config(&config, Path::new("/tmp")).await;
868 assert!(
869 probes.iter().any(|p| p.key == "engines.vale"),
870 "{probes:#?}"
871 );
872 }
873
874 #[test]
875 fn a_config_path_that_is_a_directory_says_so() {
876 let dir = tempfile::tempdir().expect("tempdir");
877 let probe = readable_file(
878 "engines.vale.config",
879 "vale",
880 &dir.path().to_string_lossy(),
881 "Vale config",
882 );
883 assert_eq!(probe.status, ProbeStatus::Down);
884 assert!(probe.detail.contains("is a directory"));
885 }
886
887 #[test]
888 fn a_readable_config_path_is_ok() {
889 let dir = tempfile::tempdir().expect("tempdir");
890 let path = dir.path().join(".vale.ini");
891 let mut file = std::fs::File::create(&path).expect("create");
892 writeln!(file, "StylesPath = styles").expect("write");
893 let probe = readable_file(
894 "engines.vale.config",
895 "vale",
896 &path.to_string_lossy(),
897 "Vale config",
898 );
899 assert_eq!(probe.status, ProbeStatus::Ok);
900 }
901
902 #[tokio::test]
903 async fn harper_alone_cannot_serve_a_language_it_does_not_read() {
904 let config = config_from("engines:\n harper: true\n spell_language: \"de-DE\"\n");
905 let probes = probe_spell_language(&config, Path::new("/tmp")).await;
906 let probe = find(&probes, "engines.spell_language").expect("reported");
907 assert_eq!(probe.status, ProbeStatus::Down);
908 assert!(probe.detail.contains("de-DE"), "{}", probe.detail);
909 }
910
911 #[tokio::test]
912 async fn harper_serves_english_on_its_own() {
913 let config = config_from("engines:\n harper: true\n spell_language: \"en-GB\"\n");
914 let probes = probe_spell_language(&config, Path::new("/tmp")).await;
915 let probe = find(&probes, "engines.spell_language").expect("reported");
916 assert_eq!(probe.status, ProbeStatus::Ok);
917 assert!(probe.detail.contains("Harper"), "{}", probe.detail);
918 }
919
920 #[tokio::test]
921 async fn an_empty_spell_language_is_reported_as_such() {
922 let config = config_from("engines:\n spell_language: \"\"\n");
923 let probes = probe_spell_language(&config, Path::new("/tmp")).await;
924 assert_eq!(
925 find(&probes, "engines.spell_language").map(|p| p.status),
926 Some(ProbeStatus::Down)
927 );
928 }
929
930 #[tokio::test]
931 async fn a_missing_binary_is_reported_as_an_install_not_a_config_error() {
932 let error = binary_version("definitely-not-a-real-binary-xyz", &["--version"])
933 .await
934 .expect_err("a missing binary is an error");
935 assert!(error.contains("not on PATH"), "{error}");
936 }
937}