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