1use anyhow::{Context, Result};
9use serde::Deserialize;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13pub const DEFAULT_PREFIX: &str = "Software";
15
16#[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 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 pub fn projects_dir(&self) -> PathBuf {
70 self.root.join(&self.prefix)
71 }
72
73 pub fn project_issues_path(&self, project: &str) -> PathBuf {
75 self.projects_dir().join(project).join("issues.org")
76 }
77}
78
79#[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#[derive(Debug, Clone, Deserialize)]
101#[serde(default)]
102pub struct IssuesSection {
103 pub default_priority: char,
105 pub id_length: usize,
107 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#[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#[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 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
180pub 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 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 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}