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