1use anyhow::Context;
9
10use crate::error::Result;
11use serde::Deserialize;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15pub const DEFAULT_PREFIX: &str = "Software";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Layout {
21 root: PathBuf,
22 prefix: String,
23}
24
25impl Layout {
26 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 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 pub fn root(&self) -> &Path {
72 &self.root
73 }
74
75 pub fn prefix(&self) -> &str {
77 &self.prefix
78 }
79
80 pub fn projects_dir(&self) -> PathBuf {
82 self.root.join(&self.prefix)
83 }
84
85 pub fn project_issues_path(&self, project: &str) -> PathBuf {
87 self.projects_dir().join(project).join("issues.org")
88 }
89}
90
91#[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#[derive(Debug, Clone, Deserialize)]
115#[serde(default)]
116pub struct IssuesSection {
117 pub default_priority: char,
119 pub id_length: usize,
121 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#[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#[derive(Debug, Clone, Default)]
163pub struct VissueConfig {
164 pub issues: IssuesSection,
166}
167
168#[derive(Debug, Clone, Default, Deserialize)]
169#[serde(default)]
170struct PrefixConfigFile {
171 issues: IssuesOverride,
172}
173
174impl VissueConfig {
175 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
200pub 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 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 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}