1use anyhow::{Context, anyhow};
11use serde::Deserialize;
12use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use crate::config::{DEFAULT_PREFIX, Layout};
17use crate::error::{Error, Result};
18use crate::model::IssueHeading;
19use crate::process_env;
20use crate::store::{self, IssueDoc};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ProjectRef {
26 pub layout: Layout,
28 pub dir: String,
30 pub key: String,
32}
33
34#[derive(Debug, Clone)]
36pub struct RouteHit {
37 pub layout: Layout,
39 pub project: String,
41 pub heading: IssueHeading,
43 pub path: PathBuf,
45}
46
47#[derive(Debug, Clone)]
49pub struct Router {
50 default: Layout,
51 named: BTreeMap<String, Layout>,
53 routes: BTreeMap<String, (String, String)>,
55}
56
57#[derive(Debug, Deserialize, Default)]
58#[serde(deny_unknown_fields, default)]
59struct UserConfig {
60 layouts: BTreeMap<String, LayoutSpec>,
61 routes: BTreeMap<String, RouteSpec>,
62}
63
64#[derive(Debug, Deserialize)]
65#[serde(deny_unknown_fields)]
66struct LayoutSpec {
67 root: String,
68 #[serde(default = "default_prefix_string")]
69 prefix: String,
70}
71
72fn default_prefix_string() -> String {
73 DEFAULT_PREFIX.to_string()
74}
75
76#[derive(Debug, Deserialize)]
77#[serde(untagged)]
78enum RouteSpec {
79 LayoutName(String),
80 Table {
81 layout: String,
82 #[serde(default)]
83 project_dir: Option<String>,
84 },
85}
86
87impl Router {
88 #[must_use]
90 pub fn unrouted(default: Layout) -> Self {
91 Self {
92 default,
93 named: BTreeMap::new(),
94 routes: BTreeMap::new(),
95 }
96 }
97
98 pub fn load(default: Layout) -> Result<Self> {
109 if routing_disabled() {
110 return Ok(Self::unrouted(default));
111 }
112 let Some(path) = user_config_path() else {
113 return Ok(Self::unrouted(default));
114 };
115 if !path.exists() {
116 if process_env::var("VISSUE_CONFIG").is_ok() {
117 return Err(anyhow!("VISSUE_CONFIG {} does not exist", path.display()).into());
118 }
119 return Ok(Self::unrouted(default));
120 }
121 Self::from_file(default, &path)
122 }
123
124 pub fn from_file(default: Layout, path: &Path) -> Result<Self> {
131 let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
132 let parsed: UserConfig =
133 toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
134 Self::from_config(default, parsed)
135 }
136
137 fn from_config(default: Layout, parsed: UserConfig) -> Result<Self> {
138 let default_key = layout_key(&default);
139 let mut named = BTreeMap::new();
140 for (name, spec) in &parsed.layouts {
141 let layout = Layout::new(expand_path(&spec.root)?, spec.prefix.clone());
142 if layout_key(&layout) == default_key {
143 continue;
144 }
145 named.insert(name.clone(), layout);
146 }
147 let mut routes = BTreeMap::new();
148 for (key, spec) in parsed.routes {
149 let (layout_name, dir) = match spec {
150 RouteSpec::LayoutName(layout) => (layout, key.clone()),
151 RouteSpec::Table {
152 layout,
153 project_dir,
154 } => {
155 let dir = project_dir.unwrap_or_else(|| key.clone());
156 (layout, dir)
157 }
158 };
159 if layout_name != "default" && !named.contains_key(&layout_name) {
160 let named_as_default = parsed.layouts.get(&layout_name).is_some_and(|spec| {
164 expand_path(&spec.root).ok().is_some_and(|root| {
165 layout_key(&Layout::new(root, spec.prefix.clone())) == default_key
166 })
167 });
168 if !named_as_default {
169 return Err(
170 anyhow!("route {key:?} names unknown layout {layout_name:?}").into(),
171 );
172 }
173 }
174 let store_as = if named.contains_key(&layout_name) {
175 layout_name
176 } else {
177 "default".to_string()
178 };
179 routes.insert(key.to_lowercase(), (store_as, dir));
180 }
181 Ok(Self {
182 default,
183 named,
184 routes,
185 })
186 }
187
188 #[must_use]
190 pub fn default_layout(&self) -> &Layout {
191 &self.default
192 }
193
194 #[must_use]
196 pub fn is_routed(&self) -> bool {
197 !self.routes.is_empty()
198 }
199
200 #[must_use]
202 pub fn route(&self, project: &str) -> ProjectRef {
203 let key = project.to_lowercase();
204 if let Some((layout_name, dir)) = self.routes.get(&key) {
205 let layout = self.layout_named(layout_name).clone();
206 return ProjectRef {
207 layout,
208 dir: dir.clone(),
209 key: project.to_string(),
210 };
211 }
212 ProjectRef {
213 layout: self.default.clone(),
214 dir: project.to_string(),
215 key: project.to_string(),
216 }
217 }
218
219 #[must_use]
222 pub fn unique_layouts(&self) -> Vec<&Layout> {
223 let mut out = Vec::with_capacity(1 + self.named.len());
224 out.push(&self.default);
225 let default_key = layout_key(&self.default);
226 for layout in self.named.values() {
227 if layout_key(layout) != default_key {
228 out.push(layout);
229 }
230 }
231 out
232 }
233
234 pub fn visible_projects(&self) -> Result<Vec<ProjectRef>> {
245 let identity_keys: Vec<String> = self
246 .routes
247 .iter()
248 .filter(|(key, (_, dir))| key.as_str() == dir.to_lowercase())
249 .map(|(key, _)| key.clone())
250 .collect();
251 let mut out = Vec::new();
252 for name in store::list_projects(&self.default)? {
253 if identity_keys.iter().any(|k| k == &name.to_lowercase()) {
254 continue;
255 }
256 out.push(ProjectRef {
257 layout: self.default.clone(),
258 dir: name.clone(),
259 key: name,
260 });
261 }
262 for (key, (layout_name, dir)) in &self.routes {
263 let layout = self.layout_named(layout_name).clone();
264 out.push(ProjectRef {
265 layout,
266 dir: dir.clone(),
267 key: key.clone(),
268 });
269 }
270 out.sort_by_key(|a| a.key.to_lowercase());
271 Ok(out)
272 }
273
274 pub fn extra_ids_for(&self, dir: &str) -> Result<Vec<String>> {
281 let mut ids = Vec::new();
282 for layout in self.unique_layouts() {
283 let path = layout.project_issues_path(dir);
284 let doc = IssueDoc::parse_file(dir, &path)?;
285 ids.extend(doc.known_ids());
286 }
287 Ok(ids)
288 }
289
290 pub fn extra_id_paths_for(&self, dir: &str) -> Vec<PathBuf> {
303 self.unique_layouts()
304 .into_iter()
305 .map(|layout| layout.project_issues_path(dir))
306 .collect()
307 }
308
309 pub fn find_by_id(&self, id: &str) -> Result<RouteHit> {
317 let hint = self.hint_project(id);
318 let mut hits: Vec<RouteHit> = Vec::new();
319 let mut seen_keys = Vec::new();
320
321 if let Some(name) = hint.as_deref() {
322 let pref = self.route(name);
323 let key = layout_key(&pref.layout);
324 if let Some(hit) = lookup_in(&pref.layout, id)? {
325 hits.push(hit);
326 }
327 seen_keys.push(key);
328 }
329
330 for layout in self.unique_layouts() {
331 let key = layout_key(layout);
332 if seen_keys.iter().any(|k| k == &key) {
333 continue;
334 }
335 if let Some(hit) = lookup_in(layout, id)? {
336 hits.push(hit);
337 }
338 seen_keys.push(key);
339 }
340
341 match hits.len() {
342 0 => Err(Error::IssueNotFound { id: id.to_string() }),
343 1 => Ok(hits.remove(0)),
344 _ => Err(Error::DuplicateId {
345 id: id.to_string(),
346 paths: hits.into_iter().map(|h| h.path).collect(),
347 }),
348 }
349 }
350
351 fn hint_project(&self, id: &str) -> Option<String> {
352 let mut names: Vec<String> = self.routes.keys().cloned().collect();
353 for (_, dir) in self.routes.values() {
354 names.push(dir.to_lowercase());
355 }
356 if let Ok(projects) = store::list_projects(&self.default) {
357 names.extend(projects.into_iter().map(|p| p.to_lowercase()));
358 }
359 let mut best: Option<String> = None;
360 for name in names {
361 if name.is_empty() {
362 continue;
363 }
364 if let Some(rest) = id.to_lowercase().strip_prefix(&name)
365 && rest.starts_with('-')
366 && rest.len() > 1
367 && best.as_ref().is_none_or(|b| name.len() > b.len())
368 {
369 best = Some(name);
370 }
371 }
372 best.or_else(|| {
373 id.split_once('-')
374 .map(|(head, _)| head.to_string())
375 .filter(|h| !h.is_empty())
376 })
377 }
378
379 fn layout_named(&self, name: &str) -> &Layout {
380 if name == "default" {
381 &self.default
382 } else {
383 self.named.get(name).unwrap_or(&self.default)
384 }
385 }
386
387 pub fn duplicate_ids(&self) -> Result<Vec<(String, Vec<PathBuf>)>> {
393 let mut map: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
394 for layout in self.unique_layouts() {
395 for (project, heading) in store::load_all(layout)? {
396 map.entry(heading.id)
397 .or_default()
398 .push(layout.project_issues_path(&project));
399 }
400 }
401 Ok(map
402 .into_iter()
403 .filter(|(_, paths)| paths.len() > 1)
404 .collect())
405 }
406}
407
408fn lookup_in(layout: &Layout, id: &str) -> Result<Option<RouteHit>> {
409 match store::find_by_id(layout, id)? {
410 Some((heading, path, project)) => Ok(Some(RouteHit {
411 layout: layout.clone(),
412 project,
413 heading,
414 path,
415 })),
416 None => Ok(None),
417 }
418}
419
420fn layout_key(layout: &Layout) -> (PathBuf, String) {
421 let root = layout
422 .root()
423 .canonicalize()
424 .unwrap_or_else(|_| layout.root().to_path_buf());
425 (root, layout.prefix().to_string())
426}
427
428fn routing_disabled() -> bool {
429 match process_env::var("VISSUE_NO_ROUTE") {
430 Ok(v) => {
431 let t = v.trim();
432 !t.is_empty() && t != "0" && !t.eq_ignore_ascii_case("false")
433 }
434 Err(_) => false,
435 }
436}
437
438fn user_config_path() -> Option<PathBuf> {
439 if let Ok(raw) = process_env::var("VISSUE_CONFIG") {
440 let trimmed = raw.trim();
441 if !trimmed.is_empty() {
442 return Some(PathBuf::from(trimmed));
443 }
444 }
445 let base = process_env::var("XDG_CONFIG_HOME")
446 .ok()
447 .map(PathBuf::from)
448 .or_else(|| {
449 process_env::var("HOME")
450 .ok()
451 .map(|h| PathBuf::from(h).join(".config"))
452 })?;
453 Some(base.join("vissue/config.toml"))
454}
455
456fn expand_path(raw: &str) -> Result<PathBuf> {
457 let trimmed = raw.trim();
458 if trimmed.is_empty() {
459 return Err(anyhow!("layout root is empty").into());
460 }
461 let with_home = if trimmed == "~" {
462 home_dir()?
463 } else if let Some(rest) = trimmed.strip_prefix("~/") {
464 format!("{}/{}", home_dir()?, rest)
465 } else {
466 trimmed.to_string()
467 };
468 let expanded = expand_vars(&with_home)?;
469 let path = PathBuf::from(&expanded);
470 if !path.is_absolute() {
471 return Err(anyhow!(
472 "layout root must be absolute after ~ and environment expansion, got {raw:?}"
473 )
474 .into());
475 }
476 Ok(path)
477}
478
479fn home_dir() -> Result<String> {
480 process_env::var("HOME")
481 .or_else(|_| process_env::var("USERPROFILE"))
482 .map_err(|_| anyhow!("~ in a layout root requires HOME"))
483 .map_err(Error::from)
484}
485
486fn expand_vars(input: &str) -> Result<String> {
487 let chars: Vec<char> = input.chars().collect();
488 let mut out = String::new();
489 let mut i = 0;
490 while i < chars.len() {
491 if chars[i] == '$' {
492 if i + 1 < chars.len() && chars[i + 1] == '{' {
493 if let Some(rel) = chars[i + 2..].iter().position(|&c| c == '}') {
494 let name: String = chars[i + 2..i + 2 + rel].iter().collect();
495 out.push_str(&lookup_var(&name)?);
496 i = i + 3 + rel;
497 continue;
498 }
499 } else {
500 let start = i + 1;
501 let mut end = start;
502 while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_')
503 {
504 end += 1;
505 }
506 if end > start {
507 let name: String = chars[start..end].iter().collect();
508 out.push_str(&lookup_var(&name)?);
509 i = end;
510 continue;
511 }
512 }
513 }
514 out.push(chars[i]);
515 i += 1;
516 }
517 Ok(out)
518}
519
520fn lookup_var(name: &str) -> Result<String> {
521 process_env::var(name)
522 .map_err(|_| anyhow!("environment variable {name} is unset in layout root"))
523 .map_err(Error::from)
524}
525
526#[cfg(test)]
527#[allow(deprecated_safe_2024)]
528mod tests {
529 use super::*;
530 use std::fs;
531
532 fn seed(layout: &Layout, project: &str, id: &str, title: &str) {
533 let path = layout.project_issues_path(project);
534 fs::create_dir_all(path.parent().unwrap()).unwrap();
535 fs::write(
536 &path,
537 format!("* TODO {title}\n:PROPERTIES:\n:ID: {id}\n:END:\n"),
538 )
539 .unwrap();
540 }
541
542 fn write_cfg(dir: &Path, body: &str) -> PathBuf {
543 let path = dir.join("config.toml");
544 fs::write(&path, body).unwrap();
545 path
546 }
547
548 #[test]
549 fn missing_config_is_a_noop() {
550 let tmp = tempfile::tempdir().unwrap();
551 let layout = Layout::new(tmp.path(), "Software");
552 let router = Router::from_file(layout.clone(), &tmp.path().join("absent.toml"));
553 assert!(router.is_err());
555 let router = Router::unrouted(layout);
556 assert!(!router.is_routed());
557 let pref = router.route("surf");
558 assert_eq!(pref.dir, "surf");
559 assert_eq!(pref.layout, router.default_layout().clone());
560 }
561
562 #[test]
563 fn a_named_route_wins_over_the_default_root() {
564 let tmp = tempfile::tempdir().unwrap();
565 let vault = tmp.path().join("vault");
566 let work = tmp.path().join("work");
567 fs::create_dir_all(&vault).unwrap();
568 fs::create_dir_all(&work).unwrap();
569 seed(&Layout::new(&work, "Issues"), "surf", "surf-abcd", "routed");
570 seed(
571 &Layout::new(&vault, "Software"),
572 "surf",
573 "surf-old1",
574 "historical",
575 );
576 let cfg = write_cfg(
577 tmp.path(),
578 &format!(
579 "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n\n[routes]\nsurf = \"work\"\n",
580 work.display()
581 ),
582 );
583 let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
584 let pref = router.route("surf");
585 assert_eq!(pref.dir, "surf");
586 assert_eq!(pref.layout.prefix(), "Issues");
587 assert_eq!(pref.layout.root(), work.as_path());
588 let hit = router.find_by_id("surf-abcd").unwrap();
589 assert_eq!(hit.heading.title, "routed");
590 let old = router.find_by_id("surf-old1").unwrap();
591 assert_eq!(old.heading.title, "historical");
592 }
593
594 #[test]
595 fn an_alias_writes_an_existing_directory_without_renaming_ids() {
596 let tmp = tempfile::tempdir().unwrap();
597 let vault = tmp.path().join("vault");
598 let work = tmp.path().join("work");
599 fs::create_dir_all(&vault).unwrap();
600 fs::create_dir_all(&work).unwrap();
601 seed(
602 &Layout::new(&work, "Issues"),
603 "solver",
604 "solver-aaaa",
605 "site ticket",
606 );
607 seed(
608 &Layout::new(&vault, "Software"),
609 "solver",
610 "solver-bbbb",
611 "product ticket",
612 );
613 let cfg = write_cfg(
614 tmp.path(),
615 &format!(
616 "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n\n[routes.solver-work]\nlayout = \"work\"\nproject_dir = \"solver\"\n",
617 work.display()
618 ),
619 );
620 let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
621 let pref = router.route("solver-work");
622 assert_eq!(pref.dir, "solver");
623 assert_eq!(pref.layout.prefix(), "Issues");
624 let raw = router.route("solver");
625 assert_eq!(raw.layout.prefix(), "Software");
626 assert_eq!(raw.dir, "solver");
627 let names: Vec<_> = router
628 .visible_projects()
629 .unwrap()
630 .into_iter()
631 .map(|p| p.key)
632 .collect();
633 assert!(names.iter().any(|n| n == "solver"), "{names:?}");
634 assert!(names.iter().any(|n| n == "solver-work"), "{names:?}");
635 let hit = router.find_by_id("solver-aaaa").unwrap();
636 assert_eq!(hit.layout.prefix(), "Issues");
637 }
638
639 #[test]
640 fn a_layout_that_equals_default_is_not_scanned_twice() {
641 let tmp = tempfile::tempdir().unwrap();
642 let vault = tmp.path().join("vault");
643 fs::create_dir_all(&vault).unwrap();
644 seed(
645 &Layout::new(&vault, "Software"),
646 "only",
647 "only-zzzz",
648 "once",
649 );
650 let cfg = write_cfg(
651 tmp.path(),
652 &format!(
653 "[layouts.vault]\nroot = \"{}\"\nprefix = \"Software\"\n",
654 vault.display()
655 ),
656 );
657 let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
658 assert_eq!(router.unique_layouts().len(), 1);
659 let hit = router.find_by_id("only-zzzz").unwrap();
660 assert_eq!(hit.heading.title, "once");
661 }
662
663 #[test]
664 fn duplicate_id_on_two_distinct_layouts_is_an_error() {
665 let tmp = tempfile::tempdir().unwrap();
666 let vault = tmp.path().join("vault");
667 let work = tmp.path().join("work");
668 fs::create_dir_all(&vault).unwrap();
669 fs::create_dir_all(&work).unwrap();
670 seed(&Layout::new(&work, "Issues"), "surf", "surf-same", "a");
671 seed(&Layout::new(&vault, "Software"), "surf", "surf-same", "b");
672 let cfg = write_cfg(
673 tmp.path(),
674 &format!(
675 "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n",
676 work.display()
677 ),
678 );
679 let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
680 let err = router.find_by_id("surf-same").unwrap_err();
681 match err {
682 Error::DuplicateId { id, paths } => {
683 assert_eq!(id, "surf-same");
684 assert_eq!(paths.len(), 2);
685 }
686 other => panic!("expected DuplicateId, got {other}"),
687 }
688 }
689
690 #[test]
691 fn extra_ids_union_the_twin_file() {
692 let tmp = tempfile::tempdir().unwrap();
693 let vault = tmp.path().join("vault");
694 let work = tmp.path().join("work");
695 fs::create_dir_all(&vault).unwrap();
696 fs::create_dir_all(&work).unwrap();
697 seed(&Layout::new(&vault, "Software"), "surf", "surf-old1", "old");
698 let cfg = write_cfg(
699 tmp.path(),
700 &format!(
701 "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n\n[routes]\nsurf = \"work\"\n",
702 work.display()
703 ),
704 );
705 let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
706 let ids = router.extra_ids_for("surf").unwrap();
707 assert!(ids.iter().any(|i| i == "surf-old1"), "{ids:?}");
708 }
709
710 #[test]
711 fn relative_roots_are_rejected() {
712 let tmp = tempfile::tempdir().unwrap();
713 let cfg = write_cfg(
714 tmp.path(),
715 "[layouts.work]\nroot = \"relative/path\"\nprefix = \"Issues\"\n",
716 );
717 let err = Router::from_file(Layout::new(tmp.path(), "Software"), &cfg).unwrap_err();
718 assert!(err.to_string().contains("absolute"), "{err}");
719 }
720
721 #[test]
722 fn tilde_and_env_expand_in_roots() {
723 let tmp = tempfile::tempdir().unwrap();
724 let work = tmp.path().join("work");
725 fs::create_dir_all(&work).unwrap();
726 crate::process_env::override_var("HOME", Some(tmp.path().to_str().unwrap()));
727 crate::process_env::override_var("WORKROOT", Some(work.to_str().unwrap()));
728 let cfg_home = write_cfg(
729 tmp.path(),
730 "[layouts.work]\nroot = \"~/work\"\nprefix = \"Issues\"\n",
731 );
732 let router = Router::from_file(Layout::new(tmp.path(), "Software"), &cfg_home).unwrap();
733 assert_eq!(router.route("x").layout.prefix(), "Software");
734 assert_eq!(
735 router.named.get("work").map(|l| l.root().to_path_buf()),
736 Some(work.clone())
737 );
738 let cfg_env = write_cfg(
739 tmp.path(),
740 "[layouts.work]\nroot = \"$WORKROOT\"\nprefix = \"Issues\"\n",
741 );
742 let router = Router::from_file(Layout::new(tmp.path(), "Software"), &cfg_env).unwrap();
743 assert_eq!(
744 router.named.get("work").map(|l| l.root().to_path_buf()),
745 Some(work)
746 );
747 crate::process_env::clear_override("HOME");
748 crate::process_env::clear_override("WORKROOT");
749 }
750
751 #[test]
752 fn an_unknown_route_layout_is_a_load_error() {
753 let tmp = tempfile::tempdir().unwrap();
754 let cfg = write_cfg(tmp.path(), "[routes]\nsurf = \"missing\"\n");
755 let err = Router::from_file(Layout::new(tmp.path(), "Software"), &cfg).unwrap_err();
756 assert!(err.to_string().contains("unknown layout"), "{err}");
757 }
758
759 #[test]
760 fn no_route_env_disables_the_table() {
761 let tmp = tempfile::tempdir().unwrap();
762 let work = tmp.path().join("work");
763 fs::create_dir_all(&work).unwrap();
764 let cfg = write_cfg(
765 tmp.path(),
766 &format!(
767 "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n\n[routes]\nsurf = \"work\"\n",
768 work.display()
769 ),
770 );
771 crate::process_env::override_var("VISSUE_NO_ROUTE", Some("1"));
772 crate::process_env::override_var("VISSUE_CONFIG", Some(cfg.to_str().unwrap()));
773 let router = Router::load(Layout::new(tmp.path(), "Software")).unwrap();
774 assert!(!router.is_routed());
775 assert_eq!(router.route("surf").layout.prefix(), "Software");
776 crate::process_env::clear_override("VISSUE_NO_ROUTE");
777 crate::process_env::clear_override("VISSUE_CONFIG");
778 }
779}