Skip to main content

git_workflow/github/
mock.rs

1//! Mock implementations for testing GitHub integration
2//!
3//! Provides configurable mock responses for testing without actual `gh` CLI.
4
5use std::collections::HashMap;
6use std::sync::{Arc, Mutex};
7
8use crate::error::Result;
9
10use super::client::{CommandExecutor, CommandOutput};
11
12/// A recorded command call for verification
13#[derive(Debug, Clone)]
14pub struct CommandCall {
15    pub program: String,
16    pub args: Vec<String>,
17    pub dir: Option<String>,
18}
19
20/// Mock command executor for testing
21///
22/// Allows configuring responses for specific commands and verifying calls.
23#[derive(Debug, Clone)]
24pub struct MockCommandExecutor {
25    /// Configured responses: (program, args_pattern) -> output
26    responses: Arc<Mutex<HashMap<String, CommandOutput>>>,
27    /// Recorded calls for verification
28    calls: Arc<Mutex<Vec<CommandCall>>>,
29    /// Default response for unconfigured commands
30    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    /// Set default response for unconfigured commands
49    pub fn with_default_response(mut self, output: CommandOutput) -> Self {
50        self.default_response = output;
51        self
52    }
53
54    /// Configure a response for a specific command pattern
55    ///
56    /// The key is generated from `program args[0] args[1] ...`
57    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    /// Get all recorded calls
63    pub fn calls(&self) -> Vec<CommandCall> {
64        self.calls.lock().unwrap().clone()
65    }
66
67    /// Check if a specific command was called
68    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    /// Get the number of times a command was called
76    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    /// Clear all recorded calls
82    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
128/// Builder for creating mock GitHub scenarios
129pub 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    /// gh CLI is available and authenticated
147    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    /// gh CLI is not available
162    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    /// gh CLI is available but not authenticated
172    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    /// Configure a PR response for a branch
187    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    /// Configure no PR found for a branch
203    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    /// Configure PR view to fail with auth error
219    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    /// Configure git cat-file response for merge method detection
235    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    /// Configure successful remote branch deletion
253    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    /// Configure remote branch already deleted
263    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    /// Configure remote branch deletion failure
273    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    /// Build the mock executor
283    pub fn build(self) -> MockCommandExecutor {
284        self.executor
285    }
286}
287
288/// Pre-built JSON responses for common PR scenarios
289pub mod fixtures {
290    /// Open PR JSON
291    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    /// Merged PR JSON (with merge commit)
299    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    /// Merged PR JSON (rebase, no merge commit)
307    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    /// Closed PR JSON
315    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    /// PR with Japanese title
323    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    /// PR with develop base branch
331    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}