1use anyhow::Context;
17
18use crate::error::Result;
19use serde::Deserialize;
20use std::collections::BTreeMap;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24pub const DEFAULT_PREFIX: &str = "Software";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Layout {
30 root: PathBuf,
31 prefix: String,
32 guessed: bool,
36}
37
38impl Layout {
39 pub fn new(root: impl Into<PathBuf>, prefix: impl Into<String>) -> Self {
43 let prefix = prefix.into();
44 Self {
45 root: root.into(),
46 prefix: if prefix.is_empty() {
47 DEFAULT_PREFIX.to_string()
48 } else {
49 prefix
50 },
51 guessed: false,
52 }
53 }
54
55 pub fn resolve(root: Option<&Path>, prefix: Option<&str>) -> Result<Self> {
65 let here = std::env::current_dir().context("resolve current directory as root")?;
66 let (root, guessed) = choose_root(
67 root,
68 std::env::var_os("ISSUE_ROOT")
69 .or_else(|| std::env::var_os("VISSUE_ROOT"))
70 .map(PathBuf::from),
71 &here,
72 here.join("vissue.toml").is_file(),
73 SeatConfig::path().as_deref().and_then(SeatConfig::read),
74 );
75 let prefix = match prefix {
76 Some(p) if !p.is_empty() => p.to_string(),
77 _ => match std::env::var("VISSUE_PREFIX") {
78 Ok(v) if !v.is_empty() => v,
79 _ => RootConfig::load(&root)?
80 .prefix
81 .unwrap_or_else(|| DEFAULT_PREFIX.to_string()),
82 },
83 };
84 let mut layout = Self::new(root, prefix);
85 layout.guessed = guessed;
86 Ok(layout)
87 }
88
89 pub fn require_tracker(&self) -> Result<()> {
101 if !self.guessed || self.root.join("vissue.toml").is_file() || self.projects_dir().is_dir()
102 {
103 return Ok(());
104 }
105 Err(crate::error::Error::NotATracker {
106 root: self.root.clone(),
107 prefix: self.prefix.clone(),
108 })
109 }
110
111 pub fn root(&self) -> &Path {
113 &self.root
114 }
115
116 pub fn prefix(&self) -> &str {
118 &self.prefix
119 }
120
121 pub fn projects_dir(&self) -> PathBuf {
123 self.root.join(&self.prefix)
124 }
125
126 pub fn project_issues_path(&self, project: &str) -> PathBuf {
128 self.projects_dir().join(project).join("issues.org")
129 }
130}
131
132fn choose_root(
140 named: Option<&Path>,
141 from_env: Option<PathBuf>,
142 here: &Path,
143 here_is_a_tracker: bool,
144 seat: Option<PathBuf>,
145) -> (PathBuf, bool) {
146 if let Some(root) = named {
147 return (root.to_path_buf(), false);
148 }
149 if let Some(root) = from_env {
150 return (root, false);
151 }
152 if here_is_a_tracker {
155 return (here.to_path_buf(), false);
156 }
157 match seat {
158 Some(root) => (root, false),
159 None => (here.to_path_buf(), true),
160 }
161}
162
163#[derive(Debug, Clone, Default, Deserialize)]
175#[serde(default)]
176struct SeatConfig {
177 root: Option<String>,
178}
179
180impl SeatConfig {
181 fn read(path: &Path) -> Option<PathBuf> {
187 let raw = fs::read_to_string(path).ok()?;
188 let parsed: Self = toml::from_str(&raw).ok()?;
189 let named = parsed.root?;
190 let named = named.trim();
191 if named.is_empty() {
192 return None;
193 }
194 let expanded = match named.strip_prefix("~/") {
195 Some(rest) => home()?.join(rest),
196 None => PathBuf::from(named),
197 };
198 expanded.is_dir().then_some(expanded)
199 }
200
201 fn path() -> Option<PathBuf> {
204 if let Some(named) = std::env::var_os("VISSUE_CONFIG").filter(|raw| !raw.is_empty()) {
205 return Some(PathBuf::from(named));
206 }
207 let base = match std::env::var_os("XDG_CONFIG_HOME") {
208 Some(dir) if !dir.is_empty() => PathBuf::from(dir),
209 _ => home()?.join(".config"),
210 };
211 Some(base.join("vissue").join("config.toml"))
212 }
213}
214
215fn home() -> Option<PathBuf> {
216 std::env::var_os("HOME")
217 .filter(|value| !value.is_empty())
218 .map(PathBuf::from)
219}
220
221#[derive(Debug, Clone, Default, Deserialize)]
223#[serde(default)]
224struct RootConfig {
225 prefix: Option<String>,
226 agent: Option<String>,
227 issues: IssuesOverride,
228 consensus: ConsensusOverride,
229}
230
231impl RootConfig {
232 fn load(root: &Path) -> Result<Self> {
233 let path = root.join("vissue.toml");
234 if !path.exists() {
235 return Ok(Self::default());
236 }
237 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
238 toml::from_str(&raw)
239 .with_context(|| format!("parse {}", path.display()))
240 .map_err(crate::error::Error::from)
241 }
242}
243
244#[derive(Debug, Clone, Deserialize)]
246#[serde(default)]
247pub struct IssuesSection {
248 pub default_priority: char,
250 pub id_length: usize,
252 pub stale_claim_days: i64,
255 pub expect_deeds: bool,
264}
265
266impl Default for IssuesSection {
267 fn default() -> Self {
268 Self {
269 default_priority: 'C',
270 id_length: 4,
271 stale_claim_days: 7,
272 expect_deeds: false,
273 }
274 }
275}
276
277#[derive(Debug, Clone, Default, Deserialize)]
281#[serde(default)]
282struct IssuesOverride {
283 default_priority: Option<char>,
284 id_length: Option<usize>,
285 stale_claim_days: Option<i64>,
286 expect_deeds: Option<bool>,
287}
288
289impl IssuesOverride {
290 fn apply_to(&self, base: &mut IssuesSection) {
291 if let Some(value) = self.default_priority {
292 base.default_priority = value;
293 }
294 if let Some(value) = self.id_length {
295 base.id_length = value;
296 }
297 if let Some(value) = self.stale_claim_days {
298 base.stale_claim_days = value;
299 }
300 if let Some(value) = self.expect_deeds {
301 base.expect_deeds = value;
302 }
303 }
304}
305
306#[derive(Debug, Clone, PartialEq)]
328pub struct ConsensusSection {
329 pub self_weight: f64,
331 pub susceptibility: f64,
344 pub tolerance: f64,
346 pub max_iterations: usize,
348 pub susceptibility_of: BTreeMap<String, f64>,
356 pub trust: BTreeMap<String, BTreeMap<String, f64>>,
358}
359
360impl Default for ConsensusSection {
361 fn default() -> Self {
362 Self {
363 self_weight: 0.5,
366 susceptibility: 1.0,
367 susceptibility_of: BTreeMap::new(),
368 tolerance: 1e-9,
369 max_iterations: 500,
370 trust: BTreeMap::new(),
371 }
372 }
373}
374
375#[derive(Debug, Clone, Default, Deserialize)]
377#[serde(default)]
378struct ConsensusOverride {
379 self_weight: Option<f64>,
380 susceptibility: Option<f64>,
381 #[serde(default)]
382 susceptibility_of: BTreeMap<String, f64>,
383 tolerance: Option<f64>,
384 max_iterations: Option<usize>,
385 trust: BTreeMap<String, BTreeMap<String, f64>>,
386}
387
388impl ConsensusOverride {
389 fn apply_to(&self, base: &mut ConsensusSection, whence: &Path) -> Result<()> {
395 if let Some(value) = self.self_weight {
396 if !(0.0..=1.0).contains(&value) {
397 return Err(anyhow::anyhow!(
398 "{}: consensus.self_weight is {value}, which is not a share between 0 and 1",
399 whence.display()
400 )
401 .into());
402 }
403 base.self_weight = value;
404 }
405 if let Some(value) = self.susceptibility {
406 if !(0.0..=1.0).contains(&value) {
407 return Err(anyhow::anyhow!(
408 "{}: consensus.susceptibility is {value}, which is not a share between 0 and 1",
409 whence.display()
410 )
411 .into());
412 }
413 base.susceptibility = value;
414 }
415 for (agent, value) in &self.susceptibility_of {
416 if !(0.0..=1.0).contains(value) {
417 return Err(anyhow::anyhow!(
418 "{}: consensus.susceptibility_of.{agent} is {value}, \
419 which is not a share between 0 and 1",
420 whence.display()
421 )
422 .into());
423 }
424 base.susceptibility_of.insert(agent.clone(), *value);
427 }
428 if let Some(value) = self.tolerance {
429 if !(value > 0.0 && value.is_finite()) {
430 return Err(anyhow::anyhow!(
431 "{}: consensus.tolerance is {value}, which is not a positive distance",
432 whence.display()
433 )
434 .into());
435 }
436 base.tolerance = value;
437 }
438 if let Some(value) = self.max_iterations {
439 if value == 0 {
440 return Err(anyhow::anyhow!(
441 "{}: consensus.max_iterations is 0, which runs no rounds at all",
442 whence.display()
443 )
444 .into());
445 }
446 base.max_iterations = value;
447 }
448 for (agent, row) in &self.trust {
449 for (other, weight) in row {
450 if !(*weight >= 0.0 && weight.is_finite()) {
451 return Err(anyhow::anyhow!(
452 "{}: consensus.trust.{agent}.{other} is {weight}, \
453 which is not a weight",
454 whence.display()
455 )
456 .into());
457 }
458 }
459 base.trust.insert(agent.clone(), row.clone());
463 }
464 Ok(())
465 }
466}
467
468#[derive(Debug, Clone, Default)]
470pub struct VissueConfig {
471 pub issues: IssuesSection,
473 pub consensus: ConsensusSection,
475}
476
477#[derive(Debug, Clone, Default, Deserialize)]
478#[serde(default)]
479struct PrefixConfigFile {
480 issues: IssuesOverride,
481 consensus: ConsensusOverride,
482}
483
484impl VissueConfig {
485 pub fn load(layout: &Layout) -> Result<Self> {
494 let mut issues = IssuesSection::default();
495 let mut consensus = ConsensusSection::default();
496 let root_path = layout.root().join("vissue.toml");
497 let root = RootConfig::load(layout.root())?;
498 root.issues.apply_to(&mut issues);
499 root.consensus.apply_to(&mut consensus, &root_path)?;
500 let path = layout.projects_dir().join("issues.config.toml");
501 if path.exists() {
502 let raw =
503 fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
504 let parsed: PrefixConfigFile =
505 toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
506 parsed.issues.apply_to(&mut issues);
507 parsed.consensus.apply_to(&mut consensus, &path)?;
508 }
509 Ok(Self { issues, consensus })
510 }
511}
512
513pub fn identity(layout: &Layout) -> String {
520 if let Ok(value) = crate::process_env::var("VISSUE_AGENT") {
521 let value = value.trim();
522 if !value.is_empty() {
523 return value.to_string();
524 }
525 }
526 if let Ok(cfg) = RootConfig::load(layout.root())
527 && let Some(agent) = cfg.agent
528 {
529 let agent = agent.trim().to_string();
530 if !agent.is_empty() {
531 return agent;
532 }
533 }
534 format!("{}@{}", current_user(), current_host())
535}
536
537fn current_user() -> String {
538 for var in ["USER", "LOGNAME", "USERNAME"] {
539 if let Ok(value) = std::env::var(var)
540 && !value.trim().is_empty()
541 {
542 return value.trim().to_string();
543 }
544 }
545 "unknown".to_string()
546}
547
548fn current_host() -> String {
549 if let Ok(value) = std::env::var("HOSTNAME")
550 && !value.trim().is_empty()
551 {
552 return value.trim().to_string();
553 }
554 for path in ["/etc/hostname", "/proc/sys/kernel/hostname"] {
557 if let Ok(text) = fs::read_to_string(path) {
558 let trimmed = text.trim();
559 if !trimmed.is_empty() {
560 return trimmed.to_string();
561 }
562 }
563 }
564 "unknown".to_string()
565}
566
567#[cfg(test)]
568#[allow(deprecated_safe_2024)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn layout_defaults_to_software_prefix() {
574 let layout = Layout::new("/somewhere", "");
575 assert_eq!(layout.prefix(), DEFAULT_PREFIX);
576 assert_eq!(
577 layout.project_issues_path("demo"),
578 Path::new("/somewhere/Software/demo/issues.org")
579 );
580 }
581
582 #[test]
583 fn explicit_prefix_wins() {
584 let dir = tempfile::tempdir().unwrap();
585 fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
586 let layout = Layout::resolve(Some(dir.path()), Some("tracker")).unwrap();
587 assert_eq!(layout.prefix(), "tracker");
588 }
589
590 #[test]
591 fn root_config_supplies_prefix() {
592 let dir = tempfile::tempdir().unwrap();
593 fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
594 let layout = Layout::resolve(Some(dir.path()), None).unwrap();
595 assert_eq!(layout.prefix(), "projects");
596 assert_eq!(
597 layout.projects_dir(),
598 dir.path().join("projects"),
599 "projects dir follows the configured prefix"
600 );
601 }
602
603 static AGENT_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
605
606 #[test]
607 fn the_environment_names_the_claiming_identity_first() {
608 let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
609 let dir = tempfile::tempdir().unwrap();
610 fs::write(dir.path().join("vissue.toml"), "agent = \"from-file\"\n").unwrap();
611 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
612
613 crate::process_env::override_var("VISSUE_AGENT", Some("from-env"));
614 let from_env = identity(&layout);
615 crate::process_env::override_var("VISSUE_AGENT", Some(" "));
616 let blank_falls_through = identity(&layout);
617 crate::process_env::override_var("VISSUE_AGENT", None);
618 let from_file = identity(&layout);
619 crate::process_env::clear_override("VISSUE_AGENT");
620
621 assert_eq!(from_env, "from-env");
622 assert_eq!(
623 blank_falls_through, "from-file",
624 "a blank value is not an identity"
625 );
626 assert_eq!(from_file, "from-file");
627 }
628
629 #[test]
630 fn without_configuration_the_identity_is_user_at_host() {
631 let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
632 let dir = tempfile::tempdir().unwrap();
633 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
634 crate::process_env::override_var("VISSUE_AGENT", None);
635 let resolved = identity(&layout);
636 crate::process_env::clear_override("VISSUE_AGENT");
637 assert!(resolved.contains('@'), "{resolved}");
638 assert!(!resolved.starts_with('@'), "{resolved}");
639 assert!(!resolved.ends_with('@'), "{resolved}");
640 }
641
642 #[test]
643 fn the_stale_claim_threshold_is_configurable() {
644 let dir = tempfile::tempdir().unwrap();
645 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
646 assert_eq!(
647 VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
648 7
649 );
650
651 fs::write(
652 dir.path().join("vissue.toml"),
653 "[issues]\nstale_claim_days = 3\n",
654 )
655 .unwrap();
656 assert_eq!(
657 VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
658 3
659 );
660 }
661
662 #[test]
666 fn a_consensus_weight_the_iteration_cannot_use_is_refused() {
667 for (body, wanted) in [
668 ("[consensus]\nself_weight = 2.0\n", "self_weight"),
669 ("[consensus]\nself_weight = -0.5\n", "self_weight"),
670 ("[consensus]\ntolerance = 0.0\n", "tolerance"),
671 ("[consensus]\ntolerance = -1.0\n", "tolerance"),
672 ("[consensus]\nmax_iterations = 0\n", "max_iterations"),
673 (
674 "[consensus.trust]\nalice = { bob = -1.0 }\n",
675 "consensus.trust.alice.bob",
676 ),
677 ] {
678 let dir = tempfile::tempdir().unwrap();
679 fs::write(dir.path().join("vissue.toml"), body).unwrap();
680 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
681 let err = VissueConfig::load(&layout).unwrap_err().to_string();
682 assert!(err.contains(wanted), "{body:?} -> {err}");
683 assert!(
684 err.contains("vissue.toml"),
685 "the message has to name the file: {err}"
686 );
687 }
688 }
689
690 #[test]
694 fn a_per_agent_susceptibility_is_checked_and_names_the_agent() {
695 let dir = tempfile::tempdir().unwrap();
696 fs::write(
697 dir.path().join("vissue.toml"),
698 "[consensus.susceptibility_of]\nmaintainer = 1.5\n",
699 )
700 .unwrap();
701 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
702 let err = VissueConfig::load(&layout).unwrap_err().to_string();
703 assert!(err.contains("maintainer"), "{err}");
704 assert!(err.contains("vissue.toml"), "{err}");
705 }
706
707 #[test]
710 fn a_susceptibility_row_overrides_only_the_agent_it_names() {
711 let dir = tempfile::tempdir().unwrap();
712 fs::write(
713 dir.path().join("vissue.toml"),
714 "[consensus.susceptibility_of]\nmaintainer = 0.2\nreviewer = 0.6\n",
715 )
716 .unwrap();
717 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
718 fs::create_dir_all(layout.projects_dir()).unwrap();
719 fs::write(
720 layout.projects_dir().join("issues.config.toml"),
721 "[consensus.susceptibility_of]\nmaintainer = 0.4\n",
722 )
723 .unwrap();
724
725 let cfg = VissueConfig::load(&layout).unwrap().consensus;
726 assert_eq!(cfg.susceptibility_of.get("maintainer"), Some(&0.4));
727 assert_eq!(
728 cfg.susceptibility_of.get("reviewer"),
729 Some(&0.6),
730 "a row the second file says nothing about survives"
731 );
732 }
733
734 #[test]
738 fn the_ends_of_the_self_weight_range_are_accepted() {
739 for value in ["0.0", "1.0"] {
740 let dir = tempfile::tempdir().unwrap();
741 fs::write(
742 dir.path().join("vissue.toml"),
743 format!("[consensus]\nself_weight = {value}\n"),
744 )
745 .unwrap();
746 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
747 let cfg = VissueConfig::load(&layout).expect(value);
748 assert_eq!(cfg.consensus.self_weight, value.parse::<f64>().unwrap());
749 }
750 }
751
752 #[test]
755 fn a_trust_row_overrides_only_the_agent_it_names() {
756 let dir = tempfile::tempdir().unwrap();
757 fs::write(
758 dir.path().join("vissue.toml"),
759 "[consensus.trust]\nalice = { bob = 1.0 }\ncarol = { alice = 1.0 }\n",
760 )
761 .unwrap();
762 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
763 fs::create_dir_all(layout.projects_dir()).unwrap();
764 fs::write(
765 layout.projects_dir().join("issues.config.toml"),
766 "[consensus.trust]\nalice = { carol = 4.0 }\n",
767 )
768 .unwrap();
769
770 let cfg = VissueConfig::load(&layout).unwrap();
771 assert_eq!(
772 cfg.consensus
773 .trust
774 .get("alice")
775 .and_then(|r| r.get("carol")),
776 Some(&4.0),
777 "the named row is replaced whole"
778 );
779 assert!(
780 cfg.consensus
781 .trust
782 .get("alice")
783 .is_some_and(|r| !r.contains_key("bob")),
784 "replaced, not merged into: {:?}",
785 cfg.consensus.trust
786 );
787 assert_eq!(
788 cfg.consensus
789 .trust
790 .get("carol")
791 .and_then(|r| r.get("alice")),
792 Some(&1.0),
793 "a row the second file says nothing about survives"
794 );
795 }
796
797 #[test]
799 fn the_consensus_defaults_converge_on_their_own() {
800 let dir = tempfile::tempdir().unwrap();
801 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
802 let cfg = VissueConfig::load(&layout).unwrap().consensus;
803 assert!(cfg.trust.is_empty());
804 assert!(
805 cfg.self_weight > 0.0,
806 "a zero diagonal is what makes a trust graph periodic"
807 );
808 assert!(cfg.tolerance > 0.0 && cfg.max_iterations > 0);
809 }
810
811 #[test]
812 fn config_defaults_when_no_files_present() {
813 let dir = tempfile::tempdir().unwrap();
814 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
815 let cfg = VissueConfig::load(&layout).unwrap();
816 assert_eq!(cfg.issues.default_priority, 'C');
817 assert_eq!(cfg.issues.id_length, 4);
818 }
819
820 #[test]
821 fn prefix_scoped_config_overrides_root_config() {
822 let dir = tempfile::tempdir().unwrap();
823 fs::write(
824 dir.path().join("vissue.toml"),
825 "[issues]\ndefault_priority = \"B\"\nid_length = 5\n",
826 )
827 .unwrap();
828 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
829 let cfg = VissueConfig::load(&layout).unwrap();
830 assert_eq!(cfg.issues.default_priority, 'B');
831 assert_eq!(cfg.issues.id_length, 5);
832
833 fs::create_dir_all(layout.projects_dir()).unwrap();
834 fs::write(
835 layout.projects_dir().join("issues.config.toml"),
836 "[issues]\ndefault_priority = \"A\"\nid_length = 6\n",
837 )
838 .unwrap();
839 let cfg = VissueConfig::load(&layout).unwrap();
840 assert_eq!(cfg.issues.default_priority, 'A');
841 assert_eq!(cfg.issues.id_length, 6);
842 }
843
844 #[test]
845 fn a_partial_override_keeps_the_keys_it_does_not_name() {
846 let dir = tempfile::tempdir().unwrap();
847 fs::write(
848 dir.path().join("vissue.toml"),
849 "[issues]\ndefault_priority = \"B\"\nid_length = 5\nstale_claim_days = 3\n",
850 )
851 .unwrap();
852 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
853 fs::create_dir_all(layout.projects_dir()).unwrap();
854 fs::write(
855 layout.projects_dir().join("issues.config.toml"),
856 "[issues]\nid_length = 6\n",
857 )
858 .unwrap();
859
860 let cfg = VissueConfig::load(&layout).unwrap();
861 assert_eq!(cfg.issues.id_length, 6, "the named key is overridden");
862 assert_eq!(
863 cfg.issues.default_priority, 'B',
864 "an unnamed key keeps the root value"
865 );
866 assert_eq!(cfg.issues.stale_claim_days, 3);
867 }
868
869 #[test]
871 fn a_seat_file_names_a_tracker() {
872 let dir = tempfile::tempdir().unwrap();
873 let tracker = tempfile::tempdir().unwrap();
874 let path = dir.path().join("config.toml");
875 fs::write(
876 &path,
877 format!("root = {:?}\n", tracker.path().display().to_string()),
878 )
879 .unwrap();
880 assert_eq!(
881 SeatConfig::read(&path).unwrap().canonicalize().unwrap(),
882 tracker.path().canonicalize().unwrap()
883 );
884 }
885
886 #[test]
890 fn a_seat_file_that_says_nothing_usable_says_nothing() {
891 let dir = tempfile::tempdir().unwrap();
892 assert!(SeatConfig::read(&dir.path().join("absent.toml")).is_none());
893 for text in [
894 "",
895 "root = \"\"\n",
896 "root = \"/nonexistent/tracker\"\n",
897 "root =",
898 ] {
899 let path = dir.path().join("config.toml");
900 fs::write(&path, text).unwrap();
901 assert!(SeatConfig::read(&path).is_none(), "{text:?}");
902 }
903 }
904
905 #[test]
909 fn the_seat_root_shares_the_file_the_router_reads() {
910 let dir = tempfile::tempdir().unwrap();
911 let tracker = tempfile::tempdir().unwrap();
912 let path = dir.path().join("config.toml");
913 fs::write(
914 &path,
915 format!(
916 "root = {:?}\n\n[layouts.other]\nroot = \"/somewhere\"\nprefix = \"Issues\"\n\n[routes]\nthing = \"other\"\n",
917 tracker.path().display().to_string()
918 ),
919 )
920 .unwrap();
921 assert_eq!(
922 SeatConfig::read(&path).unwrap().canonicalize().unwrap(),
923 tracker.path().canonicalize().unwrap()
924 );
925 crate::router::Router::from_file(Layout::new(dir.path(), DEFAULT_PREFIX), &path)
928 .expect("the router reads the same file");
929 }
930
931 #[test]
933 fn the_caller_beats_the_environment_beats_where_you_stand() {
934 let named = PathBuf::from("/named");
935 let from_env = PathBuf::from("/env");
936 let seat = PathBuf::from("/seat");
937 let here = PathBuf::from("/here");
938
939 assert_eq!(
941 choose_root(
942 Some(&named),
943 Some(from_env.clone()),
944 &here,
945 false,
946 Some(seat.clone())
947 ),
948 (named.clone(), false)
949 );
950 assert_eq!(
952 choose_root(
953 None,
954 Some(from_env.clone()),
955 &here,
956 true,
957 Some(seat.clone())
958 ),
959 (from_env, false)
960 );
961 assert_eq!(
964 choose_root(None, None, &here, true, Some(seat.clone())),
965 (here.clone(), false)
966 );
967 assert_eq!(
969 choose_root(None, None, &here, false, Some(seat.clone())),
970 (seat, false)
971 );
972 assert_eq!(choose_root(None, None, &here, false, None), (here, true));
975 }
976}