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
193#[derive(Debug, Clone, Default, Deserialize)]
195#[serde(default)]
196struct RootConfig {
197 prefix: Option<String>,
198 agent: Option<String>,
199 issues: IssuesOverride,
200 consensus: ConsensusOverride,
201}
202
203impl RootConfig {
204 fn load(root: &Path) -> Result<Self> {
205 let path = root.join("vissue.toml");
206 if !path.exists() {
207 return Ok(Self::default());
208 }
209 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
210 toml::from_str(&raw)
211 .with_context(|| format!("parse {}", path.display()))
212 .map_err(crate::error::Error::from)
213 }
214}
215
216#[derive(Debug, Clone, Deserialize)]
218#[serde(default)]
219pub struct IssuesSection {
220 pub default_priority: char,
222 pub id_length: usize,
224 pub stale_claim_days: i64,
227 pub expect_deeds: bool,
229}
230
231impl Default for IssuesSection {
232 fn default() -> Self {
233 Self {
234 default_priority: 'C',
235 id_length: 4,
236 stale_claim_days: 7,
237 expect_deeds: false,
238 }
239 }
240}
241
242#[derive(Debug, Clone, Default, Deserialize)]
246#[serde(default)]
247struct IssuesOverride {
248 default_priority: Option<char>,
249 id_length: Option<usize>,
250 stale_claim_days: Option<i64>,
251 expect_deeds: Option<bool>,
252}
253
254impl IssuesOverride {
255 fn apply_to(&self, base: &mut IssuesSection) {
256 if let Some(value) = self.default_priority {
257 base.default_priority = value;
258 }
259 if let Some(value) = self.id_length {
260 base.id_length = value;
261 }
262 if let Some(value) = self.stale_claim_days {
263 base.stale_claim_days = value;
264 }
265 if let Some(value) = self.expect_deeds {
266 base.expect_deeds = value;
267 }
268 }
269}
270
271#[derive(Debug, Clone, PartialEq)]
288pub struct ConsensusSection {
289 pub self_weight: f64,
291 pub susceptibility: f64,
294 pub tolerance: f64,
296 pub max_iterations: usize,
298 pub susceptibility_of: BTreeMap<String, f64>,
300 pub trust: BTreeMap<String, BTreeMap<String, f64>>,
302}
303
304impl Default for ConsensusSection {
305 fn default() -> Self {
306 Self {
307 self_weight: 0.5,
310 susceptibility: 1.0,
311 susceptibility_of: BTreeMap::new(),
312 tolerance: 1e-9,
313 max_iterations: 500,
314 trust: BTreeMap::new(),
315 }
316 }
317}
318
319#[derive(Debug, Clone, Default, Deserialize)]
321#[serde(default)]
322struct ConsensusOverride {
323 self_weight: Option<f64>,
324 susceptibility: Option<f64>,
325 #[serde(default)]
326 susceptibility_of: BTreeMap<String, f64>,
327 tolerance: Option<f64>,
328 max_iterations: Option<usize>,
329 trust: BTreeMap<String, BTreeMap<String, f64>>,
330}
331
332impl ConsensusOverride {
333 fn apply_to(&self, base: &mut ConsensusSection, whence: &Path) -> Result<()> {
336 if let Some(value) = self.self_weight {
337 if !(0.0..=1.0).contains(&value) {
338 return Err(anyhow::anyhow!(
339 "{}: consensus.self_weight is {value}, which is not a share between 0 and 1",
340 whence.display()
341 )
342 .into());
343 }
344 base.self_weight = value;
345 }
346 if let Some(value) = self.susceptibility {
347 if !(0.0..=1.0).contains(&value) {
348 return Err(anyhow::anyhow!(
349 "{}: consensus.susceptibility is {value}, which is not a share between 0 and 1",
350 whence.display()
351 )
352 .into());
353 }
354 base.susceptibility = value;
355 }
356 for (agent, value) in &self.susceptibility_of {
357 if !(0.0..=1.0).contains(value) {
358 return Err(anyhow::anyhow!(
359 "{}: consensus.susceptibility_of.{agent} is {value}, \
360 which is not a share between 0 and 1",
361 whence.display()
362 )
363 .into());
364 }
365 base.susceptibility_of.insert(agent.clone(), *value);
368 }
369 if let Some(value) = self.tolerance {
370 if !(value > 0.0 && value.is_finite()) {
371 return Err(anyhow::anyhow!(
372 "{}: consensus.tolerance is {value}, which is not a positive distance",
373 whence.display()
374 )
375 .into());
376 }
377 base.tolerance = value;
378 }
379 if let Some(value) = self.max_iterations {
380 if value == 0 {
381 return Err(anyhow::anyhow!(
382 "{}: consensus.max_iterations is 0, which runs no rounds at all",
383 whence.display()
384 )
385 .into());
386 }
387 base.max_iterations = value;
388 }
389 for (agent, row) in &self.trust {
390 for (other, weight) in row {
391 if !(*weight >= 0.0 && weight.is_finite()) {
392 return Err(anyhow::anyhow!(
393 "{}: consensus.trust.{agent}.{other} is {weight}, \
394 which is not a weight",
395 whence.display()
396 )
397 .into());
398 }
399 }
400 base.trust.insert(agent.clone(), row.clone());
404 }
405 Ok(())
406 }
407}
408
409#[derive(Debug, Clone, Default)]
411pub struct VissueConfig {
412 pub issues: IssuesSection,
414 pub consensus: ConsensusSection,
416}
417
418#[derive(Debug, Clone, Default, Deserialize)]
419#[serde(default)]
420struct PrefixConfigFile {
421 issues: IssuesOverride,
422 consensus: ConsensusOverride,
423}
424
425impl VissueConfig {
426 pub fn load(layout: &Layout) -> Result<Self> {
435 let mut issues = IssuesSection::default();
436 let mut consensus = ConsensusSection::default();
437 let root_path = layout.root().join("vissue.toml");
438 let root = RootConfig::load(layout.root())?;
439 root.issues.apply_to(&mut issues);
440 root.consensus.apply_to(&mut consensus, &root_path)?;
441 let path = layout.projects_dir().join("issues.config.toml");
442 if path.exists() {
443 let raw =
444 fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
445 let parsed: PrefixConfigFile =
446 toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
447 parsed.issues.apply_to(&mut issues);
448 parsed.consensus.apply_to(&mut consensus, &path)?;
449 }
450 Ok(Self { issues, consensus })
451 }
452}
453
454pub fn identity(layout: &Layout) -> String {
461 if let Ok(value) = crate::process_env::var("VISSUE_AGENT") {
462 let value = value.trim();
463 if !value.is_empty() {
464 return value.to_string();
465 }
466 }
467 if let Ok(cfg) = RootConfig::load(layout.root())
468 && let Some(agent) = cfg.agent
469 {
470 let agent = agent.trim().to_string();
471 if !agent.is_empty() {
472 return agent;
473 }
474 }
475 format!("{}@{}", current_user(), current_host())
476}
477
478fn current_user() -> String {
479 for var in ["USER", "LOGNAME", "USERNAME"] {
480 if let Ok(value) = std::env::var(var)
481 && !value.trim().is_empty()
482 {
483 return value.trim().to_string();
484 }
485 }
486 "unknown".to_string()
487}
488
489fn current_host() -> String {
490 if let Ok(value) = std::env::var("HOSTNAME")
491 && !value.trim().is_empty()
492 {
493 return value.trim().to_string();
494 }
495 for path in ["/etc/hostname", "/proc/sys/kernel/hostname"] {
498 if let Ok(text) = fs::read_to_string(path) {
499 let trimmed = text.trim();
500 if !trimmed.is_empty() {
501 return trimmed.to_string();
502 }
503 }
504 }
505 "unknown".to_string()
506}
507
508#[cfg(test)]
509#[allow(deprecated_safe_2024)]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn layout_defaults_to_software_prefix() {
515 let layout = Layout::new("/somewhere", "");
516 assert_eq!(layout.prefix(), DEFAULT_PREFIX);
517 assert_eq!(
518 layout.project_issues_path("demo"),
519 Path::new("/somewhere/Software/demo/issues.org")
520 );
521 }
522
523 #[test]
524 fn explicit_prefix_wins() {
525 let dir = tempfile::tempdir().unwrap();
526 fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
527 let layout = Layout::resolve(Some(dir.path()), Some("tracker")).unwrap();
528 assert_eq!(layout.prefix(), "tracker");
529 }
530
531 #[test]
532 fn root_config_supplies_prefix() {
533 let dir = tempfile::tempdir().unwrap();
534 fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
535 let layout = Layout::resolve(Some(dir.path()), None).unwrap();
536 assert_eq!(layout.prefix(), "projects");
537 assert_eq!(
538 layout.projects_dir(),
539 dir.path().join("projects"),
540 "projects dir follows the configured prefix"
541 );
542 }
543
544 static AGENT_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
546
547 #[test]
548 fn the_environment_names_the_claiming_identity_first() {
549 let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
550 let dir = tempfile::tempdir().unwrap();
551 fs::write(dir.path().join("vissue.toml"), "agent = \"from-file\"\n").unwrap();
552 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
553
554 crate::process_env::override_var("VISSUE_AGENT", Some("from-env"));
555 let from_env = identity(&layout);
556 crate::process_env::override_var("VISSUE_AGENT", Some(" "));
557 let blank_falls_through = identity(&layout);
558 crate::process_env::override_var("VISSUE_AGENT", None);
559 let from_file = identity(&layout);
560 crate::process_env::clear_override("VISSUE_AGENT");
561
562 assert_eq!(from_env, "from-env");
563 assert_eq!(
564 blank_falls_through, "from-file",
565 "a blank value is not an identity"
566 );
567 assert_eq!(from_file, "from-file");
568 }
569
570 #[test]
571 fn without_configuration_the_identity_is_user_at_host() {
572 let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
573 let dir = tempfile::tempdir().unwrap();
574 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
575 crate::process_env::override_var("VISSUE_AGENT", None);
576 let resolved = identity(&layout);
577 crate::process_env::clear_override("VISSUE_AGENT");
578 assert!(resolved.contains('@'), "{resolved}");
579 assert!(!resolved.starts_with('@'), "{resolved}");
580 assert!(!resolved.ends_with('@'), "{resolved}");
581 }
582
583 #[test]
584 fn the_stale_claim_threshold_is_configurable() {
585 let dir = tempfile::tempdir().unwrap();
586 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
587 assert_eq!(
588 VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
589 7
590 );
591
592 fs::write(
593 dir.path().join("vissue.toml"),
594 "[issues]\nstale_claim_days = 3\n",
595 )
596 .unwrap();
597 assert_eq!(
598 VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
599 3
600 );
601 }
602
603 #[test]
607 fn a_consensus_weight_the_iteration_cannot_use_is_refused() {
608 for (body, wanted) in [
609 ("[consensus]\nself_weight = 2.0\n", "self_weight"),
610 ("[consensus]\nself_weight = -0.5\n", "self_weight"),
611 ("[consensus]\ntolerance = 0.0\n", "tolerance"),
612 ("[consensus]\ntolerance = -1.0\n", "tolerance"),
613 ("[consensus]\nmax_iterations = 0\n", "max_iterations"),
614 (
615 "[consensus.trust]\nalice = { bob = -1.0 }\n",
616 "consensus.trust.alice.bob",
617 ),
618 ] {
619 let dir = tempfile::tempdir().unwrap();
620 fs::write(dir.path().join("vissue.toml"), body).unwrap();
621 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
622 let err = VissueConfig::load(&layout).unwrap_err().to_string();
623 assert!(err.contains(wanted), "{body:?} -> {err}");
624 assert!(
625 err.contains("vissue.toml"),
626 "the message has to name the file: {err}"
627 );
628 }
629 }
630
631 #[test]
635 fn a_per_agent_susceptibility_is_checked_and_names_the_agent() {
636 let dir = tempfile::tempdir().unwrap();
637 fs::write(
638 dir.path().join("vissue.toml"),
639 "[consensus.susceptibility_of]\nmaintainer = 1.5\n",
640 )
641 .unwrap();
642 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
643 let err = VissueConfig::load(&layout).unwrap_err().to_string();
644 assert!(err.contains("maintainer"), "{err}");
645 assert!(err.contains("vissue.toml"), "{err}");
646 }
647
648 #[test]
651 fn a_susceptibility_row_overrides_only_the_agent_it_names() {
652 let dir = tempfile::tempdir().unwrap();
653 fs::write(
654 dir.path().join("vissue.toml"),
655 "[consensus.susceptibility_of]\nmaintainer = 0.2\nreviewer = 0.6\n",
656 )
657 .unwrap();
658 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
659 fs::create_dir_all(layout.projects_dir()).unwrap();
660 fs::write(
661 layout.projects_dir().join("issues.config.toml"),
662 "[consensus.susceptibility_of]\nmaintainer = 0.4\n",
663 )
664 .unwrap();
665
666 let cfg = VissueConfig::load(&layout).unwrap().consensus;
667 assert_eq!(cfg.susceptibility_of.get("maintainer"), Some(&0.4));
668 assert_eq!(
669 cfg.susceptibility_of.get("reviewer"),
670 Some(&0.6),
671 "a row the second file says nothing about survives"
672 );
673 }
674
675 #[test]
679 fn the_ends_of_the_self_weight_range_are_accepted() {
680 for value in ["0.0", "1.0"] {
681 let dir = tempfile::tempdir().unwrap();
682 fs::write(
683 dir.path().join("vissue.toml"),
684 format!("[consensus]\nself_weight = {value}\n"),
685 )
686 .unwrap();
687 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
688 let cfg = VissueConfig::load(&layout).expect(value);
689 assert_eq!(cfg.consensus.self_weight, value.parse::<f64>().unwrap());
690 }
691 }
692
693 #[test]
696 fn a_trust_row_overrides_only_the_agent_it_names() {
697 let dir = tempfile::tempdir().unwrap();
698 fs::write(
699 dir.path().join("vissue.toml"),
700 "[consensus.trust]\nalice = { bob = 1.0 }\ncarol = { alice = 1.0 }\n",
701 )
702 .unwrap();
703 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
704 fs::create_dir_all(layout.projects_dir()).unwrap();
705 fs::write(
706 layout.projects_dir().join("issues.config.toml"),
707 "[consensus.trust]\nalice = { carol = 4.0 }\n",
708 )
709 .unwrap();
710
711 let cfg = VissueConfig::load(&layout).unwrap();
712 assert_eq!(
713 cfg.consensus
714 .trust
715 .get("alice")
716 .and_then(|r| r.get("carol")),
717 Some(&4.0),
718 "the named row is replaced whole"
719 );
720 assert!(
721 cfg.consensus
722 .trust
723 .get("alice")
724 .is_some_and(|r| !r.contains_key("bob")),
725 "replaced, not merged into: {:?}",
726 cfg.consensus.trust
727 );
728 assert_eq!(
729 cfg.consensus
730 .trust
731 .get("carol")
732 .and_then(|r| r.get("alice")),
733 Some(&1.0),
734 "a row the second file says nothing about survives"
735 );
736 }
737
738 #[test]
740 fn the_consensus_defaults_converge_on_their_own() {
741 let dir = tempfile::tempdir().unwrap();
742 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
743 let cfg = VissueConfig::load(&layout).unwrap().consensus;
744 assert!(cfg.trust.is_empty());
745 assert!(
746 cfg.self_weight > 0.0,
747 "a zero diagonal is what makes a trust graph periodic"
748 );
749 assert!(cfg.tolerance > 0.0 && cfg.max_iterations > 0);
750 }
751
752 #[test]
753 fn config_defaults_when_no_files_present() {
754 let dir = tempfile::tempdir().unwrap();
755 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
756 let cfg = VissueConfig::load(&layout).unwrap();
757 assert_eq!(cfg.issues.default_priority, 'C');
758 assert_eq!(cfg.issues.id_length, 4);
759 }
760
761 #[test]
762 fn prefix_scoped_config_overrides_root_config() {
763 let dir = tempfile::tempdir().unwrap();
764 fs::write(
765 dir.path().join("vissue.toml"),
766 "[issues]\ndefault_priority = \"B\"\nid_length = 5\n",
767 )
768 .unwrap();
769 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
770 let cfg = VissueConfig::load(&layout).unwrap();
771 assert_eq!(cfg.issues.default_priority, 'B');
772 assert_eq!(cfg.issues.id_length, 5);
773
774 fs::create_dir_all(layout.projects_dir()).unwrap();
775 fs::write(
776 layout.projects_dir().join("issues.config.toml"),
777 "[issues]\ndefault_priority = \"A\"\nid_length = 6\n",
778 )
779 .unwrap();
780 let cfg = VissueConfig::load(&layout).unwrap();
781 assert_eq!(cfg.issues.default_priority, 'A');
782 assert_eq!(cfg.issues.id_length, 6);
783 }
784
785 #[test]
786 fn a_partial_override_keeps_the_keys_it_does_not_name() {
787 let dir = tempfile::tempdir().unwrap();
788 fs::write(
789 dir.path().join("vissue.toml"),
790 "[issues]\ndefault_priority = \"B\"\nid_length = 5\nstale_claim_days = 3\n",
791 )
792 .unwrap();
793 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
794 fs::create_dir_all(layout.projects_dir()).unwrap();
795 fs::write(
796 layout.projects_dir().join("issues.config.toml"),
797 "[issues]\nid_length = 6\n",
798 )
799 .unwrap();
800
801 let cfg = VissueConfig::load(&layout).unwrap();
802 assert_eq!(cfg.issues.id_length, 6, "the named key is overridden");
803 assert_eq!(
804 cfg.issues.default_priority, 'B',
805 "an unnamed key keeps the root value"
806 );
807 assert_eq!(cfg.issues.stale_claim_days, 3);
808 }
809
810 #[test]
812 fn a_seat_file_names_a_tracker() {
813 let dir = tempfile::tempdir().unwrap();
814 let tracker = tempfile::tempdir().unwrap();
815 let path = dir.path().join("config.toml");
816 fs::write(
817 &path,
818 format!("root = {:?}\n", tracker.path().display().to_string()),
819 )
820 .unwrap();
821 assert_eq!(
822 SeatConfig::read(&path).unwrap().canonicalize().unwrap(),
823 tracker.path().canonicalize().unwrap()
824 );
825 }
826
827 #[test]
831 fn a_seat_file_that_says_nothing_usable_says_nothing() {
832 let dir = tempfile::tempdir().unwrap();
833 assert!(SeatConfig::read(&dir.path().join("absent.toml")).is_none());
834 for text in [
835 "",
836 "root = \"\"\n",
837 "root = \"/nonexistent/tracker\"\n",
838 "root =",
839 ] {
840 let path = dir.path().join("config.toml");
841 fs::write(&path, text).unwrap();
842 assert!(SeatConfig::read(&path).is_none(), "{text:?}");
843 }
844 }
845
846 #[test]
850 fn the_seat_root_shares_the_file_the_router_reads() {
851 let dir = tempfile::tempdir().unwrap();
852 let tracker = tempfile::tempdir().unwrap();
853 let path = dir.path().join("config.toml");
854 fs::write(
855 &path,
856 format!(
857 "root = {:?}\n\n[layouts.other]\nroot = \"/somewhere\"\nprefix = \"Issues\"\n\n[routes]\nthing = \"other\"\n",
858 tracker.path().display().to_string()
859 ),
860 )
861 .unwrap();
862 assert_eq!(
863 SeatConfig::read(&path).unwrap().canonicalize().unwrap(),
864 tracker.path().canonicalize().unwrap()
865 );
866 crate::router::Router::from_file(Layout::new(dir.path(), DEFAULT_PREFIX), &path)
869 .expect("the router reads the same file");
870 }
871
872 #[test]
874 fn the_caller_beats_the_environment_beats_where_you_stand() {
875 let named = PathBuf::from("/named");
876 let from_env = PathBuf::from("/env");
877 let seat = PathBuf::from("/seat");
878 let here = PathBuf::from("/here");
879
880 assert_eq!(
882 choose_root(
883 Some(&named),
884 Some(from_env.clone()),
885 &here,
886 false,
887 Some(seat.clone())
888 ),
889 (named.clone(), false)
890 );
891 assert_eq!(
893 choose_root(
894 None,
895 Some(from_env.clone()),
896 &here,
897 true,
898 Some(seat.clone())
899 ),
900 (from_env, false)
901 );
902 assert_eq!(
905 choose_root(None, None, &here, true, Some(seat.clone())),
906 (here.clone(), false)
907 );
908 assert_eq!(
910 choose_root(None, None, &here, false, Some(seat.clone())),
911 (seat, false)
912 );
913 assert_eq!(choose_root(None, None, &here, false, None), (here, true));
916 }
917}