1use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Deserializer, Serialize, Serializer};
24
25use crate::terminal::cell::NamedColor;
26
27pub const DEFAULT_SCROLLBACK: usize = 10_000;
32
33pub const CONFIG_FILE: &str = "tui-test.toml";
35
36pub const DEFAULT_PROFILE: &str = "default";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct Rgb {
41 pub r: u8,
42 pub g: u8,
43 pub b: u8,
44}
45
46impl Rgb {
47 pub const fn new(r: u8, g: u8, b: u8) -> Self {
48 Rgb { r, g, b }
49 }
50
51 pub fn to_hex(self) -> String {
52 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
53 }
54
55 pub fn parse(s: &str) -> Result<Self, String> {
58 let trimmed = s.trim();
59 let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
60 let digit = |byte: u8| -> Option<u8> {
61 match byte {
62 b'0'..=b'9' => Some(byte - b'0'),
63 b'a'..=b'f' => Some(byte - b'a' + 10),
64 b'A'..=b'F' => Some(byte - b'A' + 10),
65 _ => None,
66 }
67 };
68 let digits = hex
69 .bytes()
70 .map(digit)
71 .collect::<Option<Vec<_>>>()
72 .ok_or_else(|| format!("invalid hex color {s:?}"))?;
73 match digits.as_slice() {
74 [r, g, b] => Ok(Rgb::new(r * 17, g * 17, b * 17)),
75 [r1, r2, g1, g2, b1, b2] => Ok(Rgb::new(r1 * 16 + r2, g1 * 16 + g2, b1 * 16 + b2)),
76 _ => Err(format!("color must be #rgb or #rrggbb (got {s:?})")),
77 }
78 }
79}
80
81impl Serialize for Rgb {
82 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
83 s.serialize_str(&self.to_hex())
84 }
85}
86
87impl<'de> Deserialize<'de> for Rgb {
88 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
89 let raw = String::deserialize(d)?;
90 Rgb::parse(&raw).map_err(serde::de::Error::custom)
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(default, deny_unknown_fields)]
103pub struct Colors {
104 pub foreground: Rgb,
106 pub background: Rgb,
108 pub cursor: Rgb,
110
111 pub black: Rgb,
112 pub red: Rgb,
113 pub green: Rgb,
114 pub yellow: Rgb,
115 pub blue: Rgb,
116 pub magenta: Rgb,
117 pub cyan: Rgb,
118 pub white: Rgb,
119 pub bright_black: Rgb,
120 pub bright_red: Rgb,
121 pub bright_green: Rgb,
122 pub bright_yellow: Rgb,
123 pub bright_blue: Rgb,
124 pub bright_magenta: Rgb,
125 pub bright_cyan: Rgb,
126 pub bright_white: Rgb,
127}
128
129impl Default for Colors {
130 fn default() -> Self {
133 Colors {
134 foreground: Rgb::new(192, 192, 192),
135 background: Rgb::new(0, 0, 0),
136 cursor: Rgb::new(192, 192, 192),
137
138 black: Rgb::new(0, 0, 0),
139 red: Rgb::new(128, 0, 0),
140 green: Rgb::new(0, 128, 0),
141 yellow: Rgb::new(128, 128, 0),
142 blue: Rgb::new(0, 0, 128),
143 magenta: Rgb::new(128, 0, 128),
144 cyan: Rgb::new(0, 128, 128),
145 white: Rgb::new(192, 192, 192),
146 bright_black: Rgb::new(128, 128, 128),
147 bright_red: Rgb::new(255, 0, 0),
148 bright_green: Rgb::new(0, 255, 0),
149 bright_yellow: Rgb::new(255, 255, 0),
150 bright_blue: Rgb::new(0, 0, 255),
151 bright_magenta: Rgb::new(255, 0, 255),
152 bright_cyan: Rgb::new(0, 255, 255),
153 bright_white: Rgb::new(255, 255, 255),
154 }
155 }
156}
157
158impl Colors {
159 pub fn ansi(&self) -> [Rgb; 16] {
161 [
162 self.black,
163 self.red,
164 self.green,
165 self.yellow,
166 self.blue,
167 self.magenta,
168 self.cyan,
169 self.white,
170 self.bright_black,
171 self.bright_red,
172 self.bright_green,
173 self.bright_yellow,
174 self.bright_blue,
175 self.bright_magenta,
176 self.bright_cyan,
177 self.bright_white,
178 ]
179 }
180
181 pub fn slot_name(index: u8) -> Option<&'static str> {
183 Some(match NamedColor::from_index(index)? {
184 NamedColor::Black => "black",
185 NamedColor::Red => "red",
186 NamedColor::Green => "green",
187 NamedColor::Yellow => "yellow",
188 NamedColor::Blue => "blue",
189 NamedColor::Magenta => "magenta",
190 NamedColor::Cyan => "cyan",
191 NamedColor::White => "white",
192 NamedColor::BrightBlack => "bright_black",
193 NamedColor::BrightRed => "bright_red",
194 NamedColor::BrightGreen => "bright_green",
195 NamedColor::BrightYellow => "bright_yellow",
196 NamedColor::BrightBlue => "bright_blue",
197 NamedColor::BrightMagenta => "bright_magenta",
198 NamedColor::BrightCyan => "bright_cyan",
199 NamedColor::BrightWhite => "bright_white",
200 })
201 }
202
203 pub fn set_named(&mut self, name: &str, value: Rgb) -> bool {
205 let target = match name {
206 "foreground" => &mut self.foreground,
207 "background" => &mut self.background,
208 "cursor" => &mut self.cursor,
209 "black" => &mut self.black,
210 "red" => &mut self.red,
211 "green" => &mut self.green,
212 "yellow" => &mut self.yellow,
213 "blue" => &mut self.blue,
214 "magenta" => &mut self.magenta,
215 "cyan" => &mut self.cyan,
216 "white" => &mut self.white,
217 "bright_black" => &mut self.bright_black,
218 "bright_red" => &mut self.bright_red,
219 "bright_green" => &mut self.bright_green,
220 "bright_yellow" => &mut self.bright_yellow,
221 "bright_blue" => &mut self.bright_blue,
222 "bright_magenta" => &mut self.bright_magenta,
223 "bright_cyan" => &mut self.bright_cyan,
224 "bright_white" => &mut self.bright_white,
225 _ => return false,
226 };
227 *target = value;
228 true
229 }
230
231 pub fn rgb(&self, index: u8) -> Rgb {
236 match index {
237 0..=15 => self.ansi()[index as usize],
238 _ => xterm_color(index),
239 }
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum ColorSlot {
250 Indexed(u8),
251 Foreground,
252 Background,
253 Cursor,
254}
255
256static XTERM_256: [Rgb; 256] = build_xterm_256();
263
264const fn build_xterm_256() -> [Rgb; 256] {
265 let mut table = [Rgb::new(0, 0, 0); 256];
266
267 let vga = [
269 (0, 0, 0),
270 (128, 0, 0),
271 (0, 128, 0),
272 (128, 128, 0),
273 (0, 0, 128),
274 (128, 0, 128),
275 (0, 128, 128),
276 (192, 192, 192),
277 (128, 128, 128),
278 (255, 0, 0),
279 (0, 255, 0),
280 (255, 255, 0),
281 (0, 0, 255),
282 (255, 0, 255),
283 (0, 255, 255),
284 (255, 255, 255),
285 ];
286 let mut i = 0;
287 while i < 16 {
288 table[i] = Rgb::new(vga[i].0, vga[i].1, vga[i].2);
289 i += 1;
290 }
291
292 let levels = [0u8, 95, 135, 175, 215, 255];
294 while i < 232 {
295 let n = i - 16;
296 table[i] = Rgb::new(levels[(n / 36) % 6], levels[(n / 6) % 6], levels[n % 6]);
297 i += 1;
298 }
299
300 while i < 256 {
302 let v = (i - 232) as u8 * 10 + 8;
303 table[i] = Rgb::new(v, v, v);
304 i += 1;
305 }
306 table
307}
308
309pub fn xterm_color(index: u8) -> Rgb {
311 XTERM_256[index as usize]
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(default, deny_unknown_fields)]
317pub struct Profile {
318 pub scrollback: usize,
320 pub colors: Colors,
321}
322
323impl Default for Profile {
324 fn default() -> Self {
325 Profile {
326 scrollback: DEFAULT_SCROLLBACK,
327 colors: Colors::default(),
328 }
329 }
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(default, deny_unknown_fields)]
335pub struct ConfigProfile {
336 pub scrollback: usize,
337 pub colors: Colors,
338 pub timeouts: crate::api::Timeouts,
339}
340
341impl Default for ConfigProfile {
342 fn default() -> Self {
343 Self {
344 scrollback: DEFAULT_SCROLLBACK,
345 colors: Colors::default(),
346 timeouts: crate::api::Timeouts::default(),
347 }
348 }
349}
350
351#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
353pub struct Settings {
354 pub profile: Profile,
355 pub timeouts: crate::api::Timeouts,
356}
357
358impl From<ConfigProfile> for Settings {
359 fn from(value: ConfigProfile) -> Self {
360 Self {
361 profile: Profile {
362 scrollback: value.scrollback,
363 colors: value.colors,
364 },
365 timeouts: value.timeouts,
366 }
367 }
368}
369
370#[derive(Debug, Clone, Default, Serialize, Deserialize)]
372#[serde(default, deny_unknown_fields)]
373pub struct ConfigFile {
374 pub profiles: BTreeMap<String, ConfigProfile>,
375}
376
377impl ConfigFile {
378 pub fn parse(toml_text: &str) -> anyhow::Result<Self> {
379 Ok(toml::from_str(toml_text)?)
380 }
381
382 pub fn load(path: &Path) -> anyhow::Result<Self> {
383 let text = std::fs::read_to_string(path)
384 .map_err(|e| anyhow::anyhow!("could not read {}: {e}", path.display()))?;
385 Self::parse(&text).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
386 }
387
388 pub fn profile(&self, name: Option<&str>) -> anyhow::Result<Profile> {
391 Ok(self.settings(name)?.profile)
392 }
393
394 pub fn settings(&self, name: Option<&str>) -> anyhow::Result<Settings> {
396 let profile = match name {
397 Some(name) => self.profiles.get(name).copied().ok_or_else(|| {
398 let known: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
399 if known.is_empty() {
400 anyhow::anyhow!("no profile {name:?}; the config file defines none")
401 } else {
402 anyhow::anyhow!("no profile {name:?}; found: {}", known.join(", "))
403 }
404 }),
405 None => Ok(self
406 .profiles
407 .get(DEFAULT_PROFILE)
408 .copied()
409 .unwrap_or_default()),
410 }?;
411 Ok(profile.into())
412 }
413}
414
415pub fn search_paths(cwd: &Path) -> Vec<PathBuf> {
421 if let Some(explicit) = std::env::var_os("TUI_TEST_CONFIG") {
422 return vec![PathBuf::from(explicit)];
423 }
424 default_search_paths(cwd)
425}
426
427fn default_search_paths(cwd: &Path) -> Vec<PathBuf> {
428 let config_home = if std::env::var_os("TUI_TEST_HOME").is_none() {
429 dirs::config_dir()
430 } else {
431 None
432 };
433 config_search_paths(cwd, config_home.as_deref(), &crate::config::home_dir())
434}
435
436fn config_search_paths(
437 cwd: &Path,
438 platform_config_home: Option<&Path>,
439 tui_test_home: &Path,
440) -> Vec<PathBuf> {
441 let mut paths = vec![cwd.join(CONFIG_FILE)];
442 if let Some(config_home) = platform_config_home {
443 paths.push(config_home.join("tui-test").join(CONFIG_FILE));
444 }
445 let home_config = tui_test_home.join(CONFIG_FILE);
446 if !paths.contains(&home_config) {
447 paths.push(home_config);
448 }
449 paths
450}
451
452pub fn resolve(
460 explicit_config: Option<&Path>,
461 profile_name: Option<&str>,
462 cwd: &Path,
463) -> anyhow::Result<Profile> {
464 Ok(resolve_settings(explicit_config, profile_name, cwd)?.profile)
465}
466
467pub fn resolve_settings(
469 explicit_config: Option<&Path>,
470 profile_name: Option<&str>,
471 cwd: &Path,
472) -> anyhow::Result<Settings> {
473 if let Some(path) = explicit_config {
474 return ConfigFile::load(path)?.settings(profile_name);
475 }
476 if let Some(path) = std::env::var_os("TUI_TEST_CONFIG").map(PathBuf::from) {
477 return ConfigFile::load(&path)?.settings(profile_name);
478 }
479 for path in default_search_paths(cwd) {
480 if path.is_file() {
481 return ConfigFile::load(&path)?.settings(profile_name);
482 }
483 }
484 match profile_name {
485 Some(name) => anyhow::bail!("no profile {name:?}: no config file found"),
486 None => Ok(Settings::default()),
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
495
496 #[test]
497 fn hex_colors_round_trip() {
498 for raw in ["#000000", "#ffffff", "#800000", "#c0c0c0"] {
499 assert_eq!(Rgb::parse(raw).unwrap().to_hex(), raw);
500 }
501 assert_eq!(Rgb::parse("#f00").unwrap(), Rgb::new(255, 0, 0));
502 assert_eq!(Rgb::parse("800000").unwrap(), Rgb::new(128, 0, 0));
503 }
504
505 #[test]
506 fn a_bad_color_says_what_it_wanted() {
507 for raw in ["", "#12", "#1234567", "nope", "#gggggg", "éa", "##fff"] {
508 let err = Rgb::parse(raw).unwrap_err();
509 assert!(
510 err.contains("color") || err.contains("hex"),
511 "{raw:?}: {err}"
512 );
513 }
514 }
515
516 #[test]
519 fn an_empty_config_yields_the_defaults() {
520 let cfg = ConfigFile::parse("").unwrap();
521 assert_eq!(cfg.profile(None).unwrap(), Profile::default());
522 assert_eq!(Profile::default().scrollback, 10_000);
523 }
524
525 #[test]
528 fn a_partial_profile_keeps_the_other_defaults() {
529 let cfg = ConfigFile::parse(
530 r##"
531 [profiles.ci]
532 scrollback = 50
533
534 [profiles.ci.colors]
535 red = "#ff0000"
536 "##,
537 )
538 .unwrap();
539 let p = cfg.profile(Some("ci")).unwrap();
540 assert_eq!(p.scrollback, 50);
541 assert_eq!(p.colors.red, Rgb::new(255, 0, 0), "the override applies");
542 assert_eq!(
543 p.colors.green,
544 Colors::default().green,
545 "an unset slot keeps its default"
546 );
547 assert_eq!(
548 p.colors.background,
549 Colors::default().background,
550 "an unset default color is untouched"
551 );
552 }
553
554 #[test]
555 fn profile_timeouts_are_loaded_and_accept_cli_overrides() {
556 let cfg = ConfigFile::parse(
557 r#"
558 [profiles.ci.timeouts]
559 text = 1000
560 command = 30000
561 "#,
562 )
563 .unwrap();
564 let settings = cfg.settings(Some("ci")).unwrap();
565 assert_eq!(settings.timeouts.text, Some(1_000));
566 assert_eq!(settings.timeouts.command, Some(30_000));
567 assert_eq!(settings.timeouts.ready, None);
568
569 let merged = settings.timeouts.with_overrides(crate::api::Timeouts {
570 text: Some(2_000),
571 ready: Some(5_000),
572 ..Default::default()
573 });
574 assert_eq!(merged.text, Some(2_000));
575 assert_eq!(merged.command, Some(30_000));
576 assert_eq!(merged.ready, Some(5_000));
577 }
578
579 #[test]
580 fn an_unknown_timeout_class_is_rejected() {
581 let err = ConfigFile::parse("[profiles.ci.timeouts]\ncommands = 10\n")
582 .unwrap_err()
583 .to_string();
584 assert!(err.contains("commands"), "{err}");
585 }
586
587 #[test]
588 fn an_unknown_profile_names_the_ones_that_exist() {
589 let cfg = ConfigFile::parse("[profiles.ci]\n[profiles.demo]\n").unwrap();
590 let err = cfg.profile(Some("nope")).unwrap_err().to_string();
591 assert!(err.contains("ci") && err.contains("demo"), "{err}");
592 }
593
594 #[test]
597 fn an_unknown_key_is_rejected() {
598 let err = ConfigFile::parse("[profiles.ci]\nscrollbacks = 10\n")
599 .unwrap_err()
600 .to_string();
601 assert!(err.contains("scrollbacks"), "{err}");
602 }
603
604 #[test]
607 fn the_color_cube_ignores_the_profile() {
608 let recolored = Colors {
609 red: Rgb::new(1, 2, 3),
610 ..Default::default()
611 };
612 for n in 16u8..=255 {
613 assert_eq!(recolored.rgb(n), Colors::default().rgb(n), "index {n}");
614 }
615 assert_eq!(Colors::default().rgb(196), Rgb::new(255, 0, 0));
616 assert_eq!(Colors::default().rgb(232), Rgb::new(8, 8, 8));
617 assert_eq!(recolored.rgb(1), Rgb::new(1, 2, 3), "but slot 1 follows it");
618 }
619
620 #[test]
623 fn every_ansi_slot_has_a_config_key() {
624 for i in 0u8..16 {
625 let name = Colors::slot_name(i).unwrap_or_else(|| panic!("slot {i} unnamed"));
626 let toml = format!("[profiles.p.colors]\n{name} = \"#010203\"\n");
627 let p = ConfigFile::parse(&toml)
628 .unwrap()
629 .profile(Some("p"))
630 .unwrap();
631 assert_eq!(
632 p.colors.rgb(i),
633 Rgb::new(1, 2, 3),
634 "setting {name:?} must move slot {i}"
635 );
636 }
637 assert_eq!(Colors::slot_name(16), None, "only 0-15 are configurable");
638 }
639
640 #[test]
641 fn every_binding_color_name_is_settable() {
642 let mut colors = Colors::default();
643 let replacement = Rgb::new(1, 2, 3);
644 for name in [
645 "foreground",
646 "background",
647 "cursor",
648 "black",
649 "red",
650 "green",
651 "yellow",
652 "blue",
653 "magenta",
654 "cyan",
655 "white",
656 "bright_black",
657 "bright_red",
658 "bright_green",
659 "bright_yellow",
660 "bright_blue",
661 "bright_magenta",
662 "bright_cyan",
663 "bright_white",
664 ] {
665 assert!(colors.set_named(name, replacement), "{name}");
666 }
667 assert!(!colors.set_named("chartreuse", replacement));
668 assert!([
669 colors.foreground,
670 colors.background,
671 colors.cursor,
672 colors.black,
673 colors.red,
674 colors.green,
675 colors.yellow,
676 colors.blue,
677 colors.magenta,
678 colors.cyan,
679 colors.white,
680 colors.bright_black,
681 colors.bright_red,
682 colors.bright_green,
683 colors.bright_yellow,
684 colors.bright_blue,
685 colors.bright_magenta,
686 colors.bright_cyan,
687 colors.bright_white,
688 ]
689 .into_iter()
690 .all(|color| color == replacement));
691 }
692
693 #[test]
696 fn only_the_ansi_slots_follow_the_profile() {
697 let recolored = Colors {
698 red: Rgb::new(1, 2, 3),
699 ..Default::default()
700 };
701 assert_eq!(recolored.rgb(1), Rgb::new(1, 2, 3), "slot 1 follows it");
702 for index in 16u8..=255 {
703 assert_eq!(
704 recolored.rgb(index),
705 xterm_color(index),
706 "slot {index} is fixed by the specification"
707 );
708 }
709 }
710
711 #[test]
714 fn the_xterm_table_matches_the_specification() {
715 assert_eq!(xterm_color(0), Rgb::new(0, 0, 0), "VGA black");
716 assert_eq!(xterm_color(1), Rgb::new(128, 0, 0), "VGA red");
717 assert_eq!(xterm_color(15), Rgb::new(255, 255, 255), "VGA bright white");
718 assert_eq!(
719 xterm_color(16),
720 Rgb::new(0, 0, 0),
721 "the cube starts at black"
722 );
723 assert_eq!(xterm_color(196), Rgb::new(255, 0, 0), "cube red");
724 assert_eq!(
725 xterm_color(231),
726 Rgb::new(255, 255, 255),
727 "the cube ends white"
728 );
729 assert_eq!(xterm_color(232), Rgb::new(8, 8, 8), "the ramp starts at 8");
730 assert_eq!(
731 xterm_color(255),
732 Rgb::new(238, 238, 238),
733 "the ramp ends at 238"
734 );
735 }
736
737 #[test]
740 fn the_search_order_puts_the_project_first() {
741 let _guard = ENV_LOCK.lock().unwrap();
742
743 let old = std::env::var_os("TUI_TEST_CONFIG");
744 std::env::remove_var("TUI_TEST_CONFIG");
745 let cwd = std::env::temp_dir().join("some-project");
746 let pinned_path = std::env::temp_dir().join("pinned.toml");
747 let result = std::panic::catch_unwind(|| {
748 let paths = search_paths(&cwd);
749 assert!(paths.len() >= 2);
750 assert_eq!(paths[0], cwd.join(CONFIG_FILE), "the project file is first");
751 assert!(
752 paths[1..].iter().all(|path| path.ends_with(CONFIG_FILE)),
753 "every user candidate names the config file: {paths:?}"
754 );
755
756 std::env::set_var("TUI_TEST_CONFIG", &pinned_path);
757 let pinned = search_paths(&cwd);
758 assert_eq!(
759 pinned,
760 vec![pinned_path.clone()],
761 "an explicit config replaces the search entirely"
762 );
763 });
764 std::env::remove_var("TUI_TEST_CONFIG");
765 if let Some(value) = old {
766 std::env::set_var("TUI_TEST_CONFIG", value);
767 }
768 result.unwrap();
769 }
770
771 #[test]
772 fn the_platform_config_directory_precedes_tui_test_home() {
773 let cwd = Path::new("project");
774 let config_home = Path::new("xdg-config");
775 let tui_test_home = Path::new("tui-test-home");
776 assert_eq!(
777 config_search_paths(cwd, Some(config_home), tui_test_home),
778 vec![
779 cwd.join(CONFIG_FILE),
780 config_home.join("tui-test").join(CONFIG_FILE),
781 tui_test_home.join(CONFIG_FILE),
782 ]
783 );
784 }
785
786 #[test]
789 fn a_missing_environment_override_is_an_error() {
790 let _guard = ENV_LOCK.lock().unwrap();
791 let old = std::env::var_os("TUI_TEST_CONFIG");
792 let dir = std::env::temp_dir().join(format!("su-profile-env-{}", std::process::id()));
793 std::fs::create_dir_all(&dir).unwrap();
794 let missing = dir.join("missing.toml");
795
796 std::env::set_var("TUI_TEST_CONFIG", &missing);
797 let result = std::panic::catch_unwind(|| {
798 let err = resolve(None, None, &dir).unwrap_err().to_string();
799 assert!(
800 err.contains("missing.toml"),
801 "the explicit missing path is named: {err}"
802 );
803 });
804
805 std::env::remove_var("TUI_TEST_CONFIG");
806 if let Some(value) = old {
807 std::env::set_var("TUI_TEST_CONFIG", value);
808 }
809 std::fs::remove_dir_all(&dir).ok();
810 result.unwrap();
811 }
812
813 #[test]
817 fn a_missing_config_defaults_but_a_broken_one_fails() {
818 let dir = std::env::temp_dir().join(format!("su-profile-{}", std::process::id()));
819 std::fs::create_dir_all(&dir).unwrap();
820
821 let missing = dir.join("absent.toml");
822 assert!(
823 resolve(Some(&missing), None, &dir).is_err(),
824 "named-but-absent is an error"
825 );
826
827 let broken = dir.join("broken.toml");
828 std::fs::write(&broken, "[profiles.ci]\nscrollback = \"lots\"\n").unwrap();
829 let err = resolve(Some(&broken), None, &dir).unwrap_err().to_string();
830 assert!(
831 err.contains("broken.toml"),
832 "the error names the file: {err}"
833 );
834
835 std::fs::remove_dir_all(&dir).ok();
836 }
837}