1use std::path::{Path, PathBuf};
18
19use crate::error::{Error, Result};
20
21pub const DEFAULT_TEMPLATE: &str = "{repo_parent}/{repo}.worktrees/{repo}-{branch_slug}";
23
24#[derive(Debug, Clone)]
27pub struct TemplateVars {
28 pub repo_parent: PathBuf,
30 pub repo: String,
32 pub repo_root: PathBuf,
34 pub branch: String,
36 pub branch_slug: String,
38 pub home: PathBuf,
40}
41
42pub 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
63fn 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
76fn 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
85pub 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 let sibling = Path::new("/home/u/code/proj/.gitignore-dir/x");
179 assert!(ensure_outside_git(sibling, git_dir).is_ok());
180 }
181}