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::fs;
13use std::path::{Path, PathBuf};
14
15/// Directory under the root that holds one subdirectory per project.
16pub const DEFAULT_PREFIX: &str = "Software";
17
18/// Where the tracker lives: a root directory and the project prefix inside it.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Layout {
21    root: PathBuf,
22    prefix: String,
23}
24
25impl Layout {
26    /// Build a layout from an explicit root and prefix.
27    ///
28    /// An empty prefix falls back to [`DEFAULT_PREFIX`].
29    pub fn new(root: impl Into<PathBuf>, prefix: impl Into<String>) -> Self {
30        let prefix = prefix.into();
31        Self {
32            root: root.into(),
33            prefix: if prefix.is_empty() {
34                DEFAULT_PREFIX.to_string()
35            } else {
36                prefix
37            },
38        }
39    }
40
41    /// Resolve from explicit arguments, falling back to the environment, the
42    /// on-disk `vissue.toml`, and finally the compiled defaults.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if the current directory cannot be resolved, or if
47    /// `<root>/vissue.toml` exists but cannot be read or parsed.
48    pub fn resolve(root: Option<&Path>, prefix: Option<&str>) -> Result<Self> {
49        let root = match root {
50            Some(p) => p.to_path_buf(),
51            None => {
52                match std::env::var_os("ISSUE_ROOT").or_else(|| std::env::var_os("VISSUE_ROOT")) {
53                    Some(v) => PathBuf::from(v),
54                    None => std::env::current_dir().context("resolve current directory as root")?,
55                }
56            }
57        };
58        let prefix = match prefix {
59            Some(p) if !p.is_empty() => p.to_string(),
60            _ => match std::env::var("VISSUE_PREFIX") {
61                Ok(v) if !v.is_empty() => v,
62                _ => RootConfig::load(&root)?
63                    .prefix
64                    .unwrap_or_else(|| DEFAULT_PREFIX.to_string()),
65            },
66        };
67        Ok(Self::new(root, prefix))
68    }
69
70    /// Tracker root: the directory that holds `vissue.toml` and `prefix`.
71    pub fn root(&self) -> &Path {
72        &self.root
73    }
74
75    /// Directory name under [`Self::root`] that holds one subdirectory per project.
76    pub fn prefix(&self) -> &str {
77        &self.prefix
78    }
79
80    /// `<root>/<prefix>`: the directory scanned for projects.
81    pub fn projects_dir(&self) -> PathBuf {
82        self.root.join(&self.prefix)
83    }
84
85    /// `<root>/<prefix>/<project>/issues.org`.
86    pub fn project_issues_path(&self, project: &str) -> PathBuf {
87        self.projects_dir().join(project).join("issues.org")
88    }
89}
90
91/// `<root>/vissue.toml`, the product-level configuration file.
92#[derive(Debug, Clone, Default, Deserialize)]
93#[serde(default)]
94struct RootConfig {
95    prefix: Option<String>,
96    agent: Option<String>,
97    issues: IssuesOverride,
98}
99
100impl RootConfig {
101    fn load(root: &Path) -> Result<Self> {
102        let path = root.join("vissue.toml");
103        if !path.exists() {
104            return Ok(Self::default());
105        }
106        let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
107        toml::from_str(&raw)
108            .with_context(|| format!("parse {}", path.display()))
109            .map_err(crate::error::Error::from)
110    }
111}
112
113/// Knobs that shape newly created issues.
114#[derive(Debug, Clone, Deserialize)]
115#[serde(default)]
116pub struct IssuesSection {
117    /// Priority cookie applied when `create` is called without one.
118    pub default_priority: char,
119    /// Length in base36 characters of the random suffix in a generated id.
120    pub id_length: usize,
121    /// How long a claim may sit on a STARTED issue before hygiene calls it
122    /// stale.
123    pub stale_claim_days: i64,
124}
125
126impl Default for IssuesSection {
127    fn default() -> Self {
128        Self {
129            default_priority: 'C',
130            id_length: 4,
131            stale_claim_days: 7,
132        }
133    }
134}
135
136/// The subset of [`IssuesSection`] a configuration file names. A key left out
137/// of a file stays whatever the layer below it set, so a file that tunes one
138/// knob does not silently reset the others.
139#[derive(Debug, Clone, Default, Deserialize)]
140#[serde(default)]
141struct IssuesOverride {
142    default_priority: Option<char>,
143    id_length: Option<usize>,
144    stale_claim_days: Option<i64>,
145}
146
147impl IssuesOverride {
148    fn apply_to(&self, base: &mut IssuesSection) {
149        if let Some(value) = self.default_priority {
150            base.default_priority = value;
151        }
152        if let Some(value) = self.id_length {
153            base.id_length = value;
154        }
155        if let Some(value) = self.stale_claim_days {
156            base.stale_claim_days = value;
157        }
158    }
159}
160
161/// Effective configuration for one layout.
162#[derive(Debug, Clone, Default)]
163pub struct VissueConfig {
164    /// Knobs that shape newly created issues and hygiene thresholds.
165    pub issues: IssuesSection,
166}
167
168#[derive(Debug, Clone, Default, Deserialize)]
169#[serde(default)]
170struct PrefixConfigFile {
171    issues: IssuesOverride,
172}
173
174impl VissueConfig {
175    /// `<root>/<prefix>/issues.config.toml` overrides `<root>/vissue.toml`,
176    /// which overrides the compiled defaults. Neither file is required, and
177    /// each layer overrides key by key rather than wholesale.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if a configuration file exists but cannot be read or
182    /// parsed.
183    pub fn load(layout: &Layout) -> Result<Self> {
184        let mut issues = IssuesSection::default();
185        RootConfig::load(layout.root())?
186            .issues
187            .apply_to(&mut issues);
188        let path = layout.projects_dir().join("issues.config.toml");
189        if path.exists() {
190            let raw =
191                fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
192            let parsed: PrefixConfigFile =
193                toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
194            parsed.issues.apply_to(&mut issues);
195        }
196        Ok(Self { issues })
197    }
198}
199
200/// Who is claiming work here.
201///
202/// `VISSUE_AGENT` wins, then `agent` in `<root>/vissue.toml`, then
203/// `user@host`. The value is opaque: an agent should set `VISSUE_AGENT` to
204/// something stable enough to identify it across sessions, such as a model
205/// and session tag, and any string it picks is stored verbatim.
206pub fn identity(layout: &Layout) -> String {
207    if let Ok(value) = crate::process_env::var("VISSUE_AGENT") {
208        let value = value.trim();
209        if !value.is_empty() {
210            return value.to_string();
211        }
212    }
213    if let Ok(cfg) = RootConfig::load(layout.root())
214        && let Some(agent) = cfg.agent
215    {
216        let agent = agent.trim().to_string();
217        if !agent.is_empty() {
218            return agent;
219        }
220    }
221    format!("{}@{}", current_user(), current_host())
222}
223
224fn current_user() -> String {
225    for var in ["USER", "LOGNAME", "USERNAME"] {
226        if let Ok(value) = std::env::var(var)
227            && !value.trim().is_empty()
228        {
229            return value.trim().to_string();
230        }
231    }
232    "unknown".to_string()
233}
234
235fn current_host() -> String {
236    if let Ok(value) = std::env::var("HOSTNAME")
237        && !value.trim().is_empty()
238    {
239        return value.trim().to_string();
240    }
241    // HOSTNAME is not exported by every shell, so fall back to the file the
242    // system keeps it in.
243    for path in ["/etc/hostname", "/proc/sys/kernel/hostname"] {
244        if let Ok(text) = fs::read_to_string(path) {
245            let trimmed = text.trim();
246            if !trimmed.is_empty() {
247                return trimmed.to_string();
248            }
249        }
250    }
251    "unknown".to_string()
252}
253
254#[cfg(test)]
255#[allow(deprecated_safe_2024)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn layout_defaults_to_software_prefix() {
261        let layout = Layout::new("/somewhere", "");
262        assert_eq!(layout.prefix(), DEFAULT_PREFIX);
263        assert_eq!(
264            layout.project_issues_path("demo"),
265            Path::new("/somewhere/Software/demo/issues.org")
266        );
267    }
268
269    #[test]
270    fn explicit_prefix_wins() {
271        let dir = tempfile::tempdir().unwrap();
272        fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
273        let layout = Layout::resolve(Some(dir.path()), Some("tracker")).unwrap();
274        assert_eq!(layout.prefix(), "tracker");
275    }
276
277    #[test]
278    fn root_config_supplies_prefix() {
279        let dir = tempfile::tempdir().unwrap();
280        fs::write(dir.path().join("vissue.toml"), "prefix = \"projects\"\n").unwrap();
281        let layout = Layout::resolve(Some(dir.path()), None).unwrap();
282        assert_eq!(layout.prefix(), "projects");
283        assert_eq!(
284            layout.projects_dir(),
285            dir.path().join("projects"),
286            "projects dir follows the configured prefix"
287        );
288    }
289
290    /// `VISSUE_AGENT` is process-global, so the identity tests take turns.
291    static AGENT_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
292
293    #[test]
294    fn the_environment_names_the_claiming_identity_first() {
295        let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
296        let dir = tempfile::tempdir().unwrap();
297        fs::write(dir.path().join("vissue.toml"), "agent = \"from-file\"\n").unwrap();
298        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
299
300        crate::process_env::override_var("VISSUE_AGENT", Some("from-env"));
301        let from_env = identity(&layout);
302        crate::process_env::override_var("VISSUE_AGENT", Some("   "));
303        let blank_falls_through = identity(&layout);
304        crate::process_env::override_var("VISSUE_AGENT", None);
305        let from_file = identity(&layout);
306        crate::process_env::clear_override("VISSUE_AGENT");
307
308        assert_eq!(from_env, "from-env");
309        assert_eq!(
310            blank_falls_through, "from-file",
311            "a blank value is not an identity"
312        );
313        assert_eq!(from_file, "from-file");
314    }
315
316    #[test]
317    fn without_configuration_the_identity_is_user_at_host() {
318        let _guard = AGENT_ENV.lock().unwrap_or_else(|p| p.into_inner());
319        let dir = tempfile::tempdir().unwrap();
320        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
321        crate::process_env::override_var("VISSUE_AGENT", None);
322        let resolved = identity(&layout);
323        crate::process_env::clear_override("VISSUE_AGENT");
324        assert!(resolved.contains('@'), "{resolved}");
325        assert!(!resolved.starts_with('@'), "{resolved}");
326        assert!(!resolved.ends_with('@'), "{resolved}");
327    }
328
329    #[test]
330    fn the_stale_claim_threshold_is_configurable() {
331        let dir = tempfile::tempdir().unwrap();
332        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
333        assert_eq!(
334            VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
335            7
336        );
337
338        fs::write(
339            dir.path().join("vissue.toml"),
340            "[issues]\nstale_claim_days = 3\n",
341        )
342        .unwrap();
343        assert_eq!(
344            VissueConfig::load(&layout).unwrap().issues.stale_claim_days,
345            3
346        );
347    }
348
349    #[test]
350    fn config_defaults_when_no_files_present() {
351        let dir = tempfile::tempdir().unwrap();
352        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
353        let cfg = VissueConfig::load(&layout).unwrap();
354        assert_eq!(cfg.issues.default_priority, 'C');
355        assert_eq!(cfg.issues.id_length, 4);
356    }
357
358    #[test]
359    fn prefix_scoped_config_overrides_root_config() {
360        let dir = tempfile::tempdir().unwrap();
361        fs::write(
362            dir.path().join("vissue.toml"),
363            "[issues]\ndefault_priority = \"B\"\nid_length = 5\n",
364        )
365        .unwrap();
366        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
367        let cfg = VissueConfig::load(&layout).unwrap();
368        assert_eq!(cfg.issues.default_priority, 'B');
369        assert_eq!(cfg.issues.id_length, 5);
370
371        fs::create_dir_all(layout.projects_dir()).unwrap();
372        fs::write(
373            layout.projects_dir().join("issues.config.toml"),
374            "[issues]\ndefault_priority = \"A\"\nid_length = 6\n",
375        )
376        .unwrap();
377        let cfg = VissueConfig::load(&layout).unwrap();
378        assert_eq!(cfg.issues.default_priority, 'A');
379        assert_eq!(cfg.issues.id_length, 6);
380    }
381
382    #[test]
383    fn a_partial_override_keeps_the_keys_it_does_not_name() {
384        let dir = tempfile::tempdir().unwrap();
385        fs::write(
386            dir.path().join("vissue.toml"),
387            "[issues]\ndefault_priority = \"B\"\nid_length = 5\nstale_claim_days = 3\n",
388        )
389        .unwrap();
390        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
391        fs::create_dir_all(layout.projects_dir()).unwrap();
392        fs::write(
393            layout.projects_dir().join("issues.config.toml"),
394            "[issues]\nid_length = 6\n",
395        )
396        .unwrap();
397
398        let cfg = VissueConfig::load(&layout).unwrap();
399        assert_eq!(cfg.issues.id_length, 6, "the named key is overridden");
400        assert_eq!(
401            cfg.issues.default_priority, 'B',
402            "an unnamed key keeps the root value"
403        );
404        assert_eq!(cfg.issues.stale_claim_days, 3);
405    }
406}