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    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    /// A router that never leaves `default`. Used when routing is off.
89    #[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    /// Load `$VISSUE_CONFIG` or the XDG file over `default`.
99    ///
100    /// A missing default-path file is a no-op. `VISSUE_NO_ROUTE` set to a
101    /// non-empty value other than `0` / `false` ignores the file.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if an explicit `VISSUE_CONFIG` path is missing, the
106    /// file cannot be read or parsed, a route names an unknown layout, or a
107    /// layout root is relative after expansion.
108    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    /// Load an explicit config file. Tests and callers that already resolved
125    /// the path use this.
126    ///
127    /// # Errors
128    ///
129    /// Same as [`Self::load`].
130    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                // A `[layouts.*]` that equalled default was dropped; treat
161                // that name as the process default so a documentary alias
162                // still loads.
163                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    /// The process default, from `--root` / `VISSUE_ROOT` / cwd.
189    #[must_use]
190    pub fn default_layout(&self) -> &Layout {
191        &self.default
192    }
193
194    /// Whether any route is configured.
195    #[must_use]
196    pub fn is_routed(&self) -> bool {
197        !self.routes.is_empty()
198    }
199
200    /// Resolve a caller-facing project name to a layout and on-disk directory.
201    #[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    /// Unique layouts, `default` first, then named ones. Equality is
220    /// `(canonical root, prefix)`.
221    #[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    /// Projects an unscoped `list` / `projects` should show.
235    ///
236    /// Default-layout directories whose name is an identity route key are
237    /// hidden. Alias keys appear; the alias directory on the routed layout
238    /// does not appear under its raw name unless that name is also a default
239    /// project.
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if a projects directory cannot be read.
244    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    /// Ids already used for `dir` on any unique layout, so a create can
275    /// refuse a suffix the twin file already holds.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if a twin file exists and cannot be parsed.
280    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    /// Locate one id. The longest matching route key or known project
291    /// directory is tried first; then every remaining unique layout.
292    ///
293    /// # Errors
294    ///
295    /// [`Error::IssueNotFound`] when no layout has the id.
296    /// [`Error::DuplicateId`] when two distinct layouts define it.
297    pub fn find_by_id(&self, id: &str) -> Result<RouteHit> {
298        let hint = self.hint_project(id);
299        let mut hits: Vec<RouteHit> = Vec::new();
300        let mut seen_keys = Vec::new();
301
302        if let Some(name) = hint.as_deref() {
303            let pref = self.route(name);
304            let key = layout_key(&pref.layout);
305            if let Some(hit) = lookup_in(&pref.layout, id)? {
306                hits.push(hit);
307            }
308            seen_keys.push(key);
309        }
310
311        for layout in self.unique_layouts() {
312            let key = layout_key(layout);
313            if seen_keys.iter().any(|k| k == &key) {
314                continue;
315            }
316            if let Some(hit) = lookup_in(layout, id)? {
317                hits.push(hit);
318            }
319            seen_keys.push(key);
320        }
321
322        match hits.len() {
323            0 => Err(Error::IssueNotFound { id: id.to_string() }),
324            1 => Ok(hits.remove(0)),
325            _ => Err(Error::DuplicateId {
326                id: id.to_string(),
327                paths: hits.into_iter().map(|h| h.path).collect(),
328            }),
329        }
330    }
331
332    fn hint_project(&self, id: &str) -> Option<String> {
333        let mut names: Vec<String> = self.routes.keys().cloned().collect();
334        for (_, dir) in self.routes.values() {
335            names.push(dir.to_lowercase());
336        }
337        if let Ok(projects) = store::list_projects(&self.default) {
338            names.extend(projects.into_iter().map(|p| p.to_lowercase()));
339        }
340        let mut best: Option<String> = None;
341        for name in names {
342            if name.is_empty() {
343                continue;
344            }
345            if let Some(rest) = id.to_lowercase().strip_prefix(&name)
346                && rest.starts_with('-')
347                && rest.len() > 1
348                && best.as_ref().is_none_or(|b| name.len() > b.len())
349            {
350                best = Some(name);
351            }
352        }
353        best.or_else(|| {
354            id.split_once('-')
355                .map(|(head, _)| head.to_string())
356                .filter(|h| !h.is_empty())
357        })
358    }
359
360    fn layout_named(&self, name: &str) -> &Layout {
361        if name == "default" {
362            &self.default
363        } else {
364            self.named.get(name).unwrap_or(&self.default)
365        }
366    }
367
368    /// Ids that appear under more than one distinct unique layout.
369    ///
370    /// # Errors
371    ///
372    /// Returns an error if a project file cannot be read.
373    pub fn duplicate_ids(&self) -> Result<Vec<(String, Vec<PathBuf>)>> {
374        let mut map: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
375        for layout in self.unique_layouts() {
376            for (project, heading) in store::load_all(layout)? {
377                map.entry(heading.id)
378                    .or_default()
379                    .push(layout.project_issues_path(&project));
380            }
381        }
382        Ok(map
383            .into_iter()
384            .filter(|(_, paths)| paths.len() > 1)
385            .collect())
386    }
387}
388
389fn lookup_in(layout: &Layout, id: &str) -> Result<Option<RouteHit>> {
390    match store::find_by_id(layout, id)? {
391        Some((heading, path, project)) => Ok(Some(RouteHit {
392            layout: layout.clone(),
393            project,
394            heading,
395            path,
396        })),
397        None => Ok(None),
398    }
399}
400
401fn layout_key(layout: &Layout) -> (PathBuf, String) {
402    let root = layout
403        .root()
404        .canonicalize()
405        .unwrap_or_else(|_| layout.root().to_path_buf());
406    (root, layout.prefix().to_string())
407}
408
409fn routing_disabled() -> bool {
410    match process_env::var("VISSUE_NO_ROUTE") {
411        Ok(v) => {
412            let t = v.trim();
413            !t.is_empty() && t != "0" && !t.eq_ignore_ascii_case("false")
414        }
415        Err(_) => false,
416    }
417}
418
419fn user_config_path() -> Option<PathBuf> {
420    if let Ok(raw) = process_env::var("VISSUE_CONFIG") {
421        let trimmed = raw.trim();
422        if !trimmed.is_empty() {
423            return Some(PathBuf::from(trimmed));
424        }
425    }
426    let base = process_env::var("XDG_CONFIG_HOME")
427        .ok()
428        .map(PathBuf::from)
429        .or_else(|| {
430            process_env::var("HOME")
431                .ok()
432                .map(|h| PathBuf::from(h).join(".config"))
433        })?;
434    Some(base.join("vissue/config.toml"))
435}
436
437fn expand_path(raw: &str) -> Result<PathBuf> {
438    let trimmed = raw.trim();
439    if trimmed.is_empty() {
440        return Err(anyhow!("layout root is empty").into());
441    }
442    let with_home = if trimmed == "~" {
443        home_dir()?
444    } else if let Some(rest) = trimmed.strip_prefix("~/") {
445        format!("{}/{}", home_dir()?, rest)
446    } else {
447        trimmed.to_string()
448    };
449    let expanded = expand_vars(&with_home)?;
450    let path = PathBuf::from(&expanded);
451    if !path.is_absolute() {
452        return Err(anyhow!(
453            "layout root must be absolute after ~ and environment expansion, got {raw:?}"
454        )
455        .into());
456    }
457    Ok(path)
458}
459
460fn home_dir() -> Result<String> {
461    process_env::var("HOME")
462        .or_else(|_| process_env::var("USERPROFILE"))
463        .map_err(|_| anyhow!("~ in a layout root requires HOME"))
464        .map_err(Error::from)
465}
466
467fn expand_vars(input: &str) -> Result<String> {
468    let chars: Vec<char> = input.chars().collect();
469    let mut out = String::new();
470    let mut i = 0;
471    while i < chars.len() {
472        if chars[i] == '$' {
473            if i + 1 < chars.len() && chars[i + 1] == '{' {
474                if let Some(rel) = chars[i + 2..].iter().position(|&c| c == '}') {
475                    let name: String = chars[i + 2..i + 2 + rel].iter().collect();
476                    out.push_str(&lookup_var(&name)?);
477                    i = i + 3 + rel;
478                    continue;
479                }
480            } else {
481                let start = i + 1;
482                let mut end = start;
483                while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_')
484                {
485                    end += 1;
486                }
487                if end > start {
488                    let name: String = chars[start..end].iter().collect();
489                    out.push_str(&lookup_var(&name)?);
490                    i = end;
491                    continue;
492                }
493            }
494        }
495        out.push(chars[i]);
496        i += 1;
497    }
498    Ok(out)
499}
500
501fn lookup_var(name: &str) -> Result<String> {
502    process_env::var(name)
503        .map_err(|_| anyhow!("environment variable {name} is unset in layout root"))
504        .map_err(Error::from)
505}
506
507#[cfg(test)]
508#[allow(deprecated_safe_2024)]
509mod tests {
510    use super::*;
511    use std::fs;
512
513    fn seed(layout: &Layout, project: &str, id: &str, title: &str) {
514        let path = layout.project_issues_path(project);
515        fs::create_dir_all(path.parent().unwrap()).unwrap();
516        fs::write(
517            &path,
518            format!("* TODO {title}\n:PROPERTIES:\n:ID:         {id}\n:END:\n"),
519        )
520        .unwrap();
521    }
522
523    fn write_cfg(dir: &Path, body: &str) -> PathBuf {
524        let path = dir.join("config.toml");
525        fs::write(&path, body).unwrap();
526        path
527    }
528
529    #[test]
530    fn missing_config_is_a_noop() {
531        let tmp = tempfile::tempdir().unwrap();
532        let layout = Layout::new(tmp.path(), "Software");
533        let router = Router::from_file(layout.clone(), &tmp.path().join("absent.toml"));
534        // from_file requires the file; load() is the missing-path no-op.
535        assert!(router.is_err());
536        let router = Router::unrouted(layout);
537        assert!(!router.is_routed());
538        let pref = router.route("surf");
539        assert_eq!(pref.dir, "surf");
540        assert_eq!(pref.layout, router.default_layout().clone());
541    }
542
543    #[test]
544    fn a_named_route_wins_over_the_default_root() {
545        let tmp = tempfile::tempdir().unwrap();
546        let vault = tmp.path().join("vault");
547        let work = tmp.path().join("work");
548        fs::create_dir_all(&vault).unwrap();
549        fs::create_dir_all(&work).unwrap();
550        seed(&Layout::new(&work, "Issues"), "surf", "surf-abcd", "routed");
551        seed(
552            &Layout::new(&vault, "Software"),
553            "surf",
554            "surf-old1",
555            "historical",
556        );
557        let cfg = write_cfg(
558            tmp.path(),
559            &format!(
560                "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n\n[routes]\nsurf = \"work\"\n",
561                work.display()
562            ),
563        );
564        let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
565        let pref = router.route("surf");
566        assert_eq!(pref.dir, "surf");
567        assert_eq!(pref.layout.prefix(), "Issues");
568        assert_eq!(pref.layout.root(), work.as_path());
569        let hit = router.find_by_id("surf-abcd").unwrap();
570        assert_eq!(hit.heading.title, "routed");
571        let old = router.find_by_id("surf-old1").unwrap();
572        assert_eq!(old.heading.title, "historical");
573    }
574
575    #[test]
576    fn an_alias_writes_an_existing_directory_without_renaming_ids() {
577        let tmp = tempfile::tempdir().unwrap();
578        let vault = tmp.path().join("vault");
579        let work = tmp.path().join("work");
580        fs::create_dir_all(&vault).unwrap();
581        fs::create_dir_all(&work).unwrap();
582        seed(
583            &Layout::new(&work, "Issues"),
584            "solver",
585            "solver-aaaa",
586            "site ticket",
587        );
588        seed(
589            &Layout::new(&vault, "Software"),
590            "solver",
591            "solver-bbbb",
592            "product ticket",
593        );
594        let cfg = write_cfg(
595            tmp.path(),
596            &format!(
597                "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n\n[routes.solver-work]\nlayout = \"work\"\nproject_dir = \"solver\"\n",
598                work.display()
599            ),
600        );
601        let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
602        let pref = router.route("solver-work");
603        assert_eq!(pref.dir, "solver");
604        assert_eq!(pref.layout.prefix(), "Issues");
605        let raw = router.route("solver");
606        assert_eq!(raw.layout.prefix(), "Software");
607        assert_eq!(raw.dir, "solver");
608        let names: Vec<_> = router
609            .visible_projects()
610            .unwrap()
611            .into_iter()
612            .map(|p| p.key)
613            .collect();
614        assert!(names.iter().any(|n| n == "solver"), "{names:?}");
615        assert!(names.iter().any(|n| n == "solver-work"), "{names:?}");
616        let hit = router.find_by_id("solver-aaaa").unwrap();
617        assert_eq!(hit.layout.prefix(), "Issues");
618    }
619
620    #[test]
621    fn a_layout_that_equals_default_is_not_scanned_twice() {
622        let tmp = tempfile::tempdir().unwrap();
623        let vault = tmp.path().join("vault");
624        fs::create_dir_all(&vault).unwrap();
625        seed(
626            &Layout::new(&vault, "Software"),
627            "only",
628            "only-zzzz",
629            "once",
630        );
631        let cfg = write_cfg(
632            tmp.path(),
633            &format!(
634                "[layouts.vault]\nroot = \"{}\"\nprefix = \"Software\"\n",
635                vault.display()
636            ),
637        );
638        let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
639        assert_eq!(router.unique_layouts().len(), 1);
640        let hit = router.find_by_id("only-zzzz").unwrap();
641        assert_eq!(hit.heading.title, "once");
642    }
643
644    #[test]
645    fn duplicate_id_on_two_distinct_layouts_is_an_error() {
646        let tmp = tempfile::tempdir().unwrap();
647        let vault = tmp.path().join("vault");
648        let work = tmp.path().join("work");
649        fs::create_dir_all(&vault).unwrap();
650        fs::create_dir_all(&work).unwrap();
651        seed(&Layout::new(&work, "Issues"), "surf", "surf-same", "a");
652        seed(&Layout::new(&vault, "Software"), "surf", "surf-same", "b");
653        let cfg = write_cfg(
654            tmp.path(),
655            &format!(
656                "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n",
657                work.display()
658            ),
659        );
660        let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
661        let err = router.find_by_id("surf-same").unwrap_err();
662        match err {
663            Error::DuplicateId { id, paths } => {
664                assert_eq!(id, "surf-same");
665                assert_eq!(paths.len(), 2);
666            }
667            other => panic!("expected DuplicateId, got {other}"),
668        }
669    }
670
671    #[test]
672    fn extra_ids_union_the_twin_file() {
673        let tmp = tempfile::tempdir().unwrap();
674        let vault = tmp.path().join("vault");
675        let work = tmp.path().join("work");
676        fs::create_dir_all(&vault).unwrap();
677        fs::create_dir_all(&work).unwrap();
678        seed(&Layout::new(&vault, "Software"), "surf", "surf-old1", "old");
679        let cfg = write_cfg(
680            tmp.path(),
681            &format!(
682                "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n\n[routes]\nsurf = \"work\"\n",
683                work.display()
684            ),
685        );
686        let router = Router::from_file(Layout::new(&vault, "Software"), &cfg).unwrap();
687        let ids = router.extra_ids_for("surf").unwrap();
688        assert!(ids.iter().any(|i| i == "surf-old1"), "{ids:?}");
689    }
690
691    #[test]
692    fn relative_roots_are_rejected() {
693        let tmp = tempfile::tempdir().unwrap();
694        let cfg = write_cfg(
695            tmp.path(),
696            "[layouts.work]\nroot = \"relative/path\"\nprefix = \"Issues\"\n",
697        );
698        let err = Router::from_file(Layout::new(tmp.path(), "Software"), &cfg).unwrap_err();
699        assert!(err.to_string().contains("absolute"), "{err}");
700    }
701
702    #[test]
703    fn tilde_and_env_expand_in_roots() {
704        let tmp = tempfile::tempdir().unwrap();
705        let work = tmp.path().join("work");
706        fs::create_dir_all(&work).unwrap();
707        crate::process_env::override_var("HOME", Some(tmp.path().to_str().unwrap()));
708        crate::process_env::override_var("WORKROOT", Some(work.to_str().unwrap()));
709        let cfg_home = write_cfg(
710            tmp.path(),
711            "[layouts.work]\nroot = \"~/work\"\nprefix = \"Issues\"\n",
712        );
713        let router = Router::from_file(Layout::new(tmp.path(), "Software"), &cfg_home).unwrap();
714        assert_eq!(router.route("x").layout.prefix(), "Software");
715        assert_eq!(
716            router.named.get("work").map(|l| l.root().to_path_buf()),
717            Some(work.clone())
718        );
719        let cfg_env = write_cfg(
720            tmp.path(),
721            "[layouts.work]\nroot = \"$WORKROOT\"\nprefix = \"Issues\"\n",
722        );
723        let router = Router::from_file(Layout::new(tmp.path(), "Software"), &cfg_env).unwrap();
724        assert_eq!(
725            router.named.get("work").map(|l| l.root().to_path_buf()),
726            Some(work)
727        );
728        crate::process_env::clear_override("HOME");
729        crate::process_env::clear_override("WORKROOT");
730    }
731
732    #[test]
733    fn an_unknown_route_layout_is_a_load_error() {
734        let tmp = tempfile::tempdir().unwrap();
735        let cfg = write_cfg(tmp.path(), "[routes]\nsurf = \"missing\"\n");
736        let err = Router::from_file(Layout::new(tmp.path(), "Software"), &cfg).unwrap_err();
737        assert!(err.to_string().contains("unknown layout"), "{err}");
738    }
739
740    #[test]
741    fn no_route_env_disables_the_table() {
742        let tmp = tempfile::tempdir().unwrap();
743        let work = tmp.path().join("work");
744        fs::create_dir_all(&work).unwrap();
745        let cfg = write_cfg(
746            tmp.path(),
747            &format!(
748                "[layouts.work]\nroot = \"{}\"\nprefix = \"Issues\"\n\n[routes]\nsurf = \"work\"\n",
749                work.display()
750            ),
751        );
752        crate::process_env::override_var("VISSUE_NO_ROUTE", Some("1"));
753        crate::process_env::override_var("VISSUE_CONFIG", Some(cfg.to_str().unwrap()));
754        let router = Router::load(Layout::new(tmp.path(), "Software")).unwrap();
755        assert!(!router.is_routed());
756        assert_eq!(router.route("surf").layout.prefix(), "Software");
757        crate::process_env::clear_override("VISSUE_NO_ROUTE");
758        crate::process_env::clear_override("VISSUE_CONFIG");
759    }
760}