1use anyhow::Context;
11
12use crate::error::Result;
13use serde::Deserialize;
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18pub const DEFAULT_PREFIX: &str = "Software";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Layout {
24 root: PathBuf,
25 prefix: String,
26 guessed: bool,
30}
31
32impl Layout {
33 pub fn new(root: impl Into<PathBuf>, prefix: impl Into<String>) -> Self {
37 let prefix = prefix.into();
38 Self {
39 root: root.into(),
40 prefix: if prefix.is_empty() {
41 DEFAULT_PREFIX.to_string()
42 } else {
43 prefix
44 },
45 guessed: false,
46 }
47 }
48
49 pub fn resolve(root: Option<&Path>, prefix: Option<&str>) -> Result<Self> {
59 let here = std::env::current_dir().context("resolve current directory as root")?;
60 let (root, guessed) = choose_root(
61 root,
62 std::env::var_os("ISSUE_ROOT")
63 .or_else(|| std::env::var_os("VISSUE_ROOT"))
64 .map(PathBuf::from),
65 &here,
66 here.join("vissue.toml").is_file(),
67 SeatConfig::path().as_deref().and_then(SeatConfig::read),
68 );
69 let prefix = match prefix {
70 Some(p) if !p.is_empty() => p.to_string(),
71 _ => match std::env::var("VISSUE_PREFIX") {
72 Ok(v) if !v.is_empty() => v,
73 _ => RootConfig::load(&root)?
74 .prefix
75 .unwrap_or_else(|| DEFAULT_PREFIX.to_string()),
76 },
77 };
78 let mut layout = Self::new(root, prefix);
79 layout.guessed = guessed;
80 Ok(layout)
81 }
82
83 pub fn require_tracker(&self) -> Result<()> {
90 if !self.guessed || self.root.join("vissue.toml").is_file() || self.projects_dir().is_dir()
91 {
92 return Ok(());
93 }
94 Err(crate::error::Error::NotATracker {
95 root: self.root.clone(),
96 prefix: self.prefix.clone(),
97 })
98 }
99
100 pub fn root(&self) -> &Path {
102 &self.root
103 }
104
105 pub fn prefix(&self) -> &str {
107 &self.prefix
108 }
109
110 pub fn projects_dir(&self) -> PathBuf {
112 self.root.join(&self.prefix)
113 }
114
115 pub fn project_issues_path(&self, project: &str) -> PathBuf {
117 self.projects_dir().join(project).join("issues.org")
118 }
119}
120
121fn choose_root(
125 named: Option<&Path>,
126 from_env: Option<PathBuf>,
127 here: &Path,
128 here_is_a_tracker: bool,
129 seat: Option<PathBuf>,
130) -> (PathBuf, bool) {
131 if let Some(root) = named {
132 return (root.to_path_buf(), false);
133 }
134 if let Some(root) = from_env {
135 return (root, false);
136 }
137 if here_is_a_tracker {
140 return (here.to_path_buf(), false);
141 }
142 match seat {
143 Some(root) => (root, false),
144 None => (here.to_path_buf(), true),
145 }
146}
147
148#[derive(Debug, Clone, Default, Deserialize)]
151#[serde(default)]
152struct SeatConfig {
153 root: Option<String>,
154}
155
156impl SeatConfig {
157 fn read(path: &Path) -> Option<PathBuf> {
159 let raw = fs::read_to_string(path).ok()?;
160 let parsed: Self = toml::from_str(&raw).ok()?;
161 let named = parsed.root?;
162 let named = named.trim();
163 if named.is_empty() {
164 return None;
165 }
166 let expanded = match named.strip_prefix("~/") {
167 Some(rest) => home()?.join(rest),
168 None => PathBuf::from(named),
169 };
170 expanded.is_dir().then_some(expanded)
171 }
172
173 fn path() -> Option<PathBuf> {
176 if let Some(named) = std::env::var_os("VISSUE_CONFIG").filter(|raw| !raw.is_empty()) {
177 return Some(PathBuf::from(named));
178 }
179 let base = match std::env::var_os("XDG_CONFIG_HOME") {
180 Some(dir) if !dir.is_empty() => PathBuf::from(dir),
181 _ => home()?.join(".config"),
182 };
183 Some(base.join("vissue").join("config.toml"))
184 }
185}
186
187fn home() -> Option<PathBuf> {
188 std::env::var_os("HOME")
189 .filter(|value| !value.is_empty())
190 .map(PathBuf::from)
191}
192
193pub fn layout_at(root: &Path) -> Result<Layout> {
200 let prefix = RootConfig::load(root)?
201 .prefix
202 .unwrap_or_else(|| DEFAULT_PREFIX.to_string());
203 Ok(Layout::new(root.to_path_buf(), prefix))
204}
205
206#[derive(Debug, Clone, Default, Deserialize)]
208#[serde(default)]
209struct RootConfig {
210 prefix: Option<String>,
211 agent: Option<String>,
212 issues: IssuesOverride,
213 consensus: ConsensusOverride,
214}
215
216impl RootConfig {
217 fn load(root: &Path) -> Result<Self> {
218 let path = root.join("vissue.toml");
219 if !path.exists() {
220 return Ok(Self::default());
221 }
222 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
223 toml::from_str(&raw)
224 .with_context(|| format!("parse {}", path.display()))
225 .map_err(crate::error::Error::from)
226 }
227}
228
229#[derive(Debug, Clone, Deserialize)]
231#[serde(default)]
232pub struct IssuesSection {
233 pub default_priority: char,
235 pub id_length: usize,
237 pub stale_claim_days: i64,
240 pub expect_deeds: bool,
242}
243
244impl Default for IssuesSection {
245 fn default() -> Self {
246 Self {
247 default_priority: 'C',
248 id_length: 4,
249 stale_claim_days: 7,
250 expect_deeds: false,
251 }
252 }
253}
254
255#[derive(Debug, Clone, Default, Deserialize)]
259#[serde(default)]
260struct IssuesOverride {
261 default_priority: Option<char>,
262 id_length: Option<usize>,
263 stale_claim_days: Option<i64>,
264 expect_deeds: Option<bool>,
265}
266
267impl IssuesOverride {
268 fn apply_to(&self, base: &mut IssuesSection) {
269 if let Some(value) = self.default_priority {
270 base.default_priority = value;
271 }
272 if let Some(value) = self.id_length {
273 base.id_length = value;
274 }
275 if let Some(value) = self.stale_claim_days {
276 base.stale_claim_days = value;
277 }
278 if let Some(value) = self.expect_deeds {
279 base.expect_deeds = value;
280 }
281 }
282}
283
284#[derive(Debug, Clone, PartialEq)]
301pub struct ConsensusSection {
302 pub self_weight: f64,
304 pub susceptibility: f64,
307 pub tolerance: f64,
309 pub max_iterations: usize,
311 pub susceptibility_of: BTreeMap<String, f64>,
313 pub trust: BTreeMap<String, BTreeMap<String, f64>>,
315}
316
317impl Default for ConsensusSection {
318 fn default() -> Self {
319 Self {
320 self_weight: 0.5,
323 susceptibility: 1.0,
324 susceptibility_of: BTreeMap::new(),
325 tolerance: 1e-9,
326 max_iterations: 500,
327 trust: BTreeMap::new(),
328 }
329 }
330}
331
332#[derive(Debug, Clone, Default, Deserialize)]
334#[serde(default)]
335struct ConsensusOverride {
336 self_weight: Option<f64>,
337 susceptibility: Option<f64>,
338 #[serde(default)]
339 susceptibility_of: BTreeMap<String, f64>,
340 tolerance: Option<f64>,
341 max_iterations: Option<usize>,
342 trust: BTreeMap<String, BTreeMap<String, f64>>,
343}
344
345impl ConsensusOverride {
346 fn apply_to(&self, base: &mut ConsensusSection, whence: &Path) -> Result<()> {
349 if let Some(value) = self.self_weight {
350 if !(0.0..=1.0).contains(&value) {
351 return Err(anyhow::anyhow!(
352 "{}: consensus.self_weight is {value}, which is not a share between 0 and 1",
353 whence.display()
354 )
355 .into());
356 }
357 base.self_weight = value;
358 }
359 if let Some(value) = self.susceptibility {
360 if !(0.0..=1.0).contains(&value) {
361 return Err(anyhow::anyhow!(
362 "{}: consensus.susceptibility is {value}, which is not a share between 0 and 1",
363 whence.display()
364 )
365 .into());
366 }
367 base.susceptibility = value;
368 }
369 for (agent, value) in &self.susceptibility_of {
370 if !(0.0..=1.0).contains(value) {
371 return Err(anyhow::anyhow!(
372 "{}: consensus.susceptibility_of.{agent} is {value}, \
373 which is not a share between 0 and 1",
374 whence.display()
375 )
376 .into());
377 }
378 base.susceptibility_of.insert(agent.clone(), *value);
381 }
382 if let Some(value) = self.tolerance {
383 if !(value > 0.0 && value.is_finite()) {
384 return Err(anyhow::anyhow!(
385 "{}: consensus.tolerance is {value}, which is not a positive distance",
386 whence.display()
387 )
388 .into());
389 }
390 base.tolerance = value;
391 }
392 if let Some(value) = self.max_iterations {
393 if value == 0 {
394 return Err(anyhow::anyhow!(
395 "{}: consensus.max_iterations is 0, which runs no rounds at all",
396 whence.display()
397 )
398 .into());
399 }
400 base.max_iterations = value;
401 }
402 for (agent, row) in &self.trust {
403 for (other, weight) in row {
404 if !(*weight >= 0.0 && weight.is_finite()) {
405 return Err(anyhow::anyhow!(
406 "{}: consensus.trust.{agent}.{other} is {weight}, \
407 which is not a weight",
408 whence.display()
409 )
410 .into());
411 }
412 }
413 base.trust.insert(agent.clone(), row.clone());
417 }
418 Ok(())
419 }
420}
421
422#[derive(Debug, Clone, Default)]
424pub struct VissueConfig {
425 pub issues: IssuesSection,
427 pub consensus: ConsensusSection,
429}
430
431#[derive(Debug, Clone, Default, Deserialize)]
432#[serde(default)]
433struct PrefixConfigFile {
434 issues: IssuesOverride,
435 consensus: ConsensusOverride,
436}
437
438impl VissueConfig {
439 pub fn load(layout: &Layout) -> Result<Self> {
448 let mut issues = IssuesSection::default();
449 let mut consensus = ConsensusSection::default();
450 let root_path = layout.root().join("vissue.toml");
451 let root = RootConfig::load(layout.root())?;
452 root.issues.apply_to(&mut issues);
453 root.consensus.apply_to(&mut consensus, &root_path)?;
454 let path = layout.projects_dir().join("issues.config.toml");
455 if path.exists() {
456 let raw =
457 fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
458 let parsed: PrefixConfigFile =
459 toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
460 parsed.issues.apply_to(&mut issues);
461 parsed.consensus.apply_to(&mut consensus, &path)?;
462 }
463 Ok(Self { issues, consensus })
464 }
465}
466
467pub fn identity(layout: &Layout) -> String {
474 if let Ok(value) = crate::process_env::var("VISSUE_AGENT") {
475 let value = value.trim();
476 if !value.is_empty() {
477 return value.to_string();
478 }
479 }
480 if let Ok(cfg) = RootConfig::load(layout.root())
481 && let Some(agent) = cfg.agent
482 {
483 let agent = agent.trim().to_string();
484 if !agent.is_empty() {
485 return agent;
486 }
487 }
488 format!("{}@{}", current_user(), current_host())
489}
490
491fn current_user() -> String {
492 for var in ["USER", "LOGNAME", "USERNAME"] {
493 if let Ok(value) = std::env::var(var)
494 && !value.trim().is_empty()
495 {
496 return value.trim().to_string();
497 }
498 }
499 "unknown".to_string()
500}
501
502fn current_host() -> String {
503 if let Ok(value) = std::env::var("HOSTNAME")
504 && !value.trim().is_empty()
505 {
506 return value.trim().to_string();
507 }
508 for path in ["/etc/hostname", "/proc/sys/kernel/hostname"] {
511 if let Ok(text) = fs::read_to_string(path) {
512 let trimmed = text.trim();
513 if !trimmed.is_empty() {
514 return trimmed.to_string();
515 }
516 }
517 }
518 "unknown".to_string()
519}
520
521#[cfg(test)]
522#[allow(deprecated_safe_2024)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn layout_defaults_to_software_prefix() {
528 let layout = Layout::new("/somewhere", "");
529 assert_eq!(layout.prefix(), DEFAULT_PREFIX);
530 assert_eq!(
531 layout.project_issues_path("demo"),
532 Path::new("/somewhere/Software/demo/issues.org")
533 );
534 }
535
536 #[test]
537 fn explicit_prefix_wins() {
538 let dir = tempfile::tempdir().unwrap();
539 fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
540 let layout = Layout::resolve(Some(dir.path()), Some("tracker")).unwrap();
541 assert_eq!(layout.prefix(), "tracker");
542 }
543
544 #[test]
545 fn root_config_supplies_prefix() {
546 let dir = tempfile::tempdir().unwrap();
547 fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
548 let layout = Layout::resolve(Some(dir.path()), None).unwrap();
549 assert_eq!(layout.prefix(), "projects");
550 assert_eq!(
551 layout.projects_dir(),
552 dir.path().join("projects"),
553 "projects dir follows the configured prefix"
554 );
555 }
556
557 static AGENT_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
559
560 #[test]
561 fn the_environment_names_the_claiming_identity_first() {
562 let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
563 let dir = tempfile::tempdir().unwrap();
564 fs::write(dir.path().join("vissue.toml"), "agent = \"from-file\"\n").unwrap();
565 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
566
567 crate::process_env::override_var("VISSUE_AGENT", Some("from-env"));
568 let from_env = identity(&layout);
569 crate::process_env::override_var("VISSUE_AGENT", Some(" "));
570 let blank_falls_through = identity(&layout);
571 crate::process_env::override_var("VISSUE_AGENT", None);
572 let from_file = identity(&layout);
573 crate::process_env::clear_override("VISSUE_AGENT");
574
575 assert_eq!(from_env, "from-env");
576 assert_eq!(
577 blank_falls_through, "from-file",
578 "a blank value is not an identity"
579 );
580 assert_eq!(from_file, "from-file");
581 }
582
583 #[test]
584 fn without_configuration_the_identity_is_user_at_host() {
585 let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
586 let dir = tempfile::tempdir().unwrap();
587 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
588 crate::process_env::override_var("VISSUE_AGENT", None);
589 let resolved = identity(&layout);
590 crate::process_env::clear_override("VISSUE_AGENT");
591 assert!(resolved.contains('@'), "{resolved}");
592 assert!(!resolved.starts_with('@'), "{resolved}");
593 assert!(!resolved.ends_with('@'), "{resolved}");
594 }
595
596 #[test]
597 fn the_stale_claim_threshold_is_configurable() {
598 let dir = tempfile::tempdir().unwrap();
599 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
600 assert_eq!(
601 VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
602 7
603 );
604
605 fs::write(
606 dir.path().join("vissue.toml"),
607 "[issues]\nstale_claim_days = 3\n",
608 )
609 .unwrap();
610 assert_eq!(
611 VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
612 3
613 );
614 }
615
616 #[test]
620 fn a_consensus_weight_the_iteration_cannot_use_is_refused() {
621 for (body, wanted) in [
622 ("[consensus]\nself_weight = 2.0\n", "self_weight"),
623 ("[consensus]\nself_weight = -0.5\n", "self_weight"),
624 ("[consensus]\ntolerance = 0.0\n", "tolerance"),
625 ("[consensus]\ntolerance = -1.0\n", "tolerance"),
626 ("[consensus]\nmax_iterations = 0\n", "max_iterations"),
627 (
628 "[consensus.trust]\nalice = { bob = -1.0 }\n",
629 "consensus.trust.alice.bob",
630 ),
631 ] {
632 let dir = tempfile::tempdir().unwrap();
633 fs::write(dir.path().join("vissue.toml"), body).unwrap();
634 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
635 let err = VissueConfig::load(&layout).unwrap_err().to_string();
636 assert!(err.contains(wanted), "{body:?} -> {err}");
637 assert!(
638 err.contains("vissue.toml"),
639 "the message has to name the file: {err}"
640 );
641 }
642 }
643
644 #[test]
648 fn a_per_agent_susceptibility_is_checked_and_names_the_agent() {
649 let dir = tempfile::tempdir().unwrap();
650 fs::write(
651 dir.path().join("vissue.toml"),
652 "[consensus.susceptibility_of]\nmaintainer = 1.5\n",
653 )
654 .unwrap();
655 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
656 let err = VissueConfig::load(&layout).unwrap_err().to_string();
657 assert!(err.contains("maintainer"), "{err}");
658 assert!(err.contains("vissue.toml"), "{err}");
659 }
660
661 #[test]
664 fn a_susceptibility_row_overrides_only_the_agent_it_names() {
665 let dir = tempfile::tempdir().unwrap();
666 fs::write(
667 dir.path().join("vissue.toml"),
668 "[consensus.susceptibility_of]\nmaintainer = 0.2\nreviewer = 0.6\n",
669 )
670 .unwrap();
671 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
672 fs::create_dir_all(layout.projects_dir()).unwrap();
673 fs::write(
674 layout.projects_dir().join("issues.config.toml"),
675 "[consensus.susceptibility_of]\nmaintainer = 0.4\n",
676 )
677 .unwrap();
678
679 let cfg = VissueConfig::load(&layout).unwrap().consensus;
680 assert_eq!(cfg.susceptibility_of.get("maintainer"), Some(&0.4));
681 assert_eq!(
682 cfg.susceptibility_of.get("reviewer"),
683 Some(&0.6),
684 "a row the second file says nothing about survives"
685 );
686 }
687
688 #[test]
692 fn the_ends_of_the_self_weight_range_are_accepted() {
693 for value in ["0.0", "1.0"] {
694 let dir = tempfile::tempdir().unwrap();
695 fs::write(
696 dir.path().join("vissue.toml"),
697 format!("[consensus]\nself_weight = {value}\n"),
698 )
699 .unwrap();
700 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
701 let cfg = VissueConfig::load(&layout).expect(value);
702 assert_eq!(cfg.consensus.self_weight, value.parse::<f64>().unwrap());
703 }
704 }
705
706 #[test]
709 fn a_trust_row_overrides_only_the_agent_it_names() {
710 let dir = tempfile::tempdir().unwrap();
711 fs::write(
712 dir.path().join("vissue.toml"),
713 "[consensus.trust]\nalice = { bob = 1.0 }\ncarol = { alice = 1.0 }\n",
714 )
715 .unwrap();
716 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
717 fs::create_dir_all(layout.projects_dir()).unwrap();
718 fs::write(
719 layout.projects_dir().join("issues.config.toml"),
720 "[consensus.trust]\nalice = { carol = 4.0 }\n",
721 )
722 .unwrap();
723
724 let cfg = VissueConfig::load(&layout).unwrap();
725 assert_eq!(
726 cfg.consensus
727 .trust
728 .get("alice")
729 .and_then(|r| r.get("carol")),
730 Some(&4.0),
731 "the named row is replaced whole"
732 );
733 assert!(
734 cfg.consensus
735 .trust
736 .get("alice")
737 .is_some_and(|r| !r.contains_key("bob")),
738 "replaced, not merged into: {:?}",
739 cfg.consensus.trust
740 );
741 assert_eq!(
742 cfg.consensus
743 .trust
744 .get("carol")
745 .and_then(|r| r.get("alice")),
746 Some(&1.0),
747 "a row the second file says nothing about survives"
748 );
749 }
750
751 #[test]
753 fn the_consensus_defaults_converge_on_their_own() {
754 let dir = tempfile::tempdir().unwrap();
755 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
756 let cfg = VissueConfig::load(&layout).unwrap().consensus;
757 assert!(cfg.trust.is_empty());
758 assert!(
759 cfg.self_weight > 0.0,
760 "a zero diagonal is what makes a trust graph periodic"
761 );
762 assert!(cfg.tolerance > 0.0 && cfg.max_iterations > 0);
763 }
764
765 #[test]
766 fn config_defaults_when_no_files_present() {
767 let dir = tempfile::tempdir().unwrap();
768 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
769 let cfg = VissueConfig::load(&layout).unwrap();
770 assert_eq!(cfg.issues.default_priority, 'C');
771 assert_eq!(cfg.issues.id_length, 4);
772 }
773
774 #[test]
775 fn prefix_scoped_config_overrides_root_config() {
776 let dir = tempfile::tempdir().unwrap();
777 fs::write(
778 dir.path().join("vissue.toml"),
779 "[issues]\ndefault_priority = \"B\"\nid_length = 5\n",
780 )
781 .unwrap();
782 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
783 let cfg = VissueConfig::load(&layout).unwrap();
784 assert_eq!(cfg.issues.default_priority, 'B');
785 assert_eq!(cfg.issues.id_length, 5);
786
787 fs::create_dir_all(layout.projects_dir()).unwrap();
788 fs::write(
789 layout.projects_dir().join("issues.config.toml"),
790 "[issues]\ndefault_priority = \"A\"\nid_length = 6\n",
791 )
792 .unwrap();
793 let cfg = VissueConfig::load(&layout).unwrap();
794 assert_eq!(cfg.issues.default_priority, 'A');
795 assert_eq!(cfg.issues.id_length, 6);
796 }
797
798 #[test]
799 fn a_partial_override_keeps_the_keys_it_does_not_name() {
800 let dir = tempfile::tempdir().unwrap();
801 fs::write(
802 dir.path().join("vissue.toml"),
803 "[issues]\ndefault_priority = \"B\"\nid_length = 5\nstale_claim_days = 3\n",
804 )
805 .unwrap();
806 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
807 fs::create_dir_all(layout.projects_dir()).unwrap();
808 fs::write(
809 layout.projects_dir().join("issues.config.toml"),
810 "[issues]\nid_length = 6\n",
811 )
812 .unwrap();
813
814 let cfg = VissueConfig::load(&layout).unwrap();
815 assert_eq!(cfg.issues.id_length, 6, "the named key is overridden");
816 assert_eq!(
817 cfg.issues.default_priority, 'B',
818 "an unnamed key keeps the root value"
819 );
820 assert_eq!(cfg.issues.stale_claim_days, 3);
821 }
822
823 #[test]
825 fn a_seat_file_names_a_tracker() {
826 let dir = tempfile::tempdir().unwrap();
827 let tracker = tempfile::tempdir().unwrap();
828 let path = dir.path().join("config.toml");
829 fs::write(
830 &path,
831 format!("root = {:?}\n", tracker.path().display().to_string()),
832 )
833 .unwrap();
834 assert_eq!(
835 SeatConfig::read(&path).unwrap().canonicalize().unwrap(),
836 tracker.path().canonicalize().unwrap()
837 );
838 }
839
840 #[test]
844 fn a_seat_file_that_says_nothing_usable_says_nothing() {
845 let dir = tempfile::tempdir().unwrap();
846 assert!(SeatConfig::read(&dir.path().join("absent.toml")).is_none());
847 for text in [
848 "",
849 "root = \"\"\n",
850 "root = \"/nonexistent/tracker\"\n",
851 "root =",
852 ] {
853 let path = dir.path().join("config.toml");
854 fs::write(&path, text).unwrap();
855 assert!(SeatConfig::read(&path).is_none(), "{text:?}");
856 }
857 }
858
859 #[test]
863 fn the_seat_root_shares_the_file_the_router_reads() {
864 let dir = tempfile::tempdir().unwrap();
865 let tracker = tempfile::tempdir().unwrap();
866 let path = dir.path().join("config.toml");
867 fs::write(
868 &path,
869 format!(
870 "root = {:?}\n\n[layouts.other]\nroot = \"/somewhere\"\nprefix = \"Issues\"\n\n[routes]\nthing = \"other\"\n",
871 tracker.path().display().to_string()
872 ),
873 )
874 .unwrap();
875 assert_eq!(
876 SeatConfig::read(&path).unwrap().canonicalize().unwrap(),
877 tracker.path().canonicalize().unwrap()
878 );
879 crate::router::Router::from_file(Layout::new(dir.path(), DEFAULT_PREFIX), &path)
882 .expect("the router reads the same file");
883 }
884
885 #[test]
887 fn the_caller_beats_the_environment_beats_where_you_stand() {
888 let named = PathBuf::from("/named");
889 let from_env = PathBuf::from("/env");
890 let seat = PathBuf::from("/seat");
891 let here = PathBuf::from("/here");
892
893 assert_eq!(
895 choose_root(
896 Some(&named),
897 Some(from_env.clone()),
898 &here,
899 false,
900 Some(seat.clone())
901 ),
902 (named.clone(), false)
903 );
904 assert_eq!(
906 choose_root(
907 None,
908 Some(from_env.clone()),
909 &here,
910 true,
911 Some(seat.clone())
912 ),
913 (from_env, false)
914 );
915 assert_eq!(
918 choose_root(None, None, &here, true, Some(seat.clone())),
919 (here.clone(), false)
920 );
921 assert_eq!(
923 choose_root(None, None, &here, false, Some(seat.clone())),
924 (seat, false)
925 );
926 assert_eq!(choose_root(None, None, &here, false, None), (here, true));
929 }
930}