1use std::path::Path;
8
9use ito_config::types::BackendApiConfig;
10
11use crate::errors::{CoreError, CoreResult};
12use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
13
14pub fn resolve_org_repo_from_config_or_remote(
28 repo_root: &Path,
29 config: &BackendApiConfig,
30) -> Option<(String, String)> {
31 let runner = SystemProcessRunner;
32 resolve_org_repo_from_config_or_remote_with_runner(&runner, repo_root, config)
33}
34
35pub(crate) fn resolve_org_repo_from_config_or_remote_with_runner(
37 runner: &dyn ProcessRunner,
38 repo_root: &Path,
39 config: &BackendApiConfig,
40) -> Option<(String, String)> {
41 let config_org = config
43 .project
44 .org
45 .as_deref()
46 .filter(|s| !s.is_empty())
47 .map(String::from);
48 let config_repo = config
49 .project
50 .repo
51 .as_deref()
52 .filter(|s| !s.is_empty())
53 .map(String::from);
54
55 if let (Some(org), Some(repo)) = (config_org, config_repo) {
56 return Some((org, repo));
57 }
58
59 let url = get_origin_remote_url(runner, repo_root)?;
61 ito_common::git_url::parse_remote_url_org_repo(&url)
62}
63
64pub fn parse_remote_url_org_repo(url: &str) -> Option<(String, String)> {
73 ito_common::git_url::parse_remote_url_org_repo(url)
74}
75
76pub fn resolve_org_repo_from_remote(repo_root: &Path) -> CoreResult<Option<(String, String)>> {
83 let runner = SystemProcessRunner;
84 let request = ProcessRequest::new("git")
85 .args(["remote", "get-url", "origin"])
86 .current_dir(repo_root);
87 let output = runner
88 .run(&request)
89 .map_err(|e| CoreError::process(format!("git remote get-url origin failed: {e}")))?;
90 if !output.success {
91 return Ok(None);
92 }
93 let url = output.stdout.trim().to_string();
94 Ok(ito_common::git_url::parse_remote_url_org_repo(&url))
95}
96
97fn get_origin_remote_url(runner: &dyn ProcessRunner, repo_root: &Path) -> Option<String> {
102 let request = ProcessRequest::new("git")
103 .args(["remote", "get-url", "origin"])
104 .current_dir(repo_root);
105 let output = runner.run(&request).ok()?;
106 if !output.success {
107 return None;
108 }
109 let url = output.stdout.trim().to_string();
110 if url.is_empty() {
111 return None;
112 }
113 Some(url)
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::process::{ProcessExecutionError, ProcessOutput};
120 use ito_config::types::BackendProjectConfig;
121 use std::cell::RefCell;
122 use std::collections::VecDeque;
123
124 struct StubRunner {
125 outputs: RefCell<VecDeque<Result<ProcessOutput, ProcessExecutionError>>>,
126 }
127
128 impl StubRunner {
129 fn with_outputs(outputs: Vec<Result<ProcessOutput, ProcessExecutionError>>) -> Self {
130 Self {
131 outputs: RefCell::new(outputs.into()),
132 }
133 }
134 }
135
136 impl ProcessRunner for StubRunner {
137 fn run(&self, _request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError> {
138 self.outputs
139 .borrow_mut()
140 .pop_front()
141 .expect("expected process output")
142 }
143
144 fn run_with_timeout(
145 &self,
146 _request: &ProcessRequest,
147 _timeout: std::time::Duration,
148 ) -> Result<ProcessOutput, ProcessExecutionError> {
149 unreachable!("not used")
150 }
151 }
152
153 fn ok_output(stdout: &str) -> ProcessOutput {
154 ProcessOutput {
155 exit_code: 0,
156 success: true,
157 stdout: stdout.to_string(),
158 stderr: String::new(),
159 timed_out: false,
160 }
161 }
162
163 fn err_output(stderr: &str) -> ProcessOutput {
164 ProcessOutput {
165 exit_code: 1,
166 success: false,
167 stdout: String::new(),
168 stderr: stderr.to_string(),
169 timed_out: false,
170 }
171 }
172
173 fn config_with_project(org: &str, repo: &str) -> BackendApiConfig {
174 BackendApiConfig {
175 project: BackendProjectConfig {
176 org: Some(org.to_string()),
177 repo: Some(repo.to_string()),
178 },
179 ..BackendApiConfig::default()
180 }
181 }
182
183 #[test]
186 fn returns_config_values_when_both_set() {
187 let config = config_with_project("acme", "widget");
188 let runner = StubRunner::with_outputs(vec![]);
190 let result = resolve_org_repo_from_config_or_remote_with_runner(
191 &runner,
192 std::env::temp_dir().as_path(),
193 &config,
194 );
195 assert_eq!(result, Some(("acme".to_string(), "widget".to_string())));
196 }
197
198 #[test]
199 fn falls_back_to_remote_when_config_org_missing() {
200 let config = BackendApiConfig {
201 project: BackendProjectConfig {
202 org: None,
203 repo: Some("widget".to_string()),
204 },
205 ..BackendApiConfig::default()
206 };
207 let runner =
208 StubRunner::with_outputs(vec![Ok(ok_output("git@github.com:acme/widget.git\n"))]);
209 let result = resolve_org_repo_from_config_or_remote_with_runner(
210 &runner,
211 std::env::temp_dir().as_path(),
212 &config,
213 );
214 assert_eq!(result, Some(("acme".to_string(), "widget".to_string())));
215 }
216
217 #[test]
218 fn falls_back_to_remote_when_config_repo_missing() {
219 let config = BackendApiConfig {
220 project: BackendProjectConfig {
221 org: Some("acme".to_string()),
222 repo: None,
223 },
224 ..BackendApiConfig::default()
225 };
226 let runner =
227 StubRunner::with_outputs(vec![Ok(ok_output("https://github.com/acme/widget.git\n"))]);
228 let result = resolve_org_repo_from_config_or_remote_with_runner(
229 &runner,
230 std::env::temp_dir().as_path(),
231 &config,
232 );
233 assert_eq!(result, Some(("acme".to_string(), "widget".to_string())));
234 }
235
236 #[test]
237 fn falls_back_to_remote_when_config_empty() {
238 let config = BackendApiConfig::default();
239 let runner =
240 StubRunner::with_outputs(vec![Ok(ok_output("git@github.com:withakay/ito.git\n"))]);
241 let result = resolve_org_repo_from_config_or_remote_with_runner(
242 &runner,
243 std::env::temp_dir().as_path(),
244 &config,
245 );
246 assert_eq!(result, Some(("withakay".to_string(), "ito".to_string())));
247 }
248
249 #[test]
250 fn ignores_empty_config_strings_and_falls_back_to_remote() {
251 let config = BackendApiConfig {
252 project: BackendProjectConfig {
253 org: Some("".to_string()),
254 repo: Some("".to_string()),
255 },
256 ..BackendApiConfig::default()
257 };
258 let runner =
259 StubRunner::with_outputs(vec![Ok(ok_output("https://github.com/acme/widget.git\n"))]);
260 let result = resolve_org_repo_from_config_or_remote_with_runner(
261 &runner,
262 std::env::temp_dir().as_path(),
263 &config,
264 );
265 assert_eq!(result, Some(("acme".to_string(), "widget".to_string())));
266 }
267
268 #[test]
271 fn returns_none_when_remote_command_fails() {
272 let config = BackendApiConfig::default();
273 let runner =
274 StubRunner::with_outputs(vec![Ok(err_output("fatal: No such remote 'origin'"))]);
275 let result = resolve_org_repo_from_config_or_remote_with_runner(
276 &runner,
277 std::env::temp_dir().as_path(),
278 &config,
279 );
280 assert_eq!(result, None);
281 }
282
283 #[test]
284 fn returns_none_when_remote_url_unrecognised() {
285 let config = BackendApiConfig::default();
286 let runner = StubRunner::with_outputs(vec![Ok(ok_output("https://github.com/onlyone\n"))]);
288 let result = resolve_org_repo_from_config_or_remote_with_runner(
289 &runner,
290 std::env::temp_dir().as_path(),
291 &config,
292 );
293 assert_eq!(result, None);
294 }
295
296 #[test]
297 fn returns_none_when_remote_output_is_empty() {
298 let config = BackendApiConfig::default();
299 let runner = StubRunner::with_outputs(vec![Ok(ok_output(""))]);
300 let result = resolve_org_repo_from_config_or_remote_with_runner(
301 &runner,
302 std::env::temp_dir().as_path(),
303 &config,
304 );
305 assert_eq!(result, None);
306 }
307
308 #[test]
311 fn reexport_delegates_to_common_parser() {
312 assert_eq!(
313 parse_remote_url_org_repo("git@github.com:withakay/ito.git"),
314 Some(("withakay".to_string(), "ito".to_string()))
315 );
316 assert_eq!(parse_remote_url_org_repo(""), None);
317 }
318}