Skip to main content

vissue_core/
router.rs

1//! User-level project router: named projects can live on another checkout.
2//!
3//! `Layout::resolve` is still the process default. A file at
4//! `$VISSUE_CONFIG` or `$XDG_CONFIG_HOME/vissue/config.toml` maps a project
5//! name onto a `{root, prefix}` plus an on-disk directory. A named route
6//! wins over `--root` / `VISSUE_ROOT`, because callers that inject a vault
7//! root still need those names to land in the routed file. `VISSUE_NO_ROUTE`
8//! or a missing config file restores single-layout behaviour.
9
10use 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/// One project as the router names it: the layout to read, the directory
23/// under that layout's prefix, and the route key the caller used.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ProjectRef {
26    /// Tracker that holds the file.
27    pub layout: Layout,
28    /// Directory name under `layout.prefix()`, which is also the id prefix.
29    pub dir: String,
30    /// Route key or, when unrouted, the same string as `dir`.
31    pub key: String,
32}
33
34/// A heading found by id, together with the layout that holds it.
35#[derive(Debug, Clone)]
36pub struct RouteHit {
37    /// Layout that contains the heading.
38    pub layout: Layout,
39    /// Project directory the heading lives in.
40    pub project: String,
41    /// Parsed heading.
42    pub heading: IssueHeading,
43    /// Path of the `issues.org` that defined it.
44    pub path: PathBuf,
45}
46
47/// User-level map of project name to layout, plus the process default.
48#[derive(Debug, Clone)]
49pub struct Router {
50    default: Layout,
51    /// Named layouts from `[layouts.*]`, excluding any that equal `default`.
52    named: BTreeMap<String, Layout>,
53    /// Lowercased route key -> (layout name or "default", on-disk directory).
54    routes: BTreeMap<String, (String, String)>,
55}
56
57#[derive(Debug, Deserialize, Default)]
58#[serde(deny_unknown_fields, default)]
59struct UserConfig {
60    /// Which tracker the seat means when nobody names one. Read by
61    /// `Layout::resolve` rather than here, and declared so that naming it does
62    /// not make the file unreadable to the router.
63    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    /// A router that never leaves `default`. Used when routing is off.
93    #[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    /// Load `$VISSUE_CONFIG` or the XDG file over `default`.
103    ///
104    /// A missing default-path file is a no-op. `VISSUE_NO_ROUTE` set to a
105    /// non-empty value other than `0` / `false` ignores the file.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if an explicit `VISSUE_CONFIG` path is missing, the
110    /// file cannot be read or parsed, a route names an unknown layout, or a
111    /// layout root is relative after expansion.
112    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    /// Load an explicit config file. Tests and callers that already resolved
129    /// the path use this.
130    ///
131    /// # Errors
132    ///
133    /// Same as [`Self::load`].
134    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                // A `[layouts.*]` that equalled default was dropped; treat
165                // that name as the process default so a documentary alias
166                // still loads.
167                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    /// A `[layouts.*]` entry by name, or the default for `default`; `None`
193    /// when the user's config names no such layout.
194    #[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    /// The process default, from `--root` / `VISSUE_ROOT` / cwd.
203    #[must_use]
204    pub fn default_layout(&self) -> &Layout {
205        &self.default
206    }
207
208    /// Whether any route is configured.
209    #[must_use]
210    pub fn is_routed(&self) -> bool {
211        !self.routes.is_empty()
212    }
213
214    /// Resolve a caller-facing project name to a layout and on-disk directory.
215    #[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    /// Unique layouts, `default` first, then named ones. Equality is
234    /// `(canonical root, prefix)`.
235    #[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    /// Projects an unscoped `list` / `projects` should show.
249    ///
250    /// Default-layout directories whose name is an identity route key are
251    /// hidden. Alias keys appear; the alias directory on the routed layout
252    /// does not appear under its raw name unless that name is also a default
253    /// project.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if a projects directory cannot be read.
258    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    /// Ids already used for `dir` on any unique layout, so a create can
289    /// refuse a suffix the twin file already holds.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if a twin file exists and cannot be parsed.
294    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    /// The twin files an id reservation has to read, rather than the ids in
305    /// them.
306    ///
307    /// Reading the ids here and minting later is a read outside the lock that
308    /// protects the write. Two creates for the same project in two roots each
309    /// see the other's file before either has written, take different locks,
310    /// because the locks are per file, and can mint the same suffix. A duplicate
311    /// across layouts is not a cosmetic clash: `find_by_id` reports
312    /// `DuplicateId` for it, so the issue becomes unreachable by id.
313    ///
314    /// So the caller hands over paths and the mint reads them under the same
315    /// lock set it writes under.
316    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    /// Locate one id. The longest matching route key or known project
324    /// directory is tried first; then every remaining unique layout.
325    ///
326    /// # Errors
327    ///
328    /// [`Error::IssueNotFound`] when no layout has the id.
329    /// [`Error::DuplicateId`] when two distinct layouts define it.
330    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    /// Ids that appear under more than one distinct unique layout.
402    ///
403    /// # Errors
404    ///
405    /// Returns an error if a project file cannot be read.
406    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        // from_file requires the file; load() is the missing-path no-op.
568        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}