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//!
10//! The seat file is what lets the bare command answer from anywhere. Without
11//! it the only way to reach one tracker from outside its directory is an
12//! environment variable, which every shell and every launcher has to be told
13//! about separately, and a seat that misses one of them has a command that
14//! works in a terminal and not under an agent.
15
16use anyhow::Context;
17
18use crate::error::Result;
19use serde::Deserialize;
20use std::collections::BTreeMap;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24/// Directory under the root that holds one subdirectory per project.
25pub const DEFAULT_PREFIX: &str = "Software";
26
27/// Where the tracker lives: a root directory and the project prefix inside it.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Layout {
30    root: PathBuf,
31    prefix: String,
32    /// Whether the root was the working directory rather than something the
33    /// caller named. A guessed root that turns out to hold no tracker is the
34    /// one case where an empty answer is a wrong answer.
35    guessed: bool,
36}
37
38impl Layout {
39    /// Build a layout from an explicit root and prefix.
40    ///
41    /// An empty prefix falls back to [`DEFAULT_PREFIX`].
42    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    /// Resolve from explicit arguments, falling back to the environment, the
56    /// directory the caller stands in, the seat's own file, and finally the
57    /// compiled defaults. The order is the caller, then the environment, then
58    /// the working directory when that is a tracker, then the seat file.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the current directory cannot be resolved, or if
63    /// `<root>/vissue.toml` exists but cannot be read or parsed.
64    pub fn resolve(root: Option<&Path>, prefix: Option<&str>) -> Result<Self> {
65        let here = std::env::current_dir().context("resolve current directory as root")?;
66        let (root, guessed) = choose_root(
67            root,
68            std::env::var_os("ISSUE_ROOT")
69                .or_else(|| std::env::var_os("VISSUE_ROOT"))
70                .map(PathBuf::from),
71            &here,
72            here.join("vissue.toml").is_file(),
73            SeatConfig::path().as_deref().and_then(SeatConfig::read),
74        );
75        let prefix = match prefix {
76            Some(p) if !p.is_empty() => p.to_string(),
77            _ => match std::env::var("VISSUE_PREFIX") {
78                Ok(v) if !v.is_empty() => v,
79                _ => RootConfig::load(&root)?
80                    .prefix
81                    .unwrap_or_else(|| DEFAULT_PREFIX.to_string()),
82            },
83        };
84        let mut layout = Self::new(root, prefix);
85        layout.guessed = guessed;
86        Ok(layout)
87    }
88
89    /// Refuse a guessed root that holds no tracker.
90    ///
91    /// A reading verb answering "none" is indistinguishable from a tracker with
92    /// nothing in it, and the two mean opposite things: one is an answer and
93    /// the other is a caller standing in the wrong directory. A root somebody
94    /// named is trusted, empty or not, because they said which one they meant.
95    ///
96    /// # Errors
97    ///
98    /// [`crate::error::Error::NotATracker`] when the root was the working
99    /// directory and carries neither `vissue.toml` nor the prefix directory.
100    pub fn require_tracker(&self) -> Result<()> {
101        if !self.guessed || self.root.join("vissue.toml").is_file() || self.projects_dir().is_dir()
102        {
103            return Ok(());
104        }
105        Err(crate::error::Error::NotATracker {
106            root: self.root.clone(),
107            prefix: self.prefix.clone(),
108        })
109    }
110
111    /// Tracker root: the directory that holds `vissue.toml` and `prefix`.
112    pub fn root(&self) -> &Path {
113        &self.root
114    }
115
116    /// Directory name under [`Self::root`] that holds one subdirectory per project.
117    pub fn prefix(&self) -> &str {
118        &self.prefix
119    }
120
121    /// `<root>/<prefix>`: the directory scanned for projects.
122    pub fn projects_dir(&self) -> PathBuf {
123        self.root.join(&self.prefix)
124    }
125
126    /// `<root>/<prefix>/<project>/issues.org`.
127    pub fn project_issues_path(&self, project: &str) -> PathBuf {
128        self.projects_dir().join(project).join("issues.org")
129    }
130}
131
132/// Which root the tracker is, and whether it was a guess.
133///
134/// A guess is the working directory taken for want of anything better, and it
135/// is the one case where an empty answer might be a wrong answer, so
136/// [`Layout::require_tracker`] refuses it when it holds no tracker. Everything
137/// else here was named by somebody: the caller, the environment, the directory
138/// they are standing in, or the seat's own file.
139fn choose_root(
140    named: Option<&Path>,
141    from_env: Option<PathBuf>,
142    here: &Path,
143    here_is_a_tracker: bool,
144    seat: Option<PathBuf>,
145) -> (PathBuf, bool) {
146    if let Some(root) = named {
147        return (root.to_path_buf(), false);
148    }
149    if let Some(root) = from_env {
150        return (root, false);
151    }
152    // Standing in a tracker means that tracker, whatever the seat file says:
153    // the caller is the more specific of the two.
154    if here_is_a_tracker {
155        return (here.to_path_buf(), false);
156    }
157    match seat {
158        Some(root) => (root, false),
159        None => (here.to_path_buf(), true),
160    }
161}
162
163/// The seat's own configuration file: which tracker it means when nobody says.
164///
165/// The same file the router reads, because a seat has one configuration file
166/// and not one per feature. Only `root` is read here and every other key is
167/// ignored, which is the opposite of the router's own strictness on purpose:
168/// this runs on the path where a caller has named nothing, and a file it
169/// cannot understand has to leave them with the working-directory message
170/// rather than an error about a key they did not ask about.
171///
172/// Separate from `<root>/vissue.toml`, which configures a tracker somebody has
173/// already found. This one is how they find it.
174#[derive(Debug, Clone, Default, Deserialize)]
175#[serde(default)]
176struct SeatConfig {
177    root: Option<String>,
178}
179
180impl SeatConfig {
181    /// The configured root, or nothing when the seat has not named one.
182    ///
183    /// A file that cannot be read or parsed is no answer rather than an error:
184    /// this is the fallback path, and the working directory below it still
185    /// gives the caller the tracker-or-not message they can act on.
186    fn read(path: &Path) -> Option<PathBuf> {
187        let raw = fs::read_to_string(path).ok()?;
188        let parsed: Self = toml::from_str(&raw).ok()?;
189        let named = parsed.root?;
190        let named = named.trim();
191        if named.is_empty() {
192            return None;
193        }
194        let expanded = match named.strip_prefix("~/") {
195            Some(rest) => home()?.join(rest),
196            None => PathBuf::from(named),
197        };
198        expanded.is_dir().then_some(expanded)
199    }
200
201    /// `$VISSUE_CONFIG`, else the file under the seat's configuration
202    /// directory. The same two the router looks at, in the same order.
203    fn path() -> Option<PathBuf> {
204        if let Some(named) = std::env::var_os("VISSUE_CONFIG").filter(|raw| !raw.is_empty()) {
205            return Some(PathBuf::from(named));
206        }
207        let base = match std::env::var_os("XDG_CONFIG_HOME") {
208            Some(dir) if !dir.is_empty() => PathBuf::from(dir),
209            _ => home()?.join(".config"),
210        };
211        Some(base.join("vissue").join("config.toml"))
212    }
213}
214
215fn home() -> Option<PathBuf> {
216    std::env::var_os("HOME")
217        .filter(|value| !value.is_empty())
218        .map(PathBuf::from)
219}
220
221/// `<root>/vissue.toml`, the product-level configuration file.
222#[derive(Debug, Clone, Default, Deserialize)]
223#[serde(default)]
224struct RootConfig {
225    prefix: Option<String>,
226    agent: Option<String>,
227    issues: IssuesOverride,
228    consensus: ConsensusOverride,
229}
230
231impl RootConfig {
232    fn load(root: &Path) -> Result<Self> {
233        let path = root.join("vissue.toml");
234        if !path.exists() {
235            return Ok(Self::default());
236        }
237        let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
238        toml::from_str(&raw)
239            .with_context(|| format!("parse {}", path.display()))
240            .map_err(crate::error::Error::from)
241    }
242}
243
244/// Knobs that shape newly created issues.
245#[derive(Debug, Clone, Deserialize)]
246#[serde(default)]
247pub struct IssuesSection {
248    /// Priority cookie applied when `create` is called without one.
249    pub default_priority: char,
250    /// Length in base36 characters of the random suffix in a generated id.
251    pub id_length: usize,
252    /// How long a claim may sit on a STARTED issue before hygiene calls it
253    /// stale.
254    pub stale_claim_days: i64,
255    /// Whether `hygiene` reports work that closed without naming what it made.
256    ///
257    /// Off by default, because plenty of issues produce nothing a deed store
258    /// would hold: a decision, a review, a question answered. On a tracker
259    /// where the next unit is expected to open the last one's product, work
260    /// that closed citing nothing is a hole in the handoff, and this is what
261    /// makes that visible instead of leaving it to be discovered by whoever
262    /// needed it.
263    pub expect_deeds: bool,
264}
265
266impl Default for IssuesSection {
267    fn default() -> Self {
268        Self {
269            default_priority: 'C',
270            id_length: 4,
271            stale_claim_days: 7,
272            expect_deeds: false,
273        }
274    }
275}
276
277/// The subset of [`IssuesSection`] a configuration file names. A key left out
278/// of a file stays whatever the layer below it set, so a file that tunes one
279/// knob does not silently reset the others.
280#[derive(Debug, Clone, Default, Deserialize)]
281#[serde(default)]
282struct IssuesOverride {
283    default_priority: Option<char>,
284    id_length: Option<usize>,
285    stale_claim_days: Option<i64>,
286    expect_deeds: Option<bool>,
287}
288
289impl IssuesOverride {
290    fn apply_to(&self, base: &mut IssuesSection) {
291        if let Some(value) = self.default_priority {
292            base.default_priority = value;
293        }
294        if let Some(value) = self.id_length {
295            base.id_length = value;
296        }
297        if let Some(value) = self.stale_claim_days {
298            base.stale_claim_days = value;
299        }
300        if let Some(value) = self.expect_deeds {
301            base.expect_deeds = value;
302        }
303    }
304}
305
306/// Who listens to whom, and how hard the consensus iteration tries.
307///
308/// The trust rows are the influence graph DeGroot averages over. Each row names
309/// the agents one agent listens to, in whatever units the author finds natural:
310/// only the ratios matter, because [`crate::consensus`] normalises the row. An
311/// agent with no row listens to itself with `self_weight` and splits the rest
312/// equally over the others, which makes an unconfigured tracker report the tally
313/// as a fraction rather than something surprising.
314///
315/// ```toml
316/// [consensus]
317/// self_weight = 0.5
318/// susceptibility = 0.8
319///
320/// [consensus.trust]
321/// reviewer = { maintainer = 3.0, worker = 1.0 }
322/// worker = { maintainer = 1.0 }
323///
324/// [consensus.susceptibility_of]
325/// maintainer = 0.2
326/// ```
327#[derive(Debug, Clone, PartialEq)]
328pub struct ConsensusSection {
329    /// Weight an agent puts on its own opinion when its row does not name it.
330    pub self_weight: f64,
331    /// How far an agent moves off the ballot it cast, in `[0, 1]`.
332    ///
333    /// One is DeGroot: an agent keeps nothing of its own starting position and
334    /// the group converges on a single number. Below one is Friedkin and
335    /// Johnsen's generalisation, where each agent stays partly anchored to the
336    /// ballot it actually cast, and what the iteration settles on is a profile
337    /// of persistent disagreement rather than one shared position.
338    ///
339    /// One by default, so a tracker that configures nothing keeps the reduction
340    /// to the tally. Below one is the honest setting where reviewers are not
341    /// expected to abandon their own reading, and it also removes the periodic
342    /// case: any anchor at all makes the iteration a contraction.
343    pub susceptibility: f64,
344    /// Largest disagreement that still counts as settled.
345    pub tolerance: f64,
346    /// Rounds to try before calling the trust graph periodic.
347    pub max_iterations: usize,
348    /// Susceptibility for one named agent, where it differs from the default.
349    ///
350    /// Friedkin and Johnsen's susceptibility is a diagonal rather than one
351    /// number: a maintainer who has read the code for years and a reviewer
352    /// seeing it for the first time are not equally movable, and saying so is
353    /// the difference between the model and an average. An agent named here
354    /// uses this value; every other agent uses [`Self::susceptibility`].
355    pub susceptibility_of: BTreeMap<String, f64>,
356    /// Trust rows, keyed by the identity that holds the opinion.
357    pub trust: BTreeMap<String, BTreeMap<String, f64>>,
358}
359
360impl Default for ConsensusSection {
361    fn default() -> Self {
362        Self {
363            // Positive on purpose. A zero diagonal is what makes a trust graph
364            // periodic, and a tracker nobody has configured should converge.
365            self_weight: 0.5,
366            susceptibility: 1.0,
367            susceptibility_of: BTreeMap::new(),
368            tolerance: 1e-9,
369            max_iterations: 500,
370            trust: BTreeMap::new(),
371        }
372    }
373}
374
375/// The subset of [`ConsensusSection`] a configuration file names.
376#[derive(Debug, Clone, Default, Deserialize)]
377#[serde(default)]
378struct ConsensusOverride {
379    self_weight: Option<f64>,
380    susceptibility: Option<f64>,
381    #[serde(default)]
382    susceptibility_of: BTreeMap<String, f64>,
383    tolerance: Option<f64>,
384    max_iterations: Option<usize>,
385    trust: BTreeMap<String, BTreeMap<String, f64>>,
386}
387
388impl ConsensusOverride {
389    /// Apply this layer, refusing values the iteration cannot use.
390    ///
391    /// Refused rather than clamped: a `self_weight` of 2 is a typo, and clamping
392    /// it to 1 would hand back a consensus in which nobody listened to anybody
393    /// and say nothing about why.
394    fn apply_to(&self, base: &mut ConsensusSection, whence: &Path) -> Result<()> {
395        if let Some(value) = self.self_weight {
396            if !(0.0..=1.0).contains(&value) {
397                return Err(anyhow::anyhow!(
398                    "{}: consensus.self_weight is {value}, which is not a share between 0 and 1",
399                    whence.display()
400                )
401                .into());
402            }
403            base.self_weight = value;
404        }
405        if let Some(value) = self.susceptibility {
406            if !(0.0..=1.0).contains(&value) {
407                return Err(anyhow::anyhow!(
408                    "{}: consensus.susceptibility is {value}, which is not a share between 0 and 1",
409                    whence.display()
410                )
411                .into());
412            }
413            base.susceptibility = value;
414        }
415        for (agent, value) in &self.susceptibility_of {
416            if !(0.0..=1.0).contains(value) {
417                return Err(anyhow::anyhow!(
418                    "{}: consensus.susceptibility_of.{agent} is {value}, \
419                     which is not a share between 0 and 1",
420                    whence.display()
421                )
422                .into());
423            }
424            // Agent by agent, like the trust rows: a file that pins one
425            // reviewer does not drop the others.
426            base.susceptibility_of.insert(agent.clone(), *value);
427        }
428        if let Some(value) = self.tolerance {
429            if !(value > 0.0 && value.is_finite()) {
430                return Err(anyhow::anyhow!(
431                    "{}: consensus.tolerance is {value}, which is not a positive distance",
432                    whence.display()
433                )
434                .into());
435            }
436            base.tolerance = value;
437        }
438        if let Some(value) = self.max_iterations {
439            if value == 0 {
440                return Err(anyhow::anyhow!(
441                    "{}: consensus.max_iterations is 0, which runs no rounds at all",
442                    whence.display()
443                )
444                .into());
445            }
446            base.max_iterations = value;
447        }
448        for (agent, row) in &self.trust {
449            for (other, weight) in row {
450                if !(*weight >= 0.0 && weight.is_finite()) {
451                    return Err(anyhow::anyhow!(
452                        "{}: consensus.trust.{agent}.{other} is {weight}, \
453                         which is not a weight",
454                        whence.display()
455                    )
456                    .into());
457                }
458            }
459            // Row by row, like every other override: a file that retunes one
460            // agent's trust does not silently drop the rows it says nothing
461            // about.
462            base.trust.insert(agent.clone(), row.clone());
463        }
464        Ok(())
465    }
466}
467
468/// Effective configuration for one layout.
469#[derive(Debug, Clone, Default)]
470pub struct VissueConfig {
471    /// Knobs that shape newly created issues and hygiene thresholds.
472    pub issues: IssuesSection,
473    /// Who listens to whom when a consensus is computed.
474    pub consensus: ConsensusSection,
475}
476
477#[derive(Debug, Clone, Default, Deserialize)]
478#[serde(default)]
479struct PrefixConfigFile {
480    issues: IssuesOverride,
481    consensus: ConsensusOverride,
482}
483
484impl VissueConfig {
485    /// `<root>/<prefix>/issues.config.toml` overrides `<root>/vissue.toml`,
486    /// which overrides the compiled defaults. Neither file is required, and
487    /// each layer overrides key by key rather than wholesale.
488    ///
489    /// # Errors
490    ///
491    /// Returns an error if a configuration file exists but cannot be read or
492    /// parsed.
493    pub fn load(layout: &Layout) -> Result<Self> {
494        let mut issues = IssuesSection::default();
495        let mut consensus = ConsensusSection::default();
496        let root_path = layout.root().join("vissue.toml");
497        let root = RootConfig::load(layout.root())?;
498        root.issues.apply_to(&mut issues);
499        root.consensus.apply_to(&mut consensus, &root_path)?;
500        let path = layout.projects_dir().join("issues.config.toml");
501        if path.exists() {
502            let raw =
503                fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
504            let parsed: PrefixConfigFile =
505                toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
506            parsed.issues.apply_to(&mut issues);
507            parsed.consensus.apply_to(&mut consensus, &path)?;
508        }
509        Ok(Self { issues, consensus })
510    }
511}
512
513/// Who is claiming work here.
514///
515/// `VISSUE_AGENT` wins, then `agent` in `<root>/vissue.toml`, then
516/// `user@host`. The value is opaque: an agent should set `VISSUE_AGENT` to
517/// something stable enough to identify it across sessions, such as a model
518/// and session tag, and any string it picks is stored verbatim.
519pub fn identity(layout: &Layout) -> String {
520    if let Ok(value) = crate::process_env::var("VISSUE_AGENT") {
521        let value = value.trim();
522        if !value.is_empty() {
523            return value.to_string();
524        }
525    }
526    if let Ok(cfg) = RootConfig::load(layout.root())
527        && let Some(agent) = cfg.agent
528    {
529        let agent = agent.trim().to_string();
530        if !agent.is_empty() {
531            return agent;
532        }
533    }
534    format!("{}@{}", current_user(), current_host())
535}
536
537fn current_user() -> String {
538    for var in ["USER", "LOGNAME", "USERNAME"] {
539        if let Ok(value) = std::env::var(var)
540            && !value.trim().is_empty()
541        {
542            return value.trim().to_string();
543        }
544    }
545    "unknown".to_string()
546}
547
548fn current_host() -> String {
549    if let Ok(value) = std::env::var("HOSTNAME")
550        && !value.trim().is_empty()
551    {
552        return value.trim().to_string();
553    }
554    // HOSTNAME is not exported by every shell, so fall back to the file the
555    // system keeps it in.
556    for path in ["/etc/hostname", "/proc/sys/kernel/hostname"] {
557        if let Ok(text) = fs::read_to_string(path) {
558            let trimmed = text.trim();
559            if !trimmed.is_empty() {
560                return trimmed.to_string();
561            }
562        }
563    }
564    "unknown".to_string()
565}
566
567#[cfg(test)]
568#[allow(deprecated_safe_2024)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn layout_defaults_to_software_prefix() {
574        let layout = Layout::new("/somewhere", "");
575        assert_eq!(layout.prefix(), DEFAULT_PREFIX);
576        assert_eq!(
577            layout.project_issues_path("demo"),
578            Path::new("/somewhere/Software/demo/issues.org")
579        );
580    }
581
582    #[test]
583    fn explicit_prefix_wins() {
584        let dir = tempfile::tempdir().unwrap();
585        fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
586        let layout = Layout::resolve(Some(dir.path()), Some("tracker")).unwrap();
587        assert_eq!(layout.prefix(), "tracker");
588    }
589
590    #[test]
591    fn root_config_supplies_prefix() {
592        let dir = tempfile::tempdir().unwrap();
593        fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
594        let layout = Layout::resolve(Some(dir.path()), None).unwrap();
595        assert_eq!(layout.prefix(), "projects");
596        assert_eq!(
597            layout.projects_dir(),
598            dir.path().join("projects"),
599            "projects dir follows the configured prefix"
600        );
601    }
602
603    /// `VISSUE_AGENT` is process-global, so the identity tests take turns.
604    static AGENT_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
605
606    #[test]
607    fn the_environment_names_the_claiming_identity_first() {
608        let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
609        let dir = tempfile::tempdir().unwrap();
610        fs::write(dir.path().join("vissue.toml"), "agent = \"from-file\"\n").unwrap();
611        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
612
613        crate::process_env::override_var("VISSUE_AGENT", Some("from-env"));
614        let from_env = identity(&layout);
615        crate::process_env::override_var("VISSUE_AGENT", Some("   "));
616        let blank_falls_through = identity(&layout);
617        crate::process_env::override_var("VISSUE_AGENT", None);
618        let from_file = identity(&layout);
619        crate::process_env::clear_override("VISSUE_AGENT");
620
621        assert_eq!(from_env, "from-env");
622        assert_eq!(
623            blank_falls_through, "from-file",
624            "a blank value is not an identity"
625        );
626        assert_eq!(from_file, "from-file");
627    }
628
629    #[test]
630    fn without_configuration_the_identity_is_user_at_host() {
631        let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
632        let dir = tempfile::tempdir().unwrap();
633        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
634        crate::process_env::override_var("VISSUE_AGENT", None);
635        let resolved = identity(&layout);
636        crate::process_env::clear_override("VISSUE_AGENT");
637        assert!(resolved.contains('@'), "{resolved}");
638        assert!(!resolved.starts_with('@'), "{resolved}");
639        assert!(!resolved.ends_with('@'), "{resolved}");
640    }
641
642    #[test]
643    fn the_stale_claim_threshold_is_configurable() {
644        let dir = tempfile::tempdir().unwrap();
645        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
646        assert_eq!(
647            VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
648            7
649        );
650
651        fs::write(
652            dir.path().join("vissue.toml"),
653            "[issues]\nstale_claim_days = 3\n",
654        )
655        .unwrap();
656        assert_eq!(
657            VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
658            3
659        );
660    }
661
662    /// A weight the iteration cannot use is refused rather than clamped. A
663    /// `self_weight` of 2 is a typo, and clamping it to 1 would hand back a
664    /// consensus in which nobody listened to anybody and say nothing about why.
665    #[test]
666    fn a_consensus_weight_the_iteration_cannot_use_is_refused() {
667        for (body, wanted) in [
668            ("[consensus]\nself_weight = 2.0\n", "self_weight"),
669            ("[consensus]\nself_weight = -0.5\n", "self_weight"),
670            ("[consensus]\ntolerance = 0.0\n", "tolerance"),
671            ("[consensus]\ntolerance = -1.0\n", "tolerance"),
672            ("[consensus]\nmax_iterations = 0\n", "max_iterations"),
673            (
674                "[consensus.trust]\nalice = { bob = -1.0 }\n",
675                "consensus.trust.alice.bob",
676            ),
677        ] {
678            let dir = tempfile::tempdir().unwrap();
679            fs::write(dir.path().join("vissue.toml"), body).unwrap();
680            let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
681            let err = VissueConfig::load(&layout).unwrap_err().to_string();
682            assert!(err.contains(wanted), "{body:?} -> {err}");
683            assert!(
684                err.contains("vissue.toml"),
685                "the message has to name the file: {err}"
686            );
687        }
688    }
689
690    /// A per-agent susceptibility outside the range is refused the same way the
691    /// default is, and the message names the agent as well as the file, because
692    /// a table of reviewers needs to say which row is wrong.
693    #[test]
694    fn a_per_agent_susceptibility_is_checked_and_names_the_agent() {
695        let dir = tempfile::tempdir().unwrap();
696        fs::write(
697            dir.path().join("vissue.toml"),
698            "[consensus.susceptibility_of]\nmaintainer = 1.5\n",
699        )
700        .unwrap();
701        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
702        let err = VissueConfig::load(&layout).unwrap_err().to_string();
703        assert!(err.contains("maintainer"), "{err}");
704        assert!(err.contains("vissue.toml"), "{err}");
705    }
706
707    /// Susceptibility merges agent by agent, like the trust rows: a file that
708    /// pins one reviewer must not drop the others.
709    #[test]
710    fn a_susceptibility_row_overrides_only_the_agent_it_names() {
711        let dir = tempfile::tempdir().unwrap();
712        fs::write(
713            dir.path().join("vissue.toml"),
714            "[consensus.susceptibility_of]\nmaintainer = 0.2\nreviewer = 0.6\n",
715        )
716        .unwrap();
717        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
718        fs::create_dir_all(layout.projects_dir()).unwrap();
719        fs::write(
720            layout.projects_dir().join("issues.config.toml"),
721            "[consensus.susceptibility_of]\nmaintainer = 0.4\n",
722        )
723        .unwrap();
724
725        let cfg = VissueConfig::load(&layout).unwrap().consensus;
726        assert_eq!(cfg.susceptibility_of.get("maintainer"), Some(&0.4));
727        assert_eq!(
728            cfg.susceptibility_of.get("reviewer"),
729            Some(&0.6),
730            "a row the second file says nothing about survives"
731        );
732    }
733
734    /// The whole range is usable, ends included: zero self-weight is the
735    /// periodic case the consensus report exists to name, and one is an agent
736    /// that listens to nobody.
737    #[test]
738    fn the_ends_of_the_self_weight_range_are_accepted() {
739        for value in ["0.0", "1.0"] {
740            let dir = tempfile::tempdir().unwrap();
741            fs::write(
742                dir.path().join("vissue.toml"),
743                format!("[consensus]\nself_weight = {value}\n"),
744            )
745            .unwrap();
746            let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
747            let cfg = VissueConfig::load(&layout).expect(value);
748            assert_eq!(cfg.consensus.self_weight, value.parse::<f64>().unwrap());
749        }
750    }
751
752    /// Trust merges row by row, like every other override. A file that retunes
753    /// one agent must not silently drop the rows it says nothing about.
754    #[test]
755    fn a_trust_row_overrides_only_the_agent_it_names() {
756        let dir = tempfile::tempdir().unwrap();
757        fs::write(
758            dir.path().join("vissue.toml"),
759            "[consensus.trust]\nalice = { bob = 1.0 }\ncarol = { alice = 1.0 }\n",
760        )
761        .unwrap();
762        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
763        fs::create_dir_all(layout.projects_dir()).unwrap();
764        fs::write(
765            layout.projects_dir().join("issues.config.toml"),
766            "[consensus.trust]\nalice = { carol = 4.0 }\n",
767        )
768        .unwrap();
769
770        let cfg = VissueConfig::load(&layout).unwrap();
771        assert_eq!(
772            cfg.consensus
773                .trust
774                .get("alice")
775                .and_then(|r| r.get("carol")),
776            Some(&4.0),
777            "the named row is replaced whole"
778        );
779        assert!(
780            cfg.consensus
781                .trust
782                .get("alice")
783                .is_some_and(|r| !r.contains_key("bob")),
784            "replaced, not merged into: {:?}",
785            cfg.consensus.trust
786        );
787        assert_eq!(
788            cfg.consensus
789                .trust
790                .get("carol")
791                .and_then(|r| r.get("alice")),
792            Some(&1.0),
793            "a row the second file says nothing about survives"
794        );
795    }
796
797    /// Nothing configured is the shape the consensus verb reduces to a tally in.
798    #[test]
799    fn the_consensus_defaults_converge_on_their_own() {
800        let dir = tempfile::tempdir().unwrap();
801        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
802        let cfg = VissueConfig::load(&layout).unwrap().consensus;
803        assert!(cfg.trust.is_empty());
804        assert!(
805            cfg.self_weight > 0.0,
806            "a zero diagonal is what makes a trust graph periodic"
807        );
808        assert!(cfg.tolerance > 0.0 && cfg.max_iterations > 0);
809    }
810
811    #[test]
812    fn config_defaults_when_no_files_present() {
813        let dir = tempfile::tempdir().unwrap();
814        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
815        let cfg = VissueConfig::load(&layout).unwrap();
816        assert_eq!(cfg.issues.default_priority, 'C');
817        assert_eq!(cfg.issues.id_length, 4);
818    }
819
820    #[test]
821    fn prefix_scoped_config_overrides_root_config() {
822        let dir = tempfile::tempdir().unwrap();
823        fs::write(
824            dir.path().join("vissue.toml"),
825            "[issues]\ndefault_priority = \"B\"\nid_length = 5\n",
826        )
827        .unwrap();
828        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
829        let cfg = VissueConfig::load(&layout).unwrap();
830        assert_eq!(cfg.issues.default_priority, 'B');
831        assert_eq!(cfg.issues.id_length, 5);
832
833        fs::create_dir_all(layout.projects_dir()).unwrap();
834        fs::write(
835            layout.projects_dir().join("issues.config.toml"),
836            "[issues]\ndefault_priority = \"A\"\nid_length = 6\n",
837        )
838        .unwrap();
839        let cfg = VissueConfig::load(&layout).unwrap();
840        assert_eq!(cfg.issues.default_priority, 'A');
841        assert_eq!(cfg.issues.id_length, 6);
842    }
843
844    #[test]
845    fn a_partial_override_keeps_the_keys_it_does_not_name() {
846        let dir = tempfile::tempdir().unwrap();
847        fs::write(
848            dir.path().join("vissue.toml"),
849            "[issues]\ndefault_priority = \"B\"\nid_length = 5\nstale_claim_days = 3\n",
850        )
851        .unwrap();
852        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
853        fs::create_dir_all(layout.projects_dir()).unwrap();
854        fs::write(
855            layout.projects_dir().join("issues.config.toml"),
856            "[issues]\nid_length = 6\n",
857        )
858        .unwrap();
859
860        let cfg = VissueConfig::load(&layout).unwrap();
861        assert_eq!(cfg.issues.id_length, 6, "the named key is overridden");
862        assert_eq!(
863            cfg.issues.default_priority, 'B',
864            "an unnamed key keeps the root value"
865        );
866        assert_eq!(cfg.issues.stale_claim_days, 3);
867    }
868
869    /// A seat file names the tracker the bare command means.
870    #[test]
871    fn a_seat_file_names_a_tracker() {
872        let dir = tempfile::tempdir().unwrap();
873        let tracker = tempfile::tempdir().unwrap();
874        let path = dir.path().join("config.toml");
875        fs::write(
876            &path,
877            format!("root = {:?}\n", tracker.path().display().to_string()),
878        )
879        .unwrap();
880        assert_eq!(
881            SeatConfig::read(&path).unwrap().canonicalize().unwrap(),
882            tracker.path().canonicalize().unwrap()
883        );
884    }
885
886    /// A file that is absent, unparseable, empty, or names a directory that is
887    /// not there says nothing rather than failing: this is the fallback path,
888    /// and the working directory below it still gives the caller a message.
889    #[test]
890    fn a_seat_file_that_says_nothing_usable_says_nothing() {
891        let dir = tempfile::tempdir().unwrap();
892        assert!(SeatConfig::read(&dir.path().join("absent.toml")).is_none());
893        for text in [
894            "",
895            "root = \"\"\n",
896            "root = \"/nonexistent/tracker\"\n",
897            "root =",
898        ] {
899            let path = dir.path().join("config.toml");
900            fs::write(&path, text).unwrap();
901            assert!(SeatConfig::read(&path).is_none(), "{text:?}");
902        }
903    }
904
905    /// The router owns this file too, and the two read it for different keys.
906    /// A seat that has routes still gets a root out of it, and the router
907    /// still parses a file that names one.
908    #[test]
909    fn the_seat_root_shares_the_file_the_router_reads() {
910        let dir = tempfile::tempdir().unwrap();
911        let tracker = tempfile::tempdir().unwrap();
912        let path = dir.path().join("config.toml");
913        fs::write(
914            &path,
915            format!(
916                "root = {:?}\n\n[layouts.other]\nroot = \"/somewhere\"\nprefix = \"Issues\"\n\n[routes]\nthing = \"other\"\n",
917                tracker.path().display().to_string()
918            ),
919        )
920        .unwrap();
921        assert_eq!(
922            SeatConfig::read(&path).unwrap().canonicalize().unwrap(),
923            tracker.path().canonicalize().unwrap()
924        );
925        // The router's own parse is strict about unknown keys, so the same
926        // bytes have to be readable by it: one file, two readers.
927        crate::router::Router::from_file(Layout::new(dir.path(), DEFAULT_PREFIX), &path)
928            .expect("the router reads the same file");
929    }
930
931    /// The order the root is decided in, with nothing global touched.
932    #[test]
933    fn the_caller_beats_the_environment_beats_where_you_stand() {
934        let named = PathBuf::from("/named");
935        let from_env = PathBuf::from("/env");
936        let seat = PathBuf::from("/seat");
937        let here = PathBuf::from("/here");
938
939        // Something the caller named wins, and is never a guess.
940        assert_eq!(
941            choose_root(
942                Some(&named),
943                Some(from_env.clone()),
944                &here,
945                false,
946                Some(seat.clone())
947            ),
948            (named.clone(), false)
949        );
950        // Then the environment.
951        assert_eq!(
952            choose_root(
953                None,
954                Some(from_env.clone()),
955                &here,
956                true,
957                Some(seat.clone())
958            ),
959            (from_env, false)
960        );
961        // Standing in a tracker means that tracker, over the seat's default:
962        // the caller is the more specific of the two.
963        assert_eq!(
964            choose_root(None, None, &here, true, Some(seat.clone())),
965            (here.clone(), false)
966        );
967        // Standing nowhere in particular, the seat's own tracker.
968        assert_eq!(
969            choose_root(None, None, &here, false, Some(seat.clone())),
970            (seat, false)
971        );
972        // And with no seat file, the working directory as a guess, which is
973        // what `require_tracker` refuses when it holds no tracker.
974        assert_eq!(choose_root(None, None, &here, false, None), (here, true));
975    }
976}