1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use serde::Deserialize;
13
14use crate::domain::threshold::{ThresholdOverride, ThresholdPreset, is_valid_threshold};
15use crate::domain::types::ComplexityMetric;
16use crate::domain::view::{CoverageRange, CoverageRangeError, GroupKey, SortKey};
17
18#[derive(Debug, Clone, Default)]
25pub struct FileConfig {
26 pub threshold: Option<f64>,
27 pub preset: Option<ThresholdPreset>,
28 pub metric: Option<ComplexityMetric>,
29 pub src: Option<PathBuf>,
30 pub exclude: Option<Vec<String>>,
31 pub overrides: Vec<ThresholdOverride>,
32 pub views: HashMap<String, ViewPreset>,
39 pub output: OutputConfig,
46}
47
48#[derive(Debug, Clone, Default, PartialEq)]
52pub struct OutputConfig {
53 pub annotation_limit: Option<u32>,
58}
59
60#[derive(Debug, Clone, Default, PartialEq)]
68pub struct ViewPreset {
69 pub top: Option<u32>,
70 pub min_coverage: Option<f64>,
71 pub max_coverage: Option<f64>,
72 pub sort: Option<SortKey>,
73 pub only_failing: Option<bool>,
74 pub no_fail: Option<bool>,
75 pub group_by: Option<GroupKey>,
76 pub minimal_view: Option<bool>,
77}
78
79#[derive(Debug, Deserialize)]
82#[serde(deny_unknown_fields)]
83struct RawConfig {
84 threshold: Option<f64>,
85 preset: Option<String>,
86 metric: Option<String>,
87 src: Option<String>,
88 exclude: Option<Vec<String>>,
89 #[serde(default)]
90 overrides: Vec<RawOverride>,
91 #[serde(default)]
92 views: HashMap<String, RawViewPreset>,
93 #[serde(default)]
94 output: RawOutputConfig,
95}
96
97#[derive(Debug, Default, Deserialize)]
98#[serde(deny_unknown_fields)]
99struct RawOutputConfig {
100 annotation_limit: Option<u32>,
101}
102
103#[derive(Debug, Deserialize)]
104#[serde(deny_unknown_fields)]
105struct RawOverride {
106 pattern: String,
107 threshold: f64,
108}
109
110#[derive(Debug, Default, Deserialize)]
111#[serde(deny_unknown_fields)]
112struct RawViewPreset {
113 top: Option<u32>,
114 min_coverage: Option<f64>,
115 max_coverage: Option<f64>,
116 sort: Option<String>,
117 only_failing: Option<bool>,
118 no_fail: Option<bool>,
119 group_by: Option<String>,
120 minimal_view: Option<bool>,
121}
122
123pub fn discover_config(name: &str) -> Result<Option<PathBuf>> {
134 let path = PathBuf::from(name);
135 match std::fs::metadata(&path) {
136 Ok(m) if m.is_file() => Ok(Some(path)),
137 Ok(_) => Ok(None), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
139 Err(e) => anyhow::bail!(
140 "cannot access config file {}: {e}\n hint: check file permissions",
141 path.display()
142 ),
143 }
144}
145
146pub fn load_config(path: &Path) -> Result<FileConfig> {
148 let content = std::fs::read_to_string(path)
149 .with_context(|| format!("failed to read config file: {}", path.display()))?;
150 parse_config(&content)
151 .with_context(|| format!("failed to parse config file: {}", path.display()))
152}
153
154fn parse_config(content: &str) -> Result<FileConfig> {
156 let raw: RawConfig = toml::from_str(content)?;
157 validate_raw_config(&raw)?;
158
159 let metric = raw.metric.as_deref().map(parse_metric).transpose()?;
160 let preset = raw.preset.as_deref().map(parse_preset).transpose()?;
161
162 let overrides = raw
163 .overrides
164 .into_iter()
165 .map(|o| ThresholdOverride {
166 pattern: o.pattern,
167 threshold: o.threshold,
168 })
169 .collect();
170
171 let views = raw
172 .views
173 .into_iter()
174 .map(|(name, raw_preset)| {
175 let preset = parse_view_preset(&name, raw_preset)?;
176 Ok::<_, anyhow::Error>((name, preset))
177 })
178 .collect::<Result<HashMap<_, _>>>()?;
179
180 Ok(FileConfig {
181 threshold: raw.threshold,
182 preset,
183 metric,
184 src: raw.src.map(PathBuf::from),
185 exclude: raw.exclude,
186 overrides,
187 views,
188 output: OutputConfig {
189 annotation_limit: raw.output.annotation_limit,
190 },
191 })
192}
193
194fn validate_raw_config(raw: &RawConfig) -> Result<()> {
195 if raw.preset.is_some() && raw.threshold.is_some() {
196 anyhow::bail!("preset and threshold are mutually exclusive in config");
197 }
198 if let Some(t) = raw.threshold
199 && !is_valid_threshold(t)
200 {
201 anyhow::bail!("threshold must be a finite positive number, got: {t}");
202 }
203 for o in &raw.overrides {
204 if !is_valid_threshold(o.threshold) {
205 anyhow::bail!(
206 "override threshold must be a finite positive number, got: {} (pattern: {})",
207 o.threshold,
208 o.pattern
209 );
210 }
211 }
212 if let Some(limit) = raw.output.annotation_limit
219 && !(1..=100).contains(&limit)
220 {
221 anyhow::bail!(
222 "output.annotation_limit must be in 1..=100, got: {limit}\n hint: matches the CLI `--annotation-limit` range; 0 disables emission, > 100 floods the GH Actions per-step cap"
223 );
224 }
225 Ok(())
226}
227
228fn parse_view_preset(name: &str, raw: RawViewPreset) -> Result<ViewPreset> {
229 let sort = raw
230 .sort
231 .as_deref()
232 .map(|s| parse_sort_key(name, s))
233 .transpose()?;
234 let group_by = raw
235 .group_by
236 .as_deref()
237 .map(|s| parse_group_key(name, s))
238 .transpose()?;
239 validate_preset_coverage_range(name, raw.min_coverage, raw.max_coverage)?;
240 Ok(ViewPreset {
241 top: raw.top,
242 min_coverage: raw.min_coverage,
243 max_coverage: raw.max_coverage,
244 sort,
245 only_failing: raw.only_failing,
246 no_fail: raw.no_fail,
247 group_by,
248 minimal_view: raw.minimal_view,
249 })
250}
251
252fn parse_sort_key(preset_name: &str, s: &str) -> Result<SortKey> {
253 match s {
254 "crap" => Ok(SortKey::Crap),
255 "coverage" => Ok(SortKey::Coverage),
256 "complexity" => Ok(SortKey::Complexity),
257 "path" => Ok(SortKey::Path),
258 other => anyhow::bail!(
259 "preset `{preset_name}`: unknown sort: {other}\n valid values: crap, coverage, complexity, path"
260 ),
261 }
262}
263
264fn parse_group_key(preset_name: &str, s: &str) -> Result<GroupKey> {
265 match s {
266 "file" => Ok(GroupKey::File),
267 other => {
268 anyhow::bail!("preset `{preset_name}`: unknown group_by: {other}\n valid values: file")
269 }
270 }
271}
272
273fn validate_preset_coverage_range(
279 preset_name: &str,
280 min: Option<f64>,
281 max: Option<f64>,
282) -> Result<()> {
283 if min.is_none() && max.is_none() {
284 return Ok(());
285 }
286 let lo = min.unwrap_or(0.0);
287 let hi = max.unwrap_or(100.0);
288 match CoverageRange::new(lo, hi) {
294 Ok(_) => Ok(()),
295 Err(CoverageRangeError::OutOfRange { value }) => anyhow::bail!(
296 "preset `{preset_name}`: coverage value out of range: {value}\n valid range: [0, 100]"
297 ),
298 Err(CoverageRangeError::MinExceedsMax { min, max }) => anyhow::bail!(
299 "preset `{preset_name}`: min_coverage ({min}) must not exceed max_coverage ({max})"
300 ),
301 }
302}
303
304fn parse_preset(s: &str) -> Result<ThresholdPreset> {
305 match s {
306 "strict" => Ok(ThresholdPreset::Strict),
307 "default" => Ok(ThresholdPreset::Default),
308 "lenient" => Ok(ThresholdPreset::Lenient),
309 other => anyhow::bail!("unknown preset: {other}\n valid values: strict, default, lenient"),
310 }
311}
312
313fn parse_metric(s: &str) -> Result<ComplexityMetric> {
314 match s {
315 "cognitive" => Ok(ComplexityMetric::Cognitive),
316 "cyclomatic" => Ok(ComplexityMetric::Cyclomatic),
317 other => anyhow::bail!("unknown metric: {other}\n valid values: cognitive, cyclomatic"),
318 }
319}
320
321#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn parse_full_config() {
329 let toml = r#"
330threshold = 10.0
331metric = "cyclomatic"
332src = "crates"
333exclude = ["tests/**", "benches/**"]
334
335[[overrides]]
336pattern = "domain/**"
337threshold = 5.0
338
339[[overrides]]
340pattern = "adapters/**"
341threshold = 15.0
342"#;
343 let config = parse_config(toml).unwrap();
344 assert_eq!(config.threshold, Some(10.0));
345 assert_eq!(config.metric, Some(ComplexityMetric::Cyclomatic));
346 assert_eq!(config.src, Some(PathBuf::from("crates")));
347 assert_eq!(
348 config.exclude,
349 Some(vec!["tests/**".to_string(), "benches/**".to_string()])
350 );
351 assert_eq!(config.overrides.len(), 2);
352 assert_eq!(config.overrides[0].pattern, "domain/**");
353 assert_eq!(config.overrides[0].threshold, 5.0);
354 assert_eq!(config.overrides[1].pattern, "adapters/**");
355 assert_eq!(config.overrides[1].threshold, 15.0);
356 }
357
358 #[test]
359 fn parse_minimal_config() {
360 let toml = "";
361 let config = parse_config(toml).unwrap();
362 assert_eq!(config.threshold, None);
363 assert_eq!(config.metric, None);
364 assert_eq!(config.src, None);
365 assert_eq!(config.exclude, None);
366 assert!(config.overrides.is_empty());
367 }
368
369 #[test]
370 fn parse_threshold_only() {
371 let toml = "threshold = 12.5\n";
372 let config = parse_config(toml).unwrap();
373 assert_eq!(config.threshold, Some(12.5));
374 assert_eq!(config.metric, None);
375 }
376
377 #[test]
378 fn parse_overrides_only() {
379 let toml = r#"
380[[overrides]]
381pattern = "core/**"
382threshold = 3.0
383"#;
384 let config = parse_config(toml).unwrap();
385 assert_eq!(config.threshold, None);
386 assert_eq!(config.overrides.len(), 1);
387 }
388
389 #[test]
390 fn parse_metric_cognitive() {
391 let toml = r#"metric = "cognitive""#;
392 let config = parse_config(toml).unwrap();
393 assert_eq!(config.metric, Some(ComplexityMetric::Cognitive));
394 }
395
396 #[test]
397 fn parse_metric_cyclomatic() {
398 let toml = r#"metric = "cyclomatic""#;
399 let config = parse_config(toml).unwrap();
400 assert_eq!(config.metric, Some(ComplexityMetric::Cyclomatic));
401 }
402
403 #[test]
404 fn invalid_metric_rejected() {
405 let toml = r#"metric = "halstead""#;
406 let err = parse_config(toml).unwrap_err();
407 assert!(err.to_string().contains("unknown metric"));
408 }
409
410 #[test]
411 fn negative_threshold_rejected() {
412 let toml = "threshold = -5.0\n";
413 let err = parse_config(toml).unwrap_err();
414 assert!(err.to_string().contains("finite positive"));
415 }
416
417 #[test]
418 fn zero_threshold_rejected() {
419 let toml = "threshold = 0.0\n";
420 let err = parse_config(toml).unwrap_err();
421 assert!(err.to_string().contains("finite positive"));
422 }
423
424 #[test]
425 fn inf_threshold_rejected() {
426 let toml = "threshold = inf\n";
427 let err = parse_config(toml).unwrap_err();
428 assert!(err.to_string().contains("finite positive"));
429 }
430
431 #[test]
432 fn negative_override_threshold_rejected() {
433 let toml = r#"
434[[overrides]]
435pattern = "src/**"
436threshold = -1.0
437"#;
438 let err = parse_config(toml).unwrap_err();
439 assert!(err.to_string().contains("finite positive"));
440 }
441
442 #[test]
443 fn unknown_field_rejected() {
444 let toml = "unknown_key = true\n";
445 let err = parse_config(toml).unwrap_err();
446 assert!(err.to_string().contains("unknown"));
447 }
448
449 #[test]
450 fn malformed_toml_rejected() {
451 let toml = "this is not toml [[[";
452 assert!(parse_config(toml).is_err());
453 }
454
455 #[test]
456 fn zero_override_threshold_rejected() {
457 let toml = r#"
458[[overrides]]
459pattern = "src/**"
460threshold = 0.0
461"#;
462 let err = parse_config(toml).unwrap_err();
463 assert!(err.to_string().contains("finite positive"));
464 }
465
466 #[test]
467 fn parse_preset_strict() {
468 let config = parse_config(r#"preset = "strict""#).unwrap();
469 assert_eq!(config.preset, Some(ThresholdPreset::Strict));
470 assert_eq!(config.threshold, None);
471 }
472
473 #[test]
474 fn parse_preset_default() {
475 let config = parse_config(r#"preset = "default""#).unwrap();
476 assert_eq!(config.preset, Some(ThresholdPreset::Default));
477 }
478
479 #[test]
480 fn parse_preset_lenient() {
481 let config = parse_config(r#"preset = "lenient""#).unwrap();
482 assert_eq!(config.preset, Some(ThresholdPreset::Lenient));
483 }
484
485 #[test]
486 fn preset_and_threshold_mutually_exclusive() {
487 let toml = "preset = \"strict\"\nthreshold = 10.0\n";
488 let err = parse_config(toml).unwrap_err();
489 assert!(err.to_string().contains("mutually exclusive"));
490 }
491
492 #[test]
493 fn unknown_preset_rejected() {
494 let err = parse_config(r#"preset = "extreme""#).unwrap_err();
495 assert!(err.to_string().contains("unknown preset"));
496 }
497
498 #[test]
499 fn load_config_missing_file() {
500 let err = load_config(Path::new("nonexistent.toml")).unwrap_err();
501 assert!(err.to_string().contains("failed to read config file"));
502 }
503
504 #[test]
505 fn load_config_valid_file() {
506 let dir = tempfile::tempdir().unwrap();
507 let path = dir.path().join("crap4rs.toml");
508 std::fs::write(&path, "threshold = 10.0\n").unwrap();
509
510 let config = load_config(&path).unwrap();
511 assert_eq!(config.threshold, Some(10.0));
512 }
513
514 #[test]
515 fn load_config_invalid_toml() {
516 let dir = tempfile::tempdir().unwrap();
517 let path = dir.path().join("crap4rs.toml");
518 std::fs::write(&path, "not valid toml [[[").unwrap();
519
520 let err = load_config(&path).unwrap_err();
521 assert!(err.to_string().contains("failed to parse config file"));
522 }
523
524 #[test]
527 fn parse_no_views_table_yields_empty_map() {
528 let config = parse_config("threshold = 10.0\n").unwrap();
531 assert_eq!(config.threshold, Some(10.0));
532 assert!(config.views.is_empty());
533 }
534
535 #[test]
536 fn parse_empty_view_block_yields_default_preset() {
537 let toml = "[views.ci]\n";
538 let config = parse_config(toml).unwrap();
539 assert_eq!(config.views.len(), 1);
540 let ci = config.views.get("ci").expect("preset `ci` parsed");
541 assert_eq!(*ci, ViewPreset::default());
542 }
543
544 #[test]
545 fn parse_full_view_block_parses_every_field() {
546 let toml = r#"
547[views.ci]
548top = 20
549min_coverage = 0
550max_coverage = 90
551sort = "coverage"
552only_failing = true
553no_fail = false
554group_by = "file"
555minimal_view = true
556"#;
557 let config = parse_config(toml).unwrap();
558 let ci = config.views.get("ci").expect("preset `ci` parsed");
559 assert_eq!(ci.top, Some(20));
560 assert_eq!(ci.min_coverage, Some(0.0));
561 assert_eq!(ci.max_coverage, Some(90.0));
562 assert_eq!(ci.sort, Some(SortKey::Coverage));
563 assert_eq!(ci.only_failing, Some(true));
564 assert_eq!(ci.no_fail, Some(false));
565 assert_eq!(ci.group_by, Some(GroupKey::File));
566 assert_eq!(ci.minimal_view, Some(true));
567 }
568
569 #[test]
570 fn parse_unknown_view_field_rejected() {
571 let toml = r#"
572[views.ci]
573top = 5
574diff_ref = "main"
575"#;
576 let err = parse_config(toml).unwrap_err();
577 let msg = err.to_string();
578 assert!(
579 msg.contains("unknown") || msg.contains("diff_ref"),
580 "expected deny_unknown_fields error, got: {msg}"
581 );
582 }
583
584 #[test]
585 fn parse_bad_sort_string_rejected() {
586 let toml = r#"
587[views.ci]
588sort = "nonsense"
589"#;
590 let err = parse_config(toml).unwrap_err();
591 let msg = err.to_string();
592 assert!(msg.contains("unknown sort"), "got: {msg}");
593 assert!(msg.contains("ci"), "error must name preset, got: {msg}");
594 }
595
596 #[test]
597 fn parse_bad_group_by_string_rejected() {
598 let toml = r#"
599[views.ci]
600group_by = "module"
601"#;
602 let err = parse_config(toml).unwrap_err();
603 let msg = err.to_string();
604 assert!(msg.contains("unknown group_by"), "got: {msg}");
605 assert!(msg.contains("ci"), "error must name preset, got: {msg}");
606 }
607
608 #[test]
609 fn parse_multiple_view_presets_independent() {
610 let toml = r#"
611[views.ci]
612top = 20
613sort = "coverage"
614
615[views.investigate]
616top = 10
617sort = "complexity"
618"#;
619 let config = parse_config(toml).unwrap();
620 assert_eq!(config.views.len(), 2);
621 let ci = config.views.get("ci").unwrap();
622 assert_eq!(ci.top, Some(20));
623 assert_eq!(ci.sort, Some(SortKey::Coverage));
624 let inv = config.views.get("investigate").unwrap();
625 assert_eq!(inv.top, Some(10));
626 assert_eq!(inv.sort, Some(SortKey::Complexity));
627 }
628
629 #[test]
630 fn parse_view_preset_top_zero_accepted() {
631 let toml = r#"
635[views.ci]
636top = 0
637"#;
638 let config = parse_config(toml).unwrap();
639 let ci = config.views.get("ci").unwrap();
640 assert_eq!(ci.top, Some(0));
641 }
642
643 #[test]
644 fn parse_view_preset_min_coverage_out_of_range_rejected() {
645 let toml = r#"
646[views.ci]
647min_coverage = -1
648"#;
649 let err = parse_config(toml).unwrap_err();
650 let msg = err.to_string();
651 assert!(msg.contains("out of range"), "got: {msg}");
652 assert!(msg.contains("ci"), "error must name preset, got: {msg}");
653 }
654
655 #[test]
656 fn parse_view_preset_max_coverage_out_of_range_rejected() {
657 let toml = r#"
658[views.ci]
659max_coverage = 105
660"#;
661 let err = parse_config(toml).unwrap_err();
662 let msg = err.to_string();
663 assert!(msg.contains("out of range"), "got: {msg}");
664 }
665
666 #[test]
667 fn parse_view_preset_min_exceeds_max_rejected() {
668 let toml = r#"
669[views.ci]
670min_coverage = 90
671max_coverage = 30
672"#;
673 let err = parse_config(toml).unwrap_err();
674 let msg = err.to_string();
675 assert!(
676 msg.contains("must not exceed") || msg.contains("exceeds"),
677 "got: {msg}"
678 );
679 assert!(msg.contains("ci"), "error must name preset, got: {msg}");
680 }
681
682 #[test]
683 fn parse_view_preset_min_only_resolves_to_full_upper_bound() {
684 let toml = r#"
688[views.ci]
689min_coverage = 50
690"#;
691 let config = parse_config(toml).unwrap();
692 let ci = config.views.get("ci").unwrap();
693 assert_eq!(ci.min_coverage, Some(50.0));
694 assert_eq!(ci.max_coverage, None);
695 }
696
697 #[test]
698 fn parse_view_preset_alongside_threshold() {
699 let toml = r#"
701threshold = 12.0
702
703[views.ci]
704top = 20
705"#;
706 let config = parse_config(toml).unwrap();
707 assert_eq!(config.threshold, Some(12.0));
708 assert_eq!(config.views.len(), 1);
709 assert_eq!(config.views["ci"].top, Some(20));
710 }
711
712 #[test]
713 fn parse_view_preset_all_sort_variants() {
714 let toml = r#"
715[views.crap_sort]
716sort = "crap"
717
718[views.coverage_sort]
719sort = "coverage"
720
721[views.complexity_sort]
722sort = "complexity"
723
724[views.path_sort]
725sort = "path"
726"#;
727 let config = parse_config(toml).unwrap();
728 assert_eq!(config.views["crap_sort"].sort, Some(SortKey::Crap));
729 assert_eq!(config.views["coverage_sort"].sort, Some(SortKey::Coverage));
730 assert_eq!(
731 config.views["complexity_sort"].sort,
732 Some(SortKey::Complexity)
733 );
734 assert_eq!(config.views["path_sort"].sort, Some(SortKey::Path));
735 }
736
737 #[test]
740 fn parse_no_output_table_yields_default_output_config() {
741 let config = parse_config("threshold = 10.0\n").unwrap();
744 assert_eq!(config.output, OutputConfig::default());
745 assert_eq!(config.output.annotation_limit, None);
746 }
747
748 #[test]
749 fn parse_output_annotation_limit() {
750 let toml = "[output]\nannotation_limit = 25\n";
751 let config = parse_config(toml).unwrap();
752 assert_eq!(config.output.annotation_limit, Some(25));
753 }
754
755 #[test]
756 fn parse_output_alongside_threshold() {
757 let toml = r#"
758threshold = 12.0
759
760[output]
761annotation_limit = 7
762"#;
763 let config = parse_config(toml).unwrap();
764 assert_eq!(config.threshold, Some(12.0));
765 assert_eq!(config.output.annotation_limit, Some(7));
766 }
767
768 #[test]
769 fn parse_output_annotation_limit_zero_rejected() {
770 let toml = "[output]\nannotation_limit = 0\n";
775 let err = parse_config(toml).unwrap_err();
776 let msg = err.to_string();
777 assert!(
778 msg.contains("annotation_limit") && msg.contains("1..=100"),
779 "expected range error, got: {msg}"
780 );
781 }
782
783 #[test]
784 fn parse_output_annotation_limit_above_max_rejected() {
785 let toml = "[output]\nannotation_limit = 101\n";
789 let err = parse_config(toml).unwrap_err();
790 let msg = err.to_string();
791 assert!(
792 msg.contains("annotation_limit") && msg.contains("1..=100"),
793 "expected range error, got: {msg}"
794 );
795 }
796
797 #[test]
798 fn parse_output_annotation_limit_boundary_values_accepted() {
799 for v in [1u32, 10, 50, 100] {
800 let toml = format!("[output]\nannotation_limit = {v}\n");
801 let config = parse_config(&toml).expect("boundary value should parse");
802 assert_eq!(config.output.annotation_limit, Some(v));
803 }
804 }
805
806 #[test]
807 fn parse_unknown_output_field_rejected() {
808 let toml = r#"
813[output]
814annotation_limit = 5
815nonsense_field = "x"
816"#;
817 let err = parse_config(toml).unwrap_err();
818 let msg = err.to_string();
819 assert!(
820 msg.contains("unknown") || msg.contains("nonsense_field"),
821 "expected deny_unknown_fields error, got: {msg}"
822 );
823 }
824
825 #[test]
828 fn discover_config_honors_caller_supplied_name() {
829 let dir = tempfile::tempdir().unwrap();
840 std::fs::write(dir.path().join("crap4ts.toml"), "threshold = 7.0\n").unwrap();
841
842 let rust_path = dir.path().join("crap4rs.toml");
843 let ts_path = dir.path().join("crap4ts.toml");
844 let alt_path = dir.path().join("custom-tool.toml");
845
846 let rust_lookup = discover_config(rust_path.to_str().unwrap()).unwrap();
847 let ts_lookup = discover_config(ts_path.to_str().unwrap()).unwrap();
848 let alt_lookup = discover_config(alt_path.to_str().unwrap()).unwrap();
849
850 assert_eq!(rust_lookup, None, "absent crap4rs.toml must return None");
851 assert_eq!(
852 ts_lookup,
853 Some(ts_path.clone()),
854 "present crap4ts.toml must be discovered by name"
855 );
856 assert_eq!(alt_lookup, None, "absent custom name must return None");
857 }
858}