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> {
373 let vale = &config.engines.vale;
374 probe_cli_engine("vale", "Vale", vale.enabled, vale.config.as_deref()).await
375}
376
377async fn probe_proselint(config: &Config) -> Vec<Probe> {
379 let proselint = &config.engines.proselint;
380 probe_cli_engine(
381 "proselint",
382 "Proselint",
383 proselint.enabled,
384 proselint.config.as_deref(),
385 )
386 .await
387}
388
389async fn probe_cli_engine(
395 engine: &str,
396 display: &str,
397 enabled: bool,
398 config_path: Option<&str>,
399) -> Vec<Probe> {
400 let key = format!("engines.{engine}");
401 if !enabled {
402 return vec![Probe::new(
403 key,
404 engine,
405 ProbeStatus::Skipped,
406 format!("{display} is switched off."),
407 )];
408 }
409
410 let mut probes = match binary_version(engine, &["--version"]).await {
411 Ok(version) => vec![Probe::new(
412 &key,
413 engine,
414 ProbeStatus::Ok,
415 format!("Found {version} on PATH."),
416 )],
417 Err(why) => return vec![Probe::new(key, engine, ProbeStatus::Down, why)],
418 };
419
420 if let Some(path) = config_path {
421 probes.push(readable_file(
422 &format!("{key}.config"),
423 engine,
424 path,
425 &format!("{display} config"),
426 ));
427 }
428 probes
429}
430
431async fn probe_spell_language(config: &Config, workspace_root: &Path) -> Vec<Probe> {
437 let tag = config.engines.spell_language.clone();
438 if tag.is_empty() {
439 return vec![Probe::new(
440 "engines.spell_language",
441 "",
442 ProbeStatus::Down,
443 "engines.spell_language is empty. Set it to a BCP-47 tag such as en-US.",
444 )];
445 }
446
447 let base = tag.split('-').next().unwrap_or(&tag).to_lowercase();
448 let mut servers: Vec<String> = Vec::new();
449 if config.engines.harper.enabled && base == "en" {
450 servers.push("Harper".to_string());
451 }
452 if config.engines.languagetool.enabled {
453 servers.push("LanguageTool".to_string());
454 }
455
456 if config.engines.hunspell.enabled {
457 let search = config.engines.hunspell.search_paths.clone();
458 let overrides = config.engines.hunspell.dictionary_paths.clone();
459 let wanted = tag.clone();
460 let root = workspace_root.to_path_buf();
461 let resolved = tokio::task::spawn_blocking(move || {
462 let mut registry = crate::packs::PackRegistry::new();
463 for path in &search {
464 registry = registry.with_search_path(absolute_against(&root, path));
465 }
466 for (language, path) in &overrides {
467 registry = registry.with_override(language, absolute_against(&root, path));
468 }
469 registry.resolve(&wanted).is_ok()
470 })
471 .await
472 .unwrap_or(false);
473 if resolved {
474 servers.push("Hunspell".to_string());
475 }
476 }
477
478 if servers.is_empty() {
479 return vec![Probe::new(
480 "engines.spell_language",
481 "",
482 ProbeStatus::Down,
483 format!(
484 "Nothing enabled here checks \"{tag}\". Harper reads English only; enable \
485 LanguageTool or install a Hunspell dictionary for it."
486 ),
487 )];
488 }
489
490 vec![Probe::new(
491 "engines.spell_language",
492 "",
493 ProbeStatus::Ok,
494 format!("\"{tag}\" is checked by {}.", servers.join(" and ")),
495 )]
496}
497
498fn absolute_against(root: &Path, value: &str) -> PathBuf {
499 let path = Path::new(value);
500 if path.is_absolute() {
501 path.to_path_buf()
502 } else {
503 root.join(path)
504 }
505}
506
507fn readable_file(key: &str, engine: &str, path: &str, what: &str) -> Probe {
509 match std::fs::metadata(path) {
510 Ok(meta) if meta.is_dir() => Probe::new(
511 key,
512 engine,
513 ProbeStatus::Down,
514 format!("{path} is a directory, not a {what} file."),
515 ),
516 Ok(_) => match std::fs::File::open(path) {
517 Ok(_) => Probe::new(key, engine, ProbeStatus::Ok, format!("Read {path}.")),
518 Err(e) => Probe::new(
519 key,
520 engine,
521 ProbeStatus::Down,
522 format!("{path} exists but could not be opened: {e}"),
523 ),
524 },
525 Err(_) => Probe::new(
526 key,
527 engine,
528 ProbeStatus::Down,
529 format!("No {what} at {path}."),
530 ),
531 }
532}
533
534async fn binary_version(binary: &str, args: &[&str]) -> Result<String, String> {
540 let mut command = tokio::process::Command::new(binary);
541 command
542 .args(args)
543 .stdin(std::process::Stdio::null())
544 .stdout(std::process::Stdio::piped())
545 .stderr(std::process::Stdio::piped());
546
547 let run = tokio::time::timeout(PROBE_TIMEOUT, command.output()).await;
548 match run {
549 Ok(Ok(output)) => {
550 let text = if output.stdout.is_empty() {
553 String::from_utf8_lossy(&output.stderr).to_string()
554 } else {
555 String::from_utf8_lossy(&output.stdout).to_string()
556 };
557 let line = text.lines().next().unwrap_or("").trim().to_string();
558 if line.is_empty() {
559 Ok(binary.to_string())
560 } else {
561 Ok(line)
562 }
563 }
564 Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Err(format!(
565 "{binary} is not on PATH. Install it, or set engines.{binary}.enabled to false."
566 )),
567 Ok(Err(e)) => Err(format!("Could not run {binary}: {e}")),
568 Err(_) => Err(format!(
569 "{binary} did not answer within {}s.",
570 PROBE_TIMEOUT.as_secs()
571 )),
572 }
573}
574
575fn terse_reqwest_error(error: &reqwest::Error) -> String {
580 let mut source: &dyn std::error::Error = error;
581 let mut last = error.to_string();
582 while let Some(next) = source.source() {
583 last = next.to_string();
584 source = next;
585 }
586 last
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use std::collections::HashMap;
593 use std::io::Write;
594
595 fn config_from(yaml: &str) -> Config {
596 Config::parse_text(yaml, Path::new("/tmp"), "yaml").expect("fixture parses")
597 }
598
599 fn find<'a>(probes: &'a [Probe], key: &str) -> Option<&'a Probe> {
600 probes.iter().find(|p| p.key == key)
601 }
602
603 async fn fake_languagetool(codes: &[(&str, &str)]) -> (String, tokio::task::JoinHandle<()>) {
609 let body = format!(
610 "[{}]",
611 codes
612 .iter()
613 .map(|(code, long)| format!(
614 r#"{{"name":"Test","code":"{code}","longCode":"{long}"}}"#
615 ))
616 .collect::<Vec<_>>()
617 .join(",")
618 );
619 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
620 .await
621 .expect("bind an ephemeral port");
622 let url = format!("http://{}", listener.local_addr().expect("local addr"));
623 let handle = tokio::spawn(async move {
624 while let Ok((mut socket, _)) = listener.accept().await {
625 use tokio::io::{AsyncReadExt, AsyncWriteExt};
626 let mut buffer = vec![0u8; 2048];
627 let read = socket.read(&mut buffer).await.unwrap_or(0);
628 let request = String::from_utf8_lossy(&buffer[..read]).to_string();
629 let response = if request.contains("/v2/languages") {
630 format!(
631 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
632 Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
633 body.len()
634 )
635 } else {
636 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
637 .to_string()
638 };
639 let _ = socket.write_all(response.as_bytes()).await;
640 let _ = socket.shutdown().await;
641 }
642 });
643 (url, handle)
644 }
645
646 #[test]
647 fn the_worst_of_two_outcomes_is_the_one_that_should_worry_the_reader() {
648 assert_eq!(ProbeStatus::Ok.worst(ProbeStatus::Down), ProbeStatus::Down);
649 assert_eq!(ProbeStatus::Down.worst(ProbeStatus::Ok), ProbeStatus::Down);
650 assert_eq!(
651 ProbeStatus::Degraded.worst(ProbeStatus::Ok),
652 ProbeStatus::Degraded
653 );
654 assert_eq!(
655 ProbeStatus::Down.worst(ProbeStatus::Degraded),
656 ProbeStatus::Down
657 );
658 assert_eq!(ProbeStatus::Skipped.worst(ProbeStatus::Ok), ProbeStatus::Ok);
661 }
662
663 #[test]
664 fn a_server_offering_a_bare_tag_serves_the_regional_one() {
665 let served: BTreeSet<String> = ["en", "de-de"].iter().map(|s| (*s).to_string()).collect();
666 assert!(serves(&served, "en-US"));
667 assert!(serves(&served, "de-DE"));
668 assert!(!serves(&served, "fr"));
669 assert!(serves(&served, ""));
671 }
672
673 #[tokio::test]
674 async fn a_disabled_engine_is_skipped_and_not_called_broken() {
675 let config = config_from("engines:\n vale: false\n proselint: false\n");
676 let probes = probe_config(&config, Path::new("/tmp")).await;
677 assert_eq!(
678 find(&probes, "engines.vale").map(|p| p.status),
679 Some(ProbeStatus::Skipped)
680 );
681 assert_eq!(
682 find(&probes, "engines.proselint").map(|p| p.status),
683 Some(ProbeStatus::Skipped)
684 );
685 }
686
687 #[tokio::test]
688 async fn harper_is_always_available_because_it_is_built_in() {
689 let config = config_from("engines:\n harper: true\n");
690 let probes = probe_harper(&config).await;
691 assert_eq!(
692 find(&probes, "engines.harper").map(|p| p.status),
693 Some(ProbeStatus::Ok)
694 );
695 }
696
697 #[tokio::test]
698 async fn a_misspelled_harper_linter_is_reported_against_its_own_key() {
699 let mut config = config_from("engines:\n harper: true\n");
700 config
701 .engines
702 .harper
703 .linters
704 .insert("LongSentance".to_string(), false);
705 let probes = probe_harper(&config).await;
706 let probe = find(&probes, "engines.harper.linters.LongSentance")
707 .expect("the unknown linter is reported");
708 assert_eq!(probe.status, ProbeStatus::Down);
709 assert!(probe.detail.contains("LongSentance"));
710 }
711
712 #[tokio::test]
713 async fn a_real_harper_linter_is_not_reported() {
714 let mut config = config_from("engines:\n harper: true\n");
715 let mut linters = HashMap::new();
716 linters.insert("LongSentences".to_string(), false);
717 config.engines.harper.linters = linters;
718 let probes = probe_harper(&config).await;
719 assert!(
720 !probes
721 .iter()
722 .any(|p| p.key.starts_with("engines.harper.linters.")),
723 "a linter Harper does have should produce nothing: {probes:#?}"
724 );
725 }
726
727 #[tokio::test]
728 async fn a_languagetool_url_that_is_not_a_url_is_reported_without_a_request() {
729 let config = config_from(
730 "engines:\n languagetool:\n enabled: true\n url: \"localhost:8010\"\n",
731 );
732 let probes = probe_languagetool(&config).await;
733 assert_eq!(
734 find(&probes, "engines.languagetool.url").map(|p| p.status),
735 Some(ProbeStatus::Down)
736 );
737 }
738
739 #[tokio::test]
740 async fn a_languagetool_that_answers_is_reported_with_what_it_serves() {
741 let (url, server) = fake_languagetool(&[("en", "en-US")]).await;
742 let config = config_from(&format!(
743 "engines:\n languagetool:\n enabled: true\n url: \"{url}\"\n \
744 spell_language: \"en-US\"\n"
745 ));
746 let probes = probe_languagetool(&config).await;
747 server.abort();
748
749 let block = find(&probes, "engines.languagetool").expect("the block is reported");
750 assert_eq!(block.status, ProbeStatus::Ok);
751 assert!(
752 block.detail.contains("serving 1 languages"),
753 "{}",
754 block.detail
755 );
756 assert_eq!(
757 find(&probes, "engines.languagetool.url").map(|p| p.status),
758 Some(ProbeStatus::Ok)
759 );
760 }
761
762 #[tokio::test]
763 async fn a_languagetool_up_but_without_the_workspace_language_is_degraded() {
764 let (url, server) = fake_languagetool(&[("de", "de-DE")]).await;
765 let config = config_from(&format!(
766 "engines:\n languagetool:\n enabled: true\n url: \"{url}\"\n \
767 spell_language: \"en-US\"\n"
768 ));
769 let probes = probe_languagetool(&config).await;
770 server.abort();
771
772 assert!(
776 probes
777 .iter()
778 .any(|p| p.key == "engines.languagetool" && p.status == ProbeStatus::Degraded),
779 "{probes:#?}"
780 );
781 }
782
783 #[tokio::test]
784 async fn a_languagetool_that_refuses_the_connection_says_so_against_the_url() {
785 let port = {
787 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
788 listener.local_addr().unwrap().port()
789 };
790 let config = config_from(&format!(
791 "engines:\n languagetool:\n enabled: true\n \
792 url: \"http://127.0.0.1:{port}\"\n"
793 ));
794 let probes = probe_languagetool(&config).await;
795 let probe = find(&probes, "engines.languagetool.url").expect("the url is reported");
796 assert_eq!(probe.status, ProbeStatus::Down);
797 assert!(probe.detail.contains("v2/languages"), "{}", probe.detail);
798 }
799
800 #[test]
801 fn a_config_path_that_does_not_exist_names_the_path() {
802 let probe = readable_file(
803 "engines.vale.config",
804 "vale",
805 "/definitely/not/here/.vale.ini",
806 "Vale config",
807 );
808 assert_eq!(probe.status, ProbeStatus::Down);
809 assert!(probe.detail.contains("/definitely/not/here/.vale.ini"));
810 }
811
812 #[test]
813 fn a_path_with_spaces_is_reported_whole() {
814 let probe = readable_file(
817 "engines.vale.config",
818 "vale",
819 "C:/Program Files/vale/.vale.ini",
820 "Vale config",
821 );
822 assert_eq!(probe.status, ProbeStatus::Down);
823 assert!(
824 probe.detail.contains("C:/Program Files/vale/.vale.ini"),
825 "{}",
826 probe.detail
827 );
828 }
829
830 #[test]
831 fn a_path_with_non_ascii_is_reported_whole() {
832 let probe = readable_file(
833 "engines.vale.config",
834 "vale",
835 "/tmp/café—ü/.vale.ini",
836 "Vale config",
837 );
838 assert_eq!(probe.status, ProbeStatus::Down);
839 assert!(probe.detail.contains("café—ü"), "{}", probe.detail);
840 }
841
842 #[tokio::test]
843 async fn a_config_whose_paths_are_odd_still_answers() {
844 let config = config_from(
847 "engines:\n vale:\n enabled: true\n config: \"C:/Program Files/x y/.vale.ini\"\n",
848 );
849 let probes = probe_config(&config, Path::new("/tmp")).await;
850 assert!(
851 probes.iter().any(|p| p.key == "engines.vale"),
852 "{probes:#?}"
853 );
854 }
855
856 #[test]
857 fn a_config_path_that_is_a_directory_says_so() {
858 let dir = tempfile::tempdir().expect("tempdir");
859 let probe = readable_file(
860 "engines.vale.config",
861 "vale",
862 &dir.path().to_string_lossy(),
863 "Vale config",
864 );
865 assert_eq!(probe.status, ProbeStatus::Down);
866 assert!(probe.detail.contains("is a directory"));
867 }
868
869 #[test]
870 fn a_readable_config_path_is_ok() {
871 let dir = tempfile::tempdir().expect("tempdir");
872 let path = dir.path().join(".vale.ini");
873 let mut file = std::fs::File::create(&path).expect("create");
874 writeln!(file, "StylesPath = styles").expect("write");
875 let probe = readable_file(
876 "engines.vale.config",
877 "vale",
878 &path.to_string_lossy(),
879 "Vale config",
880 );
881 assert_eq!(probe.status, ProbeStatus::Ok);
882 }
883
884 #[tokio::test]
885 async fn harper_alone_cannot_serve_a_language_it_does_not_read() {
886 let config = config_from("engines:\n harper: true\n spell_language: \"de-DE\"\n");
887 let probes = probe_spell_language(&config, Path::new("/tmp")).await;
888 let probe = find(&probes, "engines.spell_language").expect("reported");
889 assert_eq!(probe.status, ProbeStatus::Down);
890 assert!(probe.detail.contains("de-DE"), "{}", probe.detail);
891 }
892
893 #[tokio::test]
894 async fn harper_serves_english_on_its_own() {
895 let config = config_from("engines:\n harper: true\n spell_language: \"en-GB\"\n");
896 let probes = probe_spell_language(&config, Path::new("/tmp")).await;
897 let probe = find(&probes, "engines.spell_language").expect("reported");
898 assert_eq!(probe.status, ProbeStatus::Ok);
899 assert!(probe.detail.contains("Harper"), "{}", probe.detail);
900 }
901
902 #[tokio::test]
903 async fn an_empty_spell_language_is_reported_as_such() {
904 let config = config_from("engines:\n spell_language: \"\"\n");
905 let probes = probe_spell_language(&config, Path::new("/tmp")).await;
906 assert_eq!(
907 find(&probes, "engines.spell_language").map(|p| p.status),
908 Some(ProbeStatus::Down)
909 );
910 }
911
912 #[tokio::test]
913 async fn a_missing_binary_is_reported_as_an_install_not_a_config_error() {
914 let error = binary_version("definitely-not-a-real-binary-xyz", &["--version"])
915 .await
916 .expect_err("a missing binary is an error");
917 assert!(error.contains("not on PATH"), "{error}");
918 }
919}