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