Skip to main content

vissue_core/
config.rs

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