Skip to main content

wt/
template.rs

1//! Worktree-store path-template rendering (spec §6).
2//!
3//! New worktrees are placed according to a configurable template with the
4//! variables `{repo_parent}`, `{repo}`, `{repo_root}`, `{branch}`,
5//! `{branch_slug}`, and `{home}`. [`render`] substitutes them; [`ensure_outside_git`]
6//! rejects a rendered path that would land inside the `.git` directory.
7//!
8//! **Embedders must resolve worktree paths through this module.** The template
9//! is repository configuration ([`Config::path_template`](crate::config::Config)),
10//! so it varies per repository and a user may change it at any time.
11//! Hard-coding [`DEFAULT_TEMPLATE`]'s layout — or any other guess at where a
12//! worktree lives — makes two tools that share a repository disagree about
13//! where its worktrees are. [`Workspace::create`](crate::worktree::Workspace::create)
14//! already renders through here and reports the resulting path, which is the
15//! easiest way to stay consistent.
16
17use std::path::{Path, PathBuf};
18
19use crate::error::{Error, Result};
20
21/// The default worktree-store template (spec §6 "Sibling").
22pub const DEFAULT_TEMPLATE: &str = "{repo_parent}/{repo}.worktrees/{repo}-{branch_slug}";
23
24/// The values substituted into a path template. For a bare repository these
25/// resolve against the bare repo's own directory (spec §6).
26#[derive(Debug, Clone)]
27pub struct TemplateVars {
28    /// Directory containing the repo root.
29    pub repo_parent: PathBuf,
30    /// Repo directory name.
31    pub repo: String,
32    /// Repo root (or bare repo path).
33    pub repo_root: PathBuf,
34    /// Raw branch name.
35    pub branch: String,
36    /// Filesystem-safe branch slug.
37    pub branch_slug: String,
38    /// The user's home directory.
39    pub home: PathBuf,
40}
41
42/// Renders `template`, substituting the [`TemplateVars`]. An unknown `{var}` or
43/// an unterminated `{` is a configuration error.
44pub fn render(template: &str, vars: &TemplateVars) -> Result<PathBuf> {
45    let mut out = String::with_capacity(template.len());
46    let mut rest = template;
47    while let Some(open) = rest.find('{') {
48        out.push_str(&rest[..open]);
49        let after = &rest[open + 1..];
50        let close = after
51            .find('}')
52            .ok_or_else(|| template_error(template, "unterminated '{' in template"))?;
53        let name = &after[..close];
54        out.push_str(&substitute(name, vars).ok_or_else(|| {
55            template_error(template, &format!("unknown template variable {{{name}}}"))
56        })?);
57        rest = &after[close + 1..];
58    }
59    out.push_str(rest);
60    Ok(PathBuf::from(out))
61}
62
63/// Returns the substitution for a variable name, or `None` if unknown.
64fn substitute(name: &str, vars: &TemplateVars) -> Option<String> {
65    Some(match name {
66        "repo_parent" => vars.repo_parent.to_string_lossy().into_owned(),
67        "repo" => vars.repo.clone(),
68        "repo_root" => vars.repo_root.to_string_lossy().into_owned(),
69        "branch" => vars.branch.clone(),
70        "branch_slug" => vars.branch_slug.clone(),
71        "home" => vars.home.to_string_lossy().into_owned(),
72        _ => return None,
73    })
74}
75
76/// Builds a config error for a bad `path_template`.
77fn template_error(template: &str, reason: &str) -> Error {
78    Error::Config {
79        file: "path_template".into(),
80        key: template.into(),
81        reason: reason.into(),
82    }
83}
84
85/// Rejects a rendered worktree path that lies inside the repository's `.git`
86/// directory (spec §6).
87pub fn ensure_outside_git(rendered: &Path, git_dir: &Path) -> Result<()> {
88    if rendered.starts_with(git_dir) {
89        return Err(Error::Config {
90            file: "path_template".into(),
91            key: "path_template".into(),
92            reason: format!(
93                "template renders a worktree inside the git directory: {}",
94                rendered.display()
95            ),
96        });
97    }
98    Ok(())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn vars() -> TemplateVars {
106        TemplateVars {
107            repo_parent: PathBuf::from("/home/u/code"),
108            repo: "proj".into(),
109            repo_root: PathBuf::from("/home/u/code/proj"),
110            branch: "feature/login".into(),
111            branch_slug: "feature-login".into(),
112            home: PathBuf::from("/home/u"),
113        }
114    }
115
116    #[test]
117    fn renders_default_sibling_template() {
118        let p = render(DEFAULT_TEMPLATE, &vars()).unwrap();
119        assert_eq!(
120            p,
121            PathBuf::from("/home/u/code/proj.worktrees/proj-feature-login")
122        );
123    }
124
125    #[test]
126    fn renders_subdir_and_central_presets() {
127        let sub = render("{repo_root}/.worktrees/{branch_slug}", &vars()).unwrap();
128        assert_eq!(
129            sub,
130            PathBuf::from("/home/u/code/proj/.worktrees/feature-login")
131        );
132        let central = render("{home}/worktrees/{repo}/{branch_slug}", &vars()).unwrap();
133        assert_eq!(
134            central,
135            PathBuf::from("/home/u/worktrees/proj/feature-login")
136        );
137    }
138
139    #[test]
140    fn repo_token_does_not_clobber_repo_parent_or_root() {
141        let p = render("{repo_parent}/{repo}/{repo_root}/{branch}", &vars()).unwrap();
142        assert_eq!(
143            p,
144            PathBuf::from("/home/u/code/proj//home/u/code/proj/feature/login")
145        );
146    }
147
148    #[test]
149    fn unknown_variable_is_config_error() {
150        let err = render("{repo}/{bogus}", &vars()).unwrap_err();
151        assert!(matches!(err, Error::Config { .. }));
152        assert!(err.to_string().contains("bogus"));
153    }
154
155    #[test]
156    fn unterminated_brace_is_config_error() {
157        let err = render("{repo}/{branch", &vars()).unwrap_err();
158        assert!(matches!(err, Error::Config { .. }));
159        assert!(err.to_string().contains("unterminated"));
160    }
161
162    #[test]
163    fn literal_text_without_variables() {
164        assert_eq!(
165            render("/tmp/fixed", &vars()).unwrap(),
166            PathBuf::from("/tmp/fixed")
167        );
168    }
169
170    #[test]
171    fn ensure_outside_git_rejects_inside_and_allows_outside() {
172        let git_dir = Path::new("/home/u/code/proj/.git");
173        let inside = Path::new("/home/u/code/proj/.git/worktrees/x");
174        let outside = Path::new("/home/u/code/proj.worktrees/x");
175        assert!(ensure_outside_git(inside, git_dir).is_err());
176        assert!(ensure_outside_git(outside, git_dir).is_ok());
177        // A sibling whose name merely starts with the git dir name is allowed.
178        let sibling = Path::new("/home/u/code/proj/.gitignore-dir/x");
179        assert!(ensure_outside_git(sibling, git_dir).is_ok());
180    }
181}