Skip to main content

vissue_core/
config.rs

1//! Root and layout resolution plus the optional on-disk configuration.
2//!
3//! A tracker lives under `<root>/<prefix>`, one directory per project, each
4//! holding an `issues.org`. `root` comes from the caller, `ISSUE_ROOT`,
5//! `VISSUE_ROOT`, the working directory when that is itself a tracker, the
6//! seat's own `vissue/config.toml`, and otherwise the working directory.
7//! `prefix` comes from the caller, `VISSUE_PREFIX`, `<root>/vissue.toml`, or
8//! the `Software` default.
9
10use anyhow::Context;
11
12use crate::error::Result;
13use serde::Deserialize;
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18/// Directory under the root that holds one subdirectory per project.
19pub const DEFAULT_PREFIX: &str = "Software";
20
21/// Where the tracker lives: a root directory and the project prefix inside it.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Layout {
24    root: PathBuf,
25    prefix: String,
26    /// Whether the root was the working directory rather than something the
27    /// caller named. A guessed root that turns out to hold no tracker is the
28    /// one case where an empty answer is a wrong answer.
29    guessed: bool,
30}
31
32impl Layout {
33    /// Build a layout from an explicit root and prefix.
34    ///
35    /// An empty prefix falls back to [`DEFAULT_PREFIX`].
36    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    /// Resolve from explicit arguments, falling back to the environment, the
50    /// directory the caller stands in, the seat's own file, and finally the
51    /// compiled defaults. The order is the caller, then the environment, then
52    /// the working directory when that is a tracker, then the seat file.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if the current directory cannot be resolved, or if
57    /// `<root>/vissue.toml` exists but cannot be read or parsed.
58    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    /// Refuse a guessed root that holds no tracker; a named root is trusted.
84    ///
85    /// # Errors
86    ///
87    /// [`crate::error::Error::NotATracker`] when the root was the working
88    /// directory and carries neither `vissue.toml` nor the prefix directory.
89    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    /// Tracker root: the directory that holds `vissue.toml` and `prefix`.
101    pub fn root(&self) -> &Path {
102        &self.root
103    }
104
105    /// Directory name under [`Self::root`] that holds one subdirectory per project.
106    pub fn prefix(&self) -> &str {
107        &self.prefix
108    }
109
110    /// `<root>/<prefix>`: the directory scanned for projects.
111    pub fn projects_dir(&self) -> PathBuf {
112        self.root.join(&self.prefix)
113    }
114
115    /// `<root>/<prefix>/<project>/issues.org`.
116    pub fn project_issues_path(&self, project: &str) -> PathBuf {
117        self.projects_dir().join(project).join("issues.org")
118    }
119}
120
121/// Which root the tracker is, and whether it was a guess (the working
122/// directory for want of anything better), which [`Layout::require_tracker`]
123/// refuses when empty.
124fn 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    // Standing in a tracker means that tracker, whatever the seat file says:
138    // the caller is the more specific of the two.
139    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/// The seat's own configuration file, the one the router reads: which tracker
149/// it means when nobody says. Only `root` is read here.
150#[derive(Debug, Clone, Default, Deserialize)]
151#[serde(default)]
152struct SeatConfig {
153    root: Option<String>,
154}
155
156impl SeatConfig {
157    /// The configured root, or nothing; an unreadable file is nothing too.
158    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    /// `$VISSUE_CONFIG`, else the file under the seat's configuration
174    /// directory. The same two the router looks at, in the same order.
175    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/// The tracker at `root` as its own `vissue.toml` describes it: the prefix
194/// it names, else the default. Named, not guessed.
195///
196/// # Errors
197///
198/// A `vissue.toml` that cannot be read or parsed.
199pub 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/// `<root>/vissue.toml`, the product-level configuration file.
207#[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/// Knobs that shape newly created issues.
230#[derive(Debug, Clone, Deserialize)]
231#[serde(default)]
232pub struct IssuesSection {
233    /// Priority cookie applied when `create` is called without one.
234    pub default_priority: char,
235    /// Length in base36 characters of the random suffix in a generated id.
236    pub id_length: usize,
237    /// How long a claim may sit on a STARTED issue before hygiene calls it
238    /// stale.
239    pub stale_claim_days: i64,
240    /// Whether `hygiene` reports work that closed citing no deed. Off by default.
241    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/// The subset of [`IssuesSection`] a configuration file names. A key left out
256/// of a file stays whatever the layer below it set, so a file that tunes one
257/// knob does not silently reset the others.
258#[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/// Who listens to whom, and how hard the consensus iteration tries. Rows are
285/// normalised by [`crate::consensus`]; an agent with no row keeps
286/// `self_weight` and splits the rest equally.
287///
288/// ```toml
289/// [consensus]
290/// self_weight = 0.5
291/// susceptibility = 0.8
292///
293/// [consensus.trust]
294/// reviewer = { maintainer = 3.0, worker = 1.0 }
295/// worker = { maintainer = 1.0 }
296///
297/// [consensus.susceptibility_of]
298/// maintainer = 0.2
299/// ```
300#[derive(Debug, Clone, PartialEq)]
301pub struct ConsensusSection {
302    /// Weight an agent puts on its own opinion when its row does not name it.
303    pub self_weight: f64,
304    /// How far an agent moves off the ballot it cast, in `[0, 1]`: one is
305    /// DeGroot (the default), below one is Friedkin-Johnsen.
306    pub susceptibility: f64,
307    /// Largest disagreement that still counts as settled.
308    pub tolerance: f64,
309    /// Rounds to try before calling the trust graph periodic.
310    pub max_iterations: usize,
311    /// Susceptibility for one named agent; others use [`Self::susceptibility`].
312    pub susceptibility_of: BTreeMap<String, f64>,
313    /// Trust rows, keyed by the identity that holds the opinion.
314    pub trust: BTreeMap<String, BTreeMap<String, f64>>,
315}
316
317impl Default for ConsensusSection {
318    fn default() -> Self {
319        Self {
320            // Positive on purpose. A zero diagonal is what makes a trust graph
321            // periodic, and a tracker nobody has configured should converge.
322            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/// The subset of [`ConsensusSection`] a configuration file names.
333#[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    /// Apply this layer, refusing rather than clamping values the iteration
347    /// cannot use.
348    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            // Agent by agent, like the trust rows: a file that pins one
379            // reviewer does not drop the others.
380            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            // Row by row, like every other override: a file that retunes one
414            // agent's trust does not silently drop the rows it says nothing
415            // about.
416            base.trust.insert(agent.clone(), row.clone());
417        }
418        Ok(())
419    }
420}
421
422/// Effective configuration for one layout.
423#[derive(Debug, Clone, Default)]
424pub struct VissueConfig {
425    /// Knobs that shape newly created issues and hygiene thresholds.
426    pub issues: IssuesSection,
427    /// Who listens to whom when a consensus is computed.
428    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    /// `<root>/<prefix>/issues.config.toml` overrides `<root>/vissue.toml`,
440    /// which overrides the compiled defaults. Neither file is required, and
441    /// each layer overrides key by key rather than wholesale.
442    ///
443    /// # Errors
444    ///
445    /// Returns an error if a configuration file exists but cannot be read or
446    /// parsed.
447    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
467/// Who is claiming work here.
468///
469/// `VISSUE_AGENT` wins, then `agent` in `<root>/vissue.toml`, then
470/// `user@host`. The value is opaque: an agent should set `VISSUE_AGENT` to
471/// something stable enough to identify it across sessions, such as a model
472/// and session tag, and any string it picks is stored verbatim.
473pub 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    // HOSTNAME is not exported by every shell, so fall back to the file the
509    // system keeps it in.
510    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    /// `VISSUE_AGENT` is process-global, so the identity tests take turns.
558    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    /// A weight the iteration cannot use is refused rather than clamped. A
617    /// `self_weight` of 2 is a typo, and clamping it to 1 would hand back a
618    /// consensus in which nobody listened to anybody and say nothing about why.
619    #[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    /// A per-agent susceptibility outside the range is refused the same way the
645    /// default is, and the message names the agent as well as the file, because
646    /// a table of reviewers needs to say which row is wrong.
647    #[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    /// Susceptibility merges agent by agent, like the trust rows: a file that
662    /// pins one reviewer must not drop the others.
663    #[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    /// The whole range is usable, ends included: zero self-weight is the
689    /// periodic case the consensus report exists to name, and one is an agent
690    /// that listens to nobody.
691    #[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    /// Trust merges row by row, like every other override. A file that retunes
707    /// one agent must not silently drop the rows it says nothing about.
708    #[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    /// Nothing configured is the shape the consensus verb reduces to a tally in.
752    #[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    /// A seat file names the tracker the bare command means.
824    #[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    /// A file that is absent, unparseable, empty, or names a directory that is
841    /// not there says nothing rather than failing: this is the fallback path,
842    /// and the working directory below it still gives the caller a message.
843    #[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    /// The router owns this file too, and the two read it for different keys.
860    /// A seat that has routes still gets a root out of it, and the router
861    /// still parses a file that names one.
862    #[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        // The router's own parse is strict about unknown keys, so the same
880        // bytes have to be readable by it: one file, two readers.
881        crate::router::Router::from_file(Layout::new(dir.path(), DEFAULT_PREFIX), &path)
882            .expect("the router reads the same file");
883    }
884
885    /// The order the root is decided in, with nothing global touched.
886    #[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        // Something the caller named wins, and is never a guess.
894        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        // Then the environment.
905        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        // Standing in a tracker means that tracker, over the seat's default:
916        // the caller is the more specific of the two.
917        assert_eq!(
918            choose_root(None, None, &here, true, Some(seat.clone())),
919            (here.clone(), false)
920        );
921        // Standing nowhere in particular, the seat's own tracker.
922        assert_eq!(
923            choose_root(None, None, &here, false, Some(seat.clone())),
924            (seat, false)
925        );
926        // And with no seat file, the working directory as a guess, which is
927        // what `require_tracker` refuses when it holds no tracker.
928        assert_eq!(choose_root(None, None, &here, false, None), (here, true));
929    }
930}