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