1use crate::emit::q;
7use std::path::Path;
8
9fn p(path: &Path) -> String {
10 path.to_string_lossy().into_owned()
11}
12
13#[must_use]
17pub fn script_cd(path: &Path) -> Vec<String> {
18 let qp = q(&p(path));
19 vec![
20 format!("touch {qp}"),
21 format!("echo {qp}"),
22 format!("cd {qp}"),
23 ]
24}
25
26#[must_use]
28pub fn script_mkdir_cd(path: &Path) -> Vec<String> {
29 let mut cmds = vec![format!("mkdir -p {}", q(&p(path)))];
30 cmds.extend(script_cd(path));
31 cmds
32}
33
34#[must_use]
37pub fn script_clone(path: &Path, uri: &str) -> Vec<String> {
38 let qp = q(&p(path));
39 let mut cmds = vec![
40 format!("mkdir -p {qp}"),
41 format!(
42 "echo {}",
43 q(&format!("Using git clone to create this trial from {uri}."))
44 ),
45 format!("git clone '{uri}' {qp}"),
46 ];
47 cmds.extend(script_cd(path));
48 cmds
49}
50
51#[must_use]
56pub fn script_worktree(path: &Path, repo: Option<&Path>, cwd: &Path) -> Vec<String> {
57 let qp = q(&p(path));
58 let worktree_cmd = match repo {
59 Some(r) => {
60 let qr = q(&p(r));
61 format!(
62 "/usr/bin/env sh -c 'if git -C {qr} rev-parse --is-inside-work-tree >/dev/null 2>&1; then repo=$(git -C {qr} rev-parse --show-toplevel); git -C \"$repo\" worktree add --detach {qp} >/dev/null 2>&1 || true; fi; exit 0'"
63 )
64 }
65 None => format!(
66 "/usr/bin/env sh -c 'if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then repo=$(git rev-parse --show-toplevel); git -C \"$repo\" worktree add --detach {qp} >/dev/null 2>&1 || true; fi; exit 0'"
67 ),
68 };
69 let src = repo.unwrap_or(cwd);
70 let mut cmds = vec![
71 format!("mkdir -p {qp}"),
72 format!(
73 "echo {}",
74 q(&format!(
75 "Using git worktree to create this trial from {}.",
76 p(src)
77 ))
78 ),
79 worktree_cmd,
80 ];
81 cmds.extend(script_cd(path));
82 cmds
83}
84
85#[must_use]
88pub fn script_delete(basenames: &[String], base_path: &Path, original_pwd: &Path) -> Vec<String> {
89 let mut cmds = vec![format!("cd {}", q(&p(base_path)))];
90 for name in basenames {
91 let qn = q(name);
92 cmds.push(format!("test -d {qn} && rm -rf {qn}"));
93 }
94 cmds.push(format!(
95 "cd {} 2>/dev/null || cd {}",
96 q(&p(original_pwd)),
97 q(&p(base_path))
98 ));
99 cmds
100}
101
102#[must_use]
107pub fn script_ascend(source: &Path, dest: &Path, basename: &str, base_path: &Path) -> Vec<String> {
108 let symlink_path = base_path.join(basename);
109 let is_worktree = source.join(".git").is_file();
110
111 let mut cmds = Vec::new();
112 if is_worktree {
113 cmds.push(format!(
114 "git worktree move {} {}",
115 q(&p(source)),
116 q(&p(dest))
117 ));
118 } else {
119 cmds.push(format!("mv {} {}", q(&p(source)), q(&p(dest))));
120 }
121 cmds.push(format!("ln -s {} {}", q(&p(dest)), q(&p(&symlink_path))));
122 cmds.push(format!(
123 "echo {}",
124 q(&format!("Graduated: {basename} → {}", p(dest)))
125 ));
126 cmds.extend(script_cd(dest));
127 cmds
128}
129
130#[must_use]
132pub fn script_rename(base_path: &Path, old_name: &str, new_name: &str) -> Vec<String> {
133 let new_path = base_path.join(new_name);
134 vec![
135 format!("cd {}", q(&p(base_path))),
136 format!("mv {} {}", q(old_name), q(new_name)),
137 format!("echo {}", q(&p(&new_path))),
138 format!("cd {}", q(&p(&new_path))),
139 ]
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use std::path::PathBuf;
146
147 #[test]
148 fn cd_script_touch_echo_cd() {
149 assert_eq!(
150 script_cd(&PathBuf::from("/t/2026-07-10-x")),
151 vec![
152 "touch '/t/2026-07-10-x'",
153 "echo '/t/2026-07-10-x'",
154 "cd '/t/2026-07-10-x'"
155 ]
156 );
157 }
158
159 #[test]
160 fn clone_script_hardcoded_uri_quotes() {
161 let cmds = script_clone(&PathBuf::from("/t/d"), "https://github.com/u/r");
162 assert_eq!(cmds[2], "git clone 'https://github.com/u/r' '/t/d'");
163 assert_eq!(
164 cmds[1],
165 "echo 'Using git clone to create this trial from https://github.com/u/r.'"
166 );
167 }
168
169 #[test]
170 fn worktree_detach_and_always_exit_zero() {
171 let cmds = script_worktree(
172 &PathBuf::from("/t/w"),
173 Some(&PathBuf::from("/repo")),
174 &PathBuf::from("/cwd"),
175 );
176 assert!(cmds[2].contains("worktree add --detach '/t/w'"));
177 assert!(cmds[2].ends_with("fi; exit 0'"));
178 assert!(cmds[2].contains("git -C '/repo' rev-parse"));
179 let cmds = script_worktree(&PathBuf::from("/t/w"), None, &PathBuf::from("/cwd"));
181 assert!(cmds[2].contains("if git rev-parse"));
182 assert!(cmds[1].contains("from /cwd."));
183 }
184
185 #[test]
186 fn delete_uses_basenames_and_pwd_fallback() {
187 let cmds = script_delete(
188 &["a".to_string(), "b".to_string()],
189 &PathBuf::from("/t"),
190 &PathBuf::from("/orig"),
191 );
192 assert_eq!(cmds[0], "cd '/t'");
193 assert_eq!(cmds[1], "test -d 'a' && rm -rf 'a'");
194 assert_eq!(cmds[3], "cd '/orig' 2>/dev/null || cd '/t'");
195 }
196
197 #[test]
198 fn ascend_mv_symlink_echo_cd() {
199 let tmp = tempfile::tempdir().unwrap();
200 let src = tmp.path().join("2026-07-10-exp");
201 std::fs::create_dir(&src).unwrap();
202 let cmds = script_ascend(
203 &src,
204 &PathBuf::from("/proj/exp"),
205 "2026-07-10-exp",
206 tmp.path(),
207 );
208 assert!(cmds[0].starts_with("mv "));
209 assert!(cmds[1].starts_with("ln -s '/proj/exp' "));
210 assert!(cmds[2].contains("Graduated: 2026-07-10-exp → /proj/exp"));
211 assert_eq!(cmds.len(), 6);
212 }
213
214 #[test]
215 fn rename_cd_mv_echo_cd() {
216 let cmds = script_rename(&PathBuf::from("/t"), "old", "new");
217 assert_eq!(
218 cmds,
219 vec!["cd '/t'", "mv 'old' 'new'", "echo '/t/new'", "cd '/t/new'"]
220 );
221 }
222}