1use anyhow::Context;
9
10use crate::error::Result;
11use serde::Deserialize;
12use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16pub const DEFAULT_PREFIX: &str = "Software";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Layout {
22 root: PathBuf,
23 prefix: String,
24 guessed: bool,
28}
29
30impl Layout {
31 pub fn new(root: impl Into<PathBuf>, prefix: impl Into<String>) -> Self {
35 let prefix = prefix.into();
36 Self {
37 root: root.into(),
38 prefix: if prefix.is_empty() {
39 DEFAULT_PREFIX.to_string()
40 } else {
41 prefix
42 },
43 guessed: false,
44 }
45 }
46
47 pub fn resolve(root: Option<&Path>, prefix: Option<&str>) -> Result<Self> {
55 let mut guessed = false;
56 let root = match root {
57 Some(p) => p.to_path_buf(),
58 None => {
59 match std::env::var_os("ISSUE_ROOT").or_else(|| std::env::var_os("VISSUE_ROOT")) {
60 Some(v) => PathBuf::from(v),
61 None => {
62 guessed = true;
63 std::env::current_dir().context("resolve current directory as root")?
64 }
65 }
66 }
67 };
68 let prefix = match prefix {
69 Some(p) if !p.is_empty() => p.to_string(),
70 _ => match std::env::var("VISSUE_PREFIX") {
71 Ok(v) if !v.is_empty() => v,
72 _ => RootConfig::load(&root)?
73 .prefix
74 .unwrap_or_else(|| DEFAULT_PREFIX.to_string()),
75 },
76 };
77 let mut layout = Self::new(root, prefix);
78 layout.guessed = guessed;
79 Ok(layout)
80 }
81
82 pub fn require_tracker(&self) -> Result<()> {
94 if !self.guessed || self.root.join("vissue.toml").is_file() || self.projects_dir().is_dir()
95 {
96 return Ok(());
97 }
98 Err(crate::error::Error::NotATracker {
99 root: self.root.clone(),
100 prefix: self.prefix.clone(),
101 })
102 }
103
104 pub fn root(&self) -> &Path {
106 &self.root
107 }
108
109 pub fn prefix(&self) -> &str {
111 &self.prefix
112 }
113
114 pub fn projects_dir(&self) -> PathBuf {
116 self.root.join(&self.prefix)
117 }
118
119 pub fn project_issues_path(&self, project: &str) -> PathBuf {
121 self.projects_dir().join(project).join("issues.org")
122 }
123}
124
125#[derive(Debug, Clone, Default, Deserialize)]
127#[serde(default)]
128struct RootConfig {
129 prefix: Option<String>,
130 agent: Option<String>,
131 issues: IssuesOverride,
132 consensus: ConsensusOverride,
133}
134
135impl RootConfig {
136 fn load(root: &Path) -> Result<Self> {
137 let path = root.join("vissue.toml");
138 if !path.exists() {
139 return Ok(Self::default());
140 }
141 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
142 toml::from_str(&raw)
143 .with_context(|| format!("parse {}", path.display()))
144 .map_err(crate::error::Error::from)
145 }
146}
147
148#[derive(Debug, Clone, Deserialize)]
150#[serde(default)]
151pub struct IssuesSection {
152 pub default_priority: char,
154 pub id_length: usize,
156 pub stale_claim_days: i64,
159 pub expect_deeds: bool,
168}
169
170impl Default for IssuesSection {
171 fn default() -> Self {
172 Self {
173 default_priority: 'C',
174 id_length: 4,
175 stale_claim_days: 7,
176 expect_deeds: false,
177 }
178 }
179}
180
181#[derive(Debug, Clone, Default, Deserialize)]
185#[serde(default)]
186struct IssuesOverride {
187 default_priority: Option<char>,
188 id_length: Option<usize>,
189 stale_claim_days: Option<i64>,
190 expect_deeds: Option<bool>,
191}
192
193impl IssuesOverride {
194 fn apply_to(&self, base: &mut IssuesSection) {
195 if let Some(value) = self.default_priority {
196 base.default_priority = value;
197 }
198 if let Some(value) = self.id_length {
199 base.id_length = value;
200 }
201 if let Some(value) = self.stale_claim_days {
202 base.stale_claim_days = value;
203 }
204 if let Some(value) = self.expect_deeds {
205 base.expect_deeds = value;
206 }
207 }
208}
209
210#[derive(Debug, Clone, PartialEq)]
232pub struct ConsensusSection {
233 pub self_weight: f64,
235 pub susceptibility: f64,
248 pub tolerance: f64,
250 pub max_iterations: usize,
252 pub susceptibility_of: BTreeMap<String, f64>,
260 pub trust: BTreeMap<String, BTreeMap<String, f64>>,
262}
263
264impl Default for ConsensusSection {
265 fn default() -> Self {
266 Self {
267 self_weight: 0.5,
270 susceptibility: 1.0,
271 susceptibility_of: BTreeMap::new(),
272 tolerance: 1e-9,
273 max_iterations: 500,
274 trust: BTreeMap::new(),
275 }
276 }
277}
278
279#[derive(Debug, Clone, Default, Deserialize)]
281#[serde(default)]
282struct ConsensusOverride {
283 self_weight: Option<f64>,
284 susceptibility: Option<f64>,
285 #[serde(default)]
286 susceptibility_of: BTreeMap<String, f64>,
287 tolerance: Option<f64>,
288 max_iterations: Option<usize>,
289 trust: BTreeMap<String, BTreeMap<String, f64>>,
290}
291
292impl ConsensusOverride {
293 fn apply_to(&self, base: &mut ConsensusSection, whence: &Path) -> Result<()> {
299 if let Some(value) = self.self_weight {
300 if !(0.0..=1.0).contains(&value) {
301 return Err(anyhow::anyhow!(
302 "{}: consensus.self_weight is {value}, which is not a share between 0 and 1",
303 whence.display()
304 )
305 .into());
306 }
307 base.self_weight = value;
308 }
309 if let Some(value) = self.susceptibility {
310 if !(0.0..=1.0).contains(&value) {
311 return Err(anyhow::anyhow!(
312 "{}: consensus.susceptibility is {value}, which is not a share between 0 and 1",
313 whence.display()
314 )
315 .into());
316 }
317 base.susceptibility = value;
318 }
319 for (agent, value) in &self.susceptibility_of {
320 if !(0.0..=1.0).contains(value) {
321 return Err(anyhow::anyhow!(
322 "{}: consensus.susceptibility_of.{agent} is {value}, \
323 which is not a share between 0 and 1",
324 whence.display()
325 )
326 .into());
327 }
328 base.susceptibility_of.insert(agent.clone(), *value);
331 }
332 if let Some(value) = self.tolerance {
333 if !(value > 0.0 && value.is_finite()) {
334 return Err(anyhow::anyhow!(
335 "{}: consensus.tolerance is {value}, which is not a positive distance",
336 whence.display()
337 )
338 .into());
339 }
340 base.tolerance = value;
341 }
342 if let Some(value) = self.max_iterations {
343 if value == 0 {
344 return Err(anyhow::anyhow!(
345 "{}: consensus.max_iterations is 0, which runs no rounds at all",
346 whence.display()
347 )
348 .into());
349 }
350 base.max_iterations = value;
351 }
352 for (agent, row) in &self.trust {
353 for (other, weight) in row {
354 if !(*weight >= 0.0 && weight.is_finite()) {
355 return Err(anyhow::anyhow!(
356 "{}: consensus.trust.{agent}.{other} is {weight}, \
357 which is not a weight",
358 whence.display()
359 )
360 .into());
361 }
362 }
363 base.trust.insert(agent.clone(), row.clone());
367 }
368 Ok(())
369 }
370}
371
372#[derive(Debug, Clone, Default)]
374pub struct VissueConfig {
375 pub issues: IssuesSection,
377 pub consensus: ConsensusSection,
379}
380
381#[derive(Debug, Clone, Default, Deserialize)]
382#[serde(default)]
383struct PrefixConfigFile {
384 issues: IssuesOverride,
385 consensus: ConsensusOverride,
386}
387
388impl VissueConfig {
389 pub fn load(layout: &Layout) -> Result<Self> {
398 let mut issues = IssuesSection::default();
399 let mut consensus = ConsensusSection::default();
400 let root_path = layout.root().join("vissue.toml");
401 let root = RootConfig::load(layout.root())?;
402 root.issues.apply_to(&mut issues);
403 root.consensus.apply_to(&mut consensus, &root_path)?;
404 let path = layout.projects_dir().join("issues.config.toml");
405 if path.exists() {
406 let raw =
407 fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
408 let parsed: PrefixConfigFile =
409 toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
410 parsed.issues.apply_to(&mut issues);
411 parsed.consensus.apply_to(&mut consensus, &path)?;
412 }
413 Ok(Self { issues, consensus })
414 }
415}
416
417pub fn identity(layout: &Layout) -> String {
424 if let Ok(value) = crate::process_env::var("VISSUE_AGENT") {
425 let value = value.trim();
426 if !value.is_empty() {
427 return value.to_string();
428 }
429 }
430 if let Ok(cfg) = RootConfig::load(layout.root())
431 && let Some(agent) = cfg.agent
432 {
433 let agent = agent.trim().to_string();
434 if !agent.is_empty() {
435 return agent;
436 }
437 }
438 format!("{}@{}", current_user(), current_host())
439}
440
441fn current_user() -> String {
442 for var in ["USER", "LOGNAME", "USERNAME"] {
443 if let Ok(value) = std::env::var(var)
444 && !value.trim().is_empty()
445 {
446 return value.trim().to_string();
447 }
448 }
449 "unknown".to_string()
450}
451
452fn current_host() -> String {
453 if let Ok(value) = std::env::var("HOSTNAME")
454 && !value.trim().is_empty()
455 {
456 return value.trim().to_string();
457 }
458 for path in ["/etc/hostname", "/proc/sys/kernel/hostname"] {
461 if let Ok(text) = fs::read_to_string(path) {
462 let trimmed = text.trim();
463 if !trimmed.is_empty() {
464 return trimmed.to_string();
465 }
466 }
467 }
468 "unknown".to_string()
469}
470
471#[cfg(test)]
472#[allow(deprecated_safe_2024)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn layout_defaults_to_software_prefix() {
478 let layout = Layout::new("/somewhere", "");
479 assert_eq!(layout.prefix(), DEFAULT_PREFIX);
480 assert_eq!(
481 layout.project_issues_path("demo"),
482 Path::new("/somewhere/Software/demo/issues.org")
483 );
484 }
485
486 #[test]
487 fn explicit_prefix_wins() {
488 let dir = tempfile::tempdir().unwrap();
489 fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
490 let layout = Layout::resolve(Some(dir.path()), Some("tracker")).unwrap();
491 assert_eq!(layout.prefix(), "tracker");
492 }
493
494 #[test]
495 fn root_config_supplies_prefix() {
496 let dir = tempfile::tempdir().unwrap();
497 fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
498 let layout = Layout::resolve(Some(dir.path()), None).unwrap();
499 assert_eq!(layout.prefix(), "projects");
500 assert_eq!(
501 layout.projects_dir(),
502 dir.path().join("projects"),
503 "projects dir follows the configured prefix"
504 );
505 }
506
507 static AGENT_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
509
510 #[test]
511 fn the_environment_names_the_claiming_identity_first() {
512 let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
513 let dir = tempfile::tempdir().unwrap();
514 fs::write(dir.path().join("vissue.toml"), "agent = \"from-file\"\n").unwrap();
515 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
516
517 crate::process_env::override_var("VISSUE_AGENT", Some("from-env"));
518 let from_env = identity(&layout);
519 crate::process_env::override_var("VISSUE_AGENT", Some(" "));
520 let blank_falls_through = identity(&layout);
521 crate::process_env::override_var("VISSUE_AGENT", None);
522 let from_file = identity(&layout);
523 crate::process_env::clear_override("VISSUE_AGENT");
524
525 assert_eq!(from_env, "from-env");
526 assert_eq!(
527 blank_falls_through, "from-file",
528 "a blank value is not an identity"
529 );
530 assert_eq!(from_file, "from-file");
531 }
532
533 #[test]
534 fn without_configuration_the_identity_is_user_at_host() {
535 let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
536 let dir = tempfile::tempdir().unwrap();
537 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
538 crate::process_env::override_var("VISSUE_AGENT", None);
539 let resolved = identity(&layout);
540 crate::process_env::clear_override("VISSUE_AGENT");
541 assert!(resolved.contains('@'), "{resolved}");
542 assert!(!resolved.starts_with('@'), "{resolved}");
543 assert!(!resolved.ends_with('@'), "{resolved}");
544 }
545
546 #[test]
547 fn the_stale_claim_threshold_is_configurable() {
548 let dir = tempfile::tempdir().unwrap();
549 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
550 assert_eq!(
551 VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
552 7
553 );
554
555 fs::write(
556 dir.path().join("vissue.toml"),
557 "[issues]\nstale_claim_days = 3\n",
558 )
559 .unwrap();
560 assert_eq!(
561 VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
562 3
563 );
564 }
565
566 #[test]
570 fn a_consensus_weight_the_iteration_cannot_use_is_refused() {
571 for (body, wanted) in [
572 ("[consensus]\nself_weight = 2.0\n", "self_weight"),
573 ("[consensus]\nself_weight = -0.5\n", "self_weight"),
574 ("[consensus]\ntolerance = 0.0\n", "tolerance"),
575 ("[consensus]\ntolerance = -1.0\n", "tolerance"),
576 ("[consensus]\nmax_iterations = 0\n", "max_iterations"),
577 (
578 "[consensus.trust]\nalice = { bob = -1.0 }\n",
579 "consensus.trust.alice.bob",
580 ),
581 ] {
582 let dir = tempfile::tempdir().unwrap();
583 fs::write(dir.path().join("vissue.toml"), body).unwrap();
584 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
585 let err = VissueConfig::load(&layout).unwrap_err().to_string();
586 assert!(err.contains(wanted), "{body:?} -> {err}");
587 assert!(
588 err.contains("vissue.toml"),
589 "the message has to name the file: {err}"
590 );
591 }
592 }
593
594 #[test]
598 fn a_per_agent_susceptibility_is_checked_and_names_the_agent() {
599 let dir = tempfile::tempdir().unwrap();
600 fs::write(
601 dir.path().join("vissue.toml"),
602 "[consensus.susceptibility_of]\nmaintainer = 1.5\n",
603 )
604 .unwrap();
605 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
606 let err = VissueConfig::load(&layout).unwrap_err().to_string();
607 assert!(err.contains("maintainer"), "{err}");
608 assert!(err.contains("vissue.toml"), "{err}");
609 }
610
611 #[test]
614 fn a_susceptibility_row_overrides_only_the_agent_it_names() {
615 let dir = tempfile::tempdir().unwrap();
616 fs::write(
617 dir.path().join("vissue.toml"),
618 "[consensus.susceptibility_of]\nmaintainer = 0.2\nreviewer = 0.6\n",
619 )
620 .unwrap();
621 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
622 fs::create_dir_all(layout.projects_dir()).unwrap();
623 fs::write(
624 layout.projects_dir().join("issues.config.toml"),
625 "[consensus.susceptibility_of]\nmaintainer = 0.4\n",
626 )
627 .unwrap();
628
629 let cfg = VissueConfig::load(&layout).unwrap().consensus;
630 assert_eq!(cfg.susceptibility_of.get("maintainer"), Some(&0.4));
631 assert_eq!(
632 cfg.susceptibility_of.get("reviewer"),
633 Some(&0.6),
634 "a row the second file says nothing about survives"
635 );
636 }
637
638 #[test]
642 fn the_ends_of_the_self_weight_range_are_accepted() {
643 for value in ["0.0", "1.0"] {
644 let dir = tempfile::tempdir().unwrap();
645 fs::write(
646 dir.path().join("vissue.toml"),
647 format!("[consensus]\nself_weight = {value}\n"),
648 )
649 .unwrap();
650 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
651 let cfg = VissueConfig::load(&layout).expect(value);
652 assert_eq!(cfg.consensus.self_weight, value.parse::<f64>().unwrap());
653 }
654 }
655
656 #[test]
659 fn a_trust_row_overrides_only_the_agent_it_names() {
660 let dir = tempfile::tempdir().unwrap();
661 fs::write(
662 dir.path().join("vissue.toml"),
663 "[consensus.trust]\nalice = { bob = 1.0 }\ncarol = { alice = 1.0 }\n",
664 )
665 .unwrap();
666 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
667 fs::create_dir_all(layout.projects_dir()).unwrap();
668 fs::write(
669 layout.projects_dir().join("issues.config.toml"),
670 "[consensus.trust]\nalice = { carol = 4.0 }\n",
671 )
672 .unwrap();
673
674 let cfg = VissueConfig::load(&layout).unwrap();
675 assert_eq!(
676 cfg.consensus
677 .trust
678 .get("alice")
679 .and_then(|r| r.get("carol")),
680 Some(&4.0),
681 "the named row is replaced whole"
682 );
683 assert!(
684 cfg.consensus
685 .trust
686 .get("alice")
687 .is_some_and(|r| !r.contains_key("bob")),
688 "replaced, not merged into: {:?}",
689 cfg.consensus.trust
690 );
691 assert_eq!(
692 cfg.consensus
693 .trust
694 .get("carol")
695 .and_then(|r| r.get("alice")),
696 Some(&1.0),
697 "a row the second file says nothing about survives"
698 );
699 }
700
701 #[test]
703 fn the_consensus_defaults_converge_on_their_own() {
704 let dir = tempfile::tempdir().unwrap();
705 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
706 let cfg = VissueConfig::load(&layout).unwrap().consensus;
707 assert!(cfg.trust.is_empty());
708 assert!(
709 cfg.self_weight > 0.0,
710 "a zero diagonal is what makes a trust graph periodic"
711 );
712 assert!(cfg.tolerance > 0.0 && cfg.max_iterations > 0);
713 }
714
715 #[test]
716 fn config_defaults_when_no_files_present() {
717 let dir = tempfile::tempdir().unwrap();
718 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
719 let cfg = VissueConfig::load(&layout).unwrap();
720 assert_eq!(cfg.issues.default_priority, 'C');
721 assert_eq!(cfg.issues.id_length, 4);
722 }
723
724 #[test]
725 fn prefix_scoped_config_overrides_root_config() {
726 let dir = tempfile::tempdir().unwrap();
727 fs::write(
728 dir.path().join("vissue.toml"),
729 "[issues]\ndefault_priority = \"B\"\nid_length = 5\n",
730 )
731 .unwrap();
732 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
733 let cfg = VissueConfig::load(&layout).unwrap();
734 assert_eq!(cfg.issues.default_priority, 'B');
735 assert_eq!(cfg.issues.id_length, 5);
736
737 fs::create_dir_all(layout.projects_dir()).unwrap();
738 fs::write(
739 layout.projects_dir().join("issues.config.toml"),
740 "[issues]\ndefault_priority = \"A\"\nid_length = 6\n",
741 )
742 .unwrap();
743 let cfg = VissueConfig::load(&layout).unwrap();
744 assert_eq!(cfg.issues.default_priority, 'A');
745 assert_eq!(cfg.issues.id_length, 6);
746 }
747
748 #[test]
749 fn a_partial_override_keeps_the_keys_it_does_not_name() {
750 let dir = tempfile::tempdir().unwrap();
751 fs::write(
752 dir.path().join("vissue.toml"),
753 "[issues]\ndefault_priority = \"B\"\nid_length = 5\nstale_claim_days = 3\n",
754 )
755 .unwrap();
756 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
757 fs::create_dir_all(layout.projects_dir()).unwrap();
758 fs::write(
759 layout.projects_dir().join("issues.config.toml"),
760 "[issues]\nid_length = 6\n",
761 )
762 .unwrap();
763
764 let cfg = VissueConfig::load(&layout).unwrap();
765 assert_eq!(cfg.issues.id_length, 6, "the named key is overridden");
766 assert_eq!(
767 cfg.issues.default_priority, 'B',
768 "an unnamed key keeps the root value"
769 );
770 assert_eq!(cfg.issues.stale_claim_days, 3);
771 }
772}