1use std::path::{Path, PathBuf};
9
10use crate::error::{Error, Result};
11
12pub const DEFAULT_TEMPLATE: &str = "{repo_parent}/{repo}.worktrees/{repo}-{branch_slug}";
14
15#[derive(Debug, Clone)]
18pub struct TemplateVars {
19 pub repo_parent: PathBuf,
21 pub repo: String,
23 pub repo_root: PathBuf,
25 pub branch: String,
27 pub branch_slug: String,
29 pub home: PathBuf,
31}
32
33pub fn render(template: &str, vars: &TemplateVars) -> Result<PathBuf> {
36 let mut out = String::with_capacity(template.len());
37 let mut rest = template;
38 while let Some(open) = rest.find('{') {
39 out.push_str(&rest[..open]);
40 let after = &rest[open + 1..];
41 let close = after
42 .find('}')
43 .ok_or_else(|| template_error(template, "unterminated '{' in template"))?;
44 let name = &after[..close];
45 out.push_str(&substitute(name, vars).ok_or_else(|| {
46 template_error(template, &format!("unknown template variable {{{name}}}"))
47 })?);
48 rest = &after[close + 1..];
49 }
50 out.push_str(rest);
51 Ok(PathBuf::from(out))
52}
53
54fn substitute(name: &str, vars: &TemplateVars) -> Option<String> {
56 Some(match name {
57 "repo_parent" => vars.repo_parent.to_string_lossy().into_owned(),
58 "repo" => vars.repo.clone(),
59 "repo_root" => vars.repo_root.to_string_lossy().into_owned(),
60 "branch" => vars.branch.clone(),
61 "branch_slug" => vars.branch_slug.clone(),
62 "home" => vars.home.to_string_lossy().into_owned(),
63 _ => return None,
64 })
65}
66
67fn template_error(template: &str, reason: &str) -> Error {
69 Error::Config {
70 file: "path_template".into(),
71 key: template.into(),
72 reason: reason.into(),
73 }
74}
75
76pub fn ensure_outside_git(rendered: &Path, git_dir: &Path) -> Result<()> {
79 if rendered.starts_with(git_dir) {
80 return Err(Error::Config {
81 file: "path_template".into(),
82 key: "path_template".into(),
83 reason: format!(
84 "template renders a worktree inside the git directory: {}",
85 rendered.display()
86 ),
87 });
88 }
89 Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 fn vars() -> TemplateVars {
97 TemplateVars {
98 repo_parent: PathBuf::from("/home/u/code"),
99 repo: "proj".into(),
100 repo_root: PathBuf::from("/home/u/code/proj"),
101 branch: "feature/login".into(),
102 branch_slug: "feature-login".into(),
103 home: PathBuf::from("/home/u"),
104 }
105 }
106
107 #[test]
108 fn renders_default_sibling_template() {
109 let p = render(DEFAULT_TEMPLATE, &vars()).unwrap();
110 assert_eq!(
111 p,
112 PathBuf::from("/home/u/code/proj.worktrees/proj-feature-login")
113 );
114 }
115
116 #[test]
117 fn renders_subdir_and_central_presets() {
118 let sub = render("{repo_root}/.worktrees/{branch_slug}", &vars()).unwrap();
119 assert_eq!(
120 sub,
121 PathBuf::from("/home/u/code/proj/.worktrees/feature-login")
122 );
123 let central = render("{home}/worktrees/{repo}/{branch_slug}", &vars()).unwrap();
124 assert_eq!(
125 central,
126 PathBuf::from("/home/u/worktrees/proj/feature-login")
127 );
128 }
129
130 #[test]
131 fn repo_token_does_not_clobber_repo_parent_or_root() {
132 let p = render("{repo_parent}/{repo}/{repo_root}/{branch}", &vars()).unwrap();
133 assert_eq!(
134 p,
135 PathBuf::from("/home/u/code/proj//home/u/code/proj/feature/login")
136 );
137 }
138
139 #[test]
140 fn unknown_variable_is_config_error() {
141 let err = render("{repo}/{bogus}", &vars()).unwrap_err();
142 assert!(matches!(err, Error::Config { .. }));
143 assert!(err.to_string().contains("bogus"));
144 }
145
146 #[test]
147 fn unterminated_brace_is_config_error() {
148 let err = render("{repo}/{branch", &vars()).unwrap_err();
149 assert!(matches!(err, Error::Config { .. }));
150 assert!(err.to_string().contains("unterminated"));
151 }
152
153 #[test]
154 fn literal_text_without_variables() {
155 assert_eq!(
156 render("/tmp/fixed", &vars()).unwrap(),
157 PathBuf::from("/tmp/fixed")
158 );
159 }
160
161 #[test]
162 fn ensure_outside_git_rejects_inside_and_allows_outside() {
163 let git_dir = Path::new("/home/u/code/proj/.git");
164 let inside = Path::new("/home/u/code/proj/.git/worktrees/x");
165 let outside = Path::new("/home/u/code/proj.worktrees/x");
166 assert!(ensure_outside_git(inside, git_dir).is_err());
167 assert!(ensure_outside_git(outside, git_dir).is_ok());
168 let sibling = Path::new("/home/u/code/proj/.gitignore-dir/x");
170 assert!(ensure_outside_git(sibling, git_dir).is_ok());
171 }
172}