1use std::collections::HashMap;
6use std::sync::{Arc, Mutex};
7
8use crate::error::Result;
9
10use super::client::{CommandExecutor, CommandOutput};
11
12#[derive(Debug, Clone)]
14pub struct CommandCall {
15 pub program: String,
16 pub args: Vec<String>,
17 pub dir: Option<String>,
18}
19
20#[derive(Debug, Clone)]
24pub struct MockCommandExecutor {
25 responses: Arc<Mutex<HashMap<String, CommandOutput>>>,
27 calls: Arc<Mutex<Vec<CommandCall>>>,
29 default_response: CommandOutput,
31}
32
33impl Default for MockCommandExecutor {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl MockCommandExecutor {
40 pub fn new() -> Self {
41 Self {
42 responses: Arc::new(Mutex::new(HashMap::new())),
43 calls: Arc::new(Mutex::new(Vec::new())),
44 default_response: CommandOutput::failure("command not configured"),
45 }
46 }
47
48 pub fn with_default_response(mut self, output: CommandOutput) -> Self {
50 self.default_response = output;
51 self
52 }
53
54 pub fn on_command(&self, program: &str, args: &[&str], output: CommandOutput) {
58 let key = self.make_key(program, args);
59 self.responses.lock().unwrap().insert(key, output);
60 }
61
62 pub fn calls(&self) -> Vec<CommandCall> {
64 self.calls.lock().unwrap().clone()
65 }
66
67 pub fn was_called(&self, program: &str, args: &[&str]) -> bool {
69 let calls = self.calls.lock().unwrap();
70 calls.iter().any(|c| {
71 c.program == program && c.args.iter().map(|s| s.as_str()).collect::<Vec<_>>() == args
72 })
73 }
74
75 pub fn call_count(&self, program: &str) -> usize {
77 let calls = self.calls.lock().unwrap();
78 calls.iter().filter(|c| c.program == program).count()
79 }
80
81 pub fn clear_calls(&self) {
83 self.calls.lock().unwrap().clear();
84 }
85
86 fn make_key(&self, program: &str, args: &[&str]) -> String {
87 std::iter::once(program)
88 .chain(args.iter().copied())
89 .collect::<Vec<_>>()
90 .join(" ")
91 }
92
93 fn record_call(&self, program: &str, args: &[&str], dir: Option<&str>) {
94 self.calls.lock().unwrap().push(CommandCall {
95 program: program.to_string(),
96 args: args.iter().map(|s| s.to_string()).collect(),
97 dir: dir.map(|s| s.to_string()),
98 });
99 }
100}
101
102impl CommandExecutor for MockCommandExecutor {
103 fn execute(&self, program: &str, args: &[&str]) -> Result<CommandOutput> {
104 self.record_call(program, args, None);
105
106 let key = self.make_key(program, args);
107 let responses = self.responses.lock().unwrap();
108
109 Ok(responses
110 .get(&key)
111 .cloned()
112 .unwrap_or(self.default_response.clone()))
113 }
114
115 fn execute_in_dir(&self, program: &str, args: &[&str], dir: &str) -> Result<CommandOutput> {
116 self.record_call(program, args, Some(dir));
117
118 let key = self.make_key(program, args);
119 let responses = self.responses.lock().unwrap();
120
121 Ok(responses
122 .get(&key)
123 .cloned()
124 .unwrap_or(self.default_response.clone()))
125 }
126}
127
128pub struct MockScenarioBuilder {
130 executor: MockCommandExecutor,
131}
132
133impl Default for MockScenarioBuilder {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139impl MockScenarioBuilder {
140 pub fn new() -> Self {
141 Self {
142 executor: MockCommandExecutor::new(),
143 }
144 }
145
146 pub fn gh_available(self) -> Self {
148 self.executor.on_command(
149 "gh",
150 &["--version"],
151 CommandOutput::success("gh version 2.40.0"),
152 );
153 self.executor.on_command(
154 "gh",
155 &["auth", "status"],
156 CommandOutput::success("Logged in"),
157 );
158 self
159 }
160
161 pub fn gh_not_available(self) -> Self {
163 self.executor.on_command(
164 "gh",
165 &["--version"],
166 CommandOutput::failure("command not found: gh"),
167 );
168 self
169 }
170
171 pub fn gh_not_authenticated(self) -> Self {
173 self.executor.on_command(
174 "gh",
175 &["--version"],
176 CommandOutput::success("gh version 2.40.0"),
177 );
178 self.executor.on_command(
179 "gh",
180 &["auth", "status"],
181 CommandOutput::failure("You are not logged into any GitHub hosts. Run gh auth login"),
182 );
183 self
184 }
185
186 pub fn with_pr(self, branch: &str, json: &str) -> Self {
188 self.executor.on_command(
189 "gh",
190 &[
191 "pr",
192 "view",
193 branch,
194 "--json",
195 "number,title,url,state,baseRefName,mergeCommit,mergedAt",
196 ],
197 CommandOutput::success(json),
198 );
199 self
200 }
201
202 pub fn with_no_pr(self, branch: &str) -> Self {
204 self.executor.on_command(
205 "gh",
206 &[
207 "pr",
208 "view",
209 branch,
210 "--json",
211 "number,title,url,state,baseRefName,mergeCommit,mergedAt",
212 ],
213 CommandOutput::failure("no pull requests found for branch \"branch\""),
214 );
215 self
216 }
217
218 pub fn with_auth_error(self, branch: &str) -> Self {
220 self.executor.on_command(
221 "gh",
222 &[
223 "pr",
224 "view",
225 branch,
226 "--json",
227 "number,title,url,state,baseRefName,mergeCommit,mergedAt",
228 ],
229 CommandOutput::failure("To get started with GitHub CLI, please run: gh auth login"),
230 );
231 self
232 }
233
234 pub fn with_merge_commit(self, sha: &str, parent_count: usize) -> Self {
236 let parents = (0..parent_count)
237 .map(|i| format!("parent abc{:03}", i))
238 .collect::<Vec<_>>()
239 .join("\n");
240 let output = format!(
241 "tree def456\n{}\nauthor Test <test@example.com>\ncommitter Test <test@example.com>\n\nMerge commit",
242 parents
243 );
244 self.executor.on_command(
245 "git",
246 &["cat-file", "-p", sha],
247 CommandOutput::success(output),
248 );
249 self
250 }
251
252 pub fn with_remote_delete_success(self, branch: &str) -> Self {
254 self.executor.on_command(
255 "git",
256 &["push", "origin", "--delete", branch],
257 CommandOutput::success(""),
258 );
259 self
260 }
261
262 pub fn with_remote_already_deleted(self, branch: &str) -> Self {
264 self.executor.on_command(
265 "git",
266 &["push", "origin", "--delete", branch],
267 CommandOutput::failure("error: unable to delete 'branch': remote ref does not exist"),
268 );
269 self
270 }
271
272 pub fn with_remote_delete_failure(self, branch: &str, error: &str) -> Self {
274 self.executor.on_command(
275 "git",
276 &["push", "origin", "--delete", branch],
277 CommandOutput::failure(error),
278 );
279 self
280 }
281
282 pub fn build(self) -> MockCommandExecutor {
284 self.executor
285 }
286}
287
288pub mod fixtures {
290 pub fn open_pr(number: u64, _branch: &str) -> String {
292 format!(
293 r#"{{"number":{},"title":"feat: add feature","url":"https://github.com/owner/repo/pull/{}","state":"OPEN","baseRefName":"main","mergeCommit":null,"mergedAt":null}}"#,
294 number, number
295 )
296 }
297
298 pub fn merged_pr(number: u64, merge_commit: &str) -> String {
300 format!(
301 r#"{{"number":{},"title":"feat: merged feature","url":"https://github.com/owner/repo/pull/{}","state":"MERGED","baseRefName":"main","mergeCommit":{{"oid":"{}"}},"mergedAt":"2024-01-01T00:00:00Z"}}"#,
302 number, number, merge_commit
303 )
304 }
305
306 pub fn merged_pr_rebase(number: u64) -> String {
308 format!(
309 r#"{{"number":{},"title":"feat: rebased feature","url":"https://github.com/owner/repo/pull/{}","state":"MERGED","baseRefName":"main","mergeCommit":null,"mergedAt":"2024-01-01T00:00:00Z"}}"#,
310 number, number
311 )
312 }
313
314 pub fn closed_pr(number: u64) -> String {
316 format!(
317 r#"{{"number":{},"title":"wip: abandoned","url":"https://github.com/owner/repo/pull/{}","state":"CLOSED","baseRefName":"main","mergeCommit":null,"mergedAt":null}}"#,
318 number, number
319 )
320 }
321
322 pub fn pr_with_japanese_title(number: u64) -> String {
324 format!(
325 r#"{{"number":{},"title":"feat: 日本語タイトル","url":"https://github.com/owner/repo/pull/{}","state":"OPEN","baseRefName":"main","mergeCommit":null,"mergedAt":null}}"#,
326 number, number
327 )
328 }
329
330 pub fn pr_with_develop_base(number: u64) -> String {
332 format!(
333 r#"{{"number":{},"title":"feat: develop branch","url":"https://github.com/owner/repo/pull/{}","state":"OPEN","baseRefName":"develop","mergeCommit":null,"mergedAt":null}}"#,
334 number, number
335 )
336 }
337}