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