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