1use std::collections::BTreeMap;
2use std::fs;
3use std::io::{self, IsTerminal};
4
5use dialoguer::{theme::ColorfulTheme, Input};
6
7use crate::config::{
8 ApiConfigFile, ConfigService, LlamaCppConfigFile, RemoteAgentConfigFile,
9 RemoteAgentReviewFollowUpConfigFile, TrackConfigFile, DEFAULT_LLAMACPP_MODEL_HF_FILE,
10 DEFAULT_LLAMACPP_MODEL_HF_REPO, DEFAULT_REMOTE_AGENT_PORT, DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT,
11 DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH,
12};
13use crate::errors::{ErrorCode, TrackError};
14use crate::paths::{
15 collapse_home_path, collapse_path_value, get_managed_remote_agent_key_path,
16 get_managed_remote_agent_known_hosts_path, resolve_path_from_invocation_dir,
17};
18use crate::terminal_ui::{
19 format_note, format_prompt_label, format_summary, SummaryTone, ValueTone,
20};
21use crate::types::RemoteAgentPreferredTool;
22
23pub const NONE_SENTINEL: &str = "none";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ConfigureReason {
27 FirstRun,
28 Manual,
29}
30
31pub trait Prompter {
32 fn ask(&mut self, prompt: &str) -> Result<String, TrackError>;
33 fn println(&mut self, line: &str);
34}
35
36pub struct TerminalPrompter;
37
38impl Prompter for TerminalPrompter {
39 fn ask(&mut self, prompt: &str) -> Result<String, TrackError> {
40 Input::<String>::with_theme(&ColorfulTheme::default())
41 .with_prompt(prompt)
42 .allow_empty(true)
43 .interact_text()
44 .map_err(|error| {
45 TrackError::new(
46 ErrorCode::InteractiveRequired,
47 format!("Could not read interactive input: {error}"),
48 )
49 })
50 }
51
52 fn println(&mut self, line: &str) {
53 println!("{line}");
54 }
55}
56
57pub fn parse_project_roots_input(input: &str) -> Vec<String> {
58 input
59 .split([',', '\n'])
60 .map(|value| value.trim().to_owned())
61 .filter(|value| !value.is_empty())
62 .collect::<Vec<_>>()
63}
64
65pub fn parse_project_aliases_input(input: &str) -> Result<BTreeMap<String, String>, TrackError> {
66 if input.trim().is_empty() {
67 return Ok(BTreeMap::new());
68 }
69
70 let mut aliases = BTreeMap::new();
71
72 for entry in input
73 .split([',', '\n'])
74 .map(|value| value.trim())
75 .filter(|value| !value.is_empty())
76 {
77 let Some((alias, canonical_name)) = entry.split_once('=') else {
78 return Err(TrackError::new(
79 ErrorCode::InvalidConfigInput,
80 "Project aliases must use alias=canonical-name format.",
81 ));
82 };
83
84 let alias = alias.trim();
85 let canonical_name = canonical_name.trim();
86 if alias.is_empty() || canonical_name.is_empty() {
87 return Err(TrackError::new(
88 ErrorCode::InvalidConfigInput,
89 "Project aliases must use alias=canonical-name format.",
90 ));
91 }
92
93 aliases.insert(alias.to_owned(), canonical_name.to_owned());
94 }
95
96 Ok(aliases)
97}
98
99fn format_project_aliases_input(aliases: &BTreeMap<String, String>) -> String {
100 aliases
101 .iter()
102 .map(|(alias, canonical_name)| format!("{alias}={canonical_name}"))
103 .collect::<Vec<_>>()
104 .join(", ")
105}
106
107fn format_project_roots_display(roots: &[String]) -> String {
108 roots
109 .iter()
110 .map(|value| collapse_path_value(value))
111 .collect::<Vec<_>>()
112 .join(", ")
113}
114
115fn create_default_config_file() -> TrackConfigFile {
116 TrackConfigFile {
117 project_roots: Vec::new(),
118 project_aliases: BTreeMap::new(),
119 api: ApiConfigFile::default(),
120 llama_cpp: LlamaCppConfigFile::default(),
121 remote_agent: None,
122 }
123}
124
125fn ensure_interactive_terminal(config_path: &std::path::Path) -> Result<(), TrackError> {
126 if io::stdin().is_terminal() && io::stdout().is_terminal() {
127 return Ok(());
128 }
129
130 Err(TrackError::new(
131 ErrorCode::InteractiveRequired,
132 format!(
133 "Config setup requires an interactive terminal. Create {} manually or rerun `track` in a terminal.",
134 collapse_home_path(config_path)
135 ),
136 ))
137}
138
139fn prompt_with_default(
140 prompter: &mut dyn Prompter,
141 label: &str,
142 default_value: Option<&str>,
143 allow_clear: bool,
144) -> Result<String, TrackError> {
145 let prompt = format_prompt_label(label, default_value.filter(|value| !value.is_empty()));
146
147 let response = prompter.ask(&prompt)?.trim().to_owned();
148
149 if allow_clear && response.eq_ignore_ascii_case(NONE_SENTINEL) {
150 return Ok(String::new());
151 }
152
153 if response.is_empty() {
154 return Ok(default_value.unwrap_or_default().to_owned());
155 }
156
157 Ok(response)
158}
159
160fn prompt_required_value(
161 prompter: &mut dyn Prompter,
162 label: &str,
163 default_value: Option<&str>,
164) -> Result<String, TrackError> {
165 loop {
166 let response = prompt_with_default(prompter, label, default_value, false)?;
167 if !response.trim().is_empty() {
168 return Ok(response.trim().to_owned());
169 }
170
171 prompter.println("Please enter a value.");
172 }
173}
174
175fn prompt_project_roots(
176 prompter: &mut dyn Prompter,
177 defaults: &[String],
178) -> Result<Vec<String>, TrackError> {
179 loop {
180 let response = prompt_with_default(
181 prompter,
182 "Project roots (comma-separated)",
183 Some(&format_project_roots_display(defaults)),
184 false,
185 )?;
186
187 let project_roots = parse_project_roots_input(&response);
188 if !project_roots.is_empty() {
189 return Ok(project_roots);
190 }
191
192 prompter.println("Please enter at least one project root.");
193 }
194}
195
196fn prompt_project_aliases(
197 prompter: &mut dyn Prompter,
198 defaults: &BTreeMap<String, String>,
199) -> Result<BTreeMap<String, String>, TrackError> {
200 loop {
201 let response = prompt_with_default(
202 prompter,
203 "Project aliases (alias=canonical-name, comma-separated)",
204 Some(&format_project_aliases_input(defaults)),
205 true,
206 )?;
207
208 match parse_project_aliases_input(&response) {
209 Ok(aliases) => return Ok(aliases),
210 Err(error) => prompter.println(error.message()),
211 }
212 }
213}
214
215fn prompt_api_port(prompter: &mut dyn Prompter, default_port: u16) -> Result<u16, TrackError> {
216 loop {
217 let response = prompt_with_default(
218 prompter,
219 "Local API port",
220 Some(&default_port.to_string()),
221 false,
222 )?;
223
224 match response.parse::<u16>() {
225 Ok(port) if port > 0 => return Ok(port),
226 _ => prompter.println("Please enter a valid TCP port."),
227 }
228 }
229}
230
231fn prompt_remote_agent_host(
232 prompter: &mut dyn Prompter,
233 default_host: Option<&str>,
234) -> Result<Option<String>, TrackError> {
235 let response = prompt_with_default(prompter, "Remote agent host", default_host, true)?;
236 let trimmed = response.trim();
237 if trimmed.is_empty() {
238 Ok(None)
239 } else {
240 Ok(Some(trimmed.to_owned()))
241 }
242}
243
244fn prompt_remote_agent_port(
245 prompter: &mut dyn Prompter,
246 default_port: u16,
247) -> Result<u16, TrackError> {
248 loop {
249 let response = prompt_with_default(
250 prompter,
251 "Remote SSH port",
252 Some(&default_port.to_string()),
253 false,
254 )?;
255
256 match response.parse::<u16>() {
257 Ok(port) if port > 0 => return Ok(port),
258 _ => prompter.println("Please enter a valid SSH port."),
259 }
260 }
261}
262
263fn prompt_yes_no(
264 prompter: &mut dyn Prompter,
265 label: &str,
266 default_value: bool,
267) -> Result<bool, TrackError> {
268 loop {
269 let default_display = if default_value { "yes" } else { "no" };
270 let response = prompt_with_default(prompter, label, Some(default_display), false)?;
271
272 match response.trim().to_ascii_lowercase().as_str() {
273 "y" | "yes" | "true" => return Ok(true),
274 "n" | "no" | "false" => return Ok(false),
275 _ => prompter.println("Please answer yes or no."),
276 }
277 }
278}
279
280fn managed_remote_agent_key_exists() -> Result<bool, TrackError> {
281 Ok(get_managed_remote_agent_key_path()?.exists())
282}
283
284fn install_managed_remote_agent_key(source_path: &str) -> Result<(), TrackError> {
285 let source_path = resolve_path_from_invocation_dir(source_path)?;
286 let managed_key_path = get_managed_remote_agent_key_path()?;
287 let known_hosts_path = get_managed_remote_agent_known_hosts_path()?;
288
289 let Some(parent_directory) = managed_key_path.parent() else {
290 return Err(TrackError::new(
291 ErrorCode::InvalidRemoteAgentConfig,
292 "Could not determine the managed remote-agent directory.",
293 ));
294 };
295
296 fs::create_dir_all(parent_directory).map_err(|error| {
297 TrackError::new(
298 ErrorCode::InvalidRemoteAgentConfig,
299 format!(
300 "Could not create the managed remote-agent directory at {}: {error}",
301 collapse_home_path(parent_directory)
302 ),
303 )
304 })?;
305
306 fs::copy(&source_path, &managed_key_path).map_err(|error| {
307 TrackError::new(
308 ErrorCode::InvalidRemoteAgentConfig,
309 format!(
310 "Could not copy the SSH private key from {} to {}: {error}",
311 collapse_home_path(&source_path),
312 collapse_home_path(&managed_key_path)
313 ),
314 )
315 })?;
316
317 #[cfg(unix)]
318 {
319 use std::os::unix::fs::PermissionsExt;
320
321 fs::set_permissions(&managed_key_path, fs::Permissions::from_mode(0o600)).map_err(
322 |error| {
323 TrackError::new(
324 ErrorCode::InvalidRemoteAgentConfig,
325 format!(
326 "Could not set permissions on the managed SSH private key at {}: {error}",
327 collapse_home_path(&managed_key_path)
328 ),
329 )
330 },
331 )?;
332 }
333
334 if !known_hosts_path.exists() {
335 fs::write(&known_hosts_path, "").map_err(|error| {
336 TrackError::new(
337 ErrorCode::InvalidRemoteAgentConfig,
338 format!(
339 "Could not create the managed known_hosts file at {}: {error}",
340 collapse_home_path(&known_hosts_path)
341 ),
342 )
343 })?;
344 }
345
346 Ok(())
347}
348
349fn prompt_remote_agent_key_import(
350 prompter: &mut dyn Prompter,
351 has_existing_managed_key: bool,
352) -> Result<(), TrackError> {
353 loop {
354 let label = if has_existing_managed_key {
355 "SSH private key to import"
356 } else {
357 "SSH private key to import"
358 };
359 let response = prompt_with_default(prompter, label, None, false)?;
360 let trimmed = response.trim();
361
362 if trimmed.is_empty() && has_existing_managed_key {
363 return Ok(());
364 }
365
366 if trimmed.is_empty() {
367 prompter.println(
368 "Please provide a private SSH key path or finish setup later by rerunning `track`.",
369 );
370 continue;
371 }
372
373 return install_managed_remote_agent_key(trimmed);
374 }
375}
376
377fn format_config_saved_output(
378 config: &TrackConfigFile,
379 config_path: &std::path::Path,
380 reason: ConfigureReason,
381) -> String {
382 let (remote_agent_display, remote_agent_tone) = match config.remote_agent.as_ref() {
383 Some(remote_agent) => (
384 format!(
385 "{}@{}:{}",
386 remote_agent.user, remote_agent.host, remote_agent.port
387 ),
388 ValueTone::Plain,
389 ),
390 None => ("disabled".to_owned(), ValueTone::Plain),
391 };
392
393 let summary = format_summary(
394 match reason {
395 ConfigureReason::FirstRun => "Config created",
396 ConfigureReason::Manual => "Config updated",
397 },
398 SummaryTone::Success,
399 &[
400 ("File", collapse_home_path(config_path), ValueTone::Path),
401 (
402 "Project roots",
403 format!("{} configured", config.project_roots.len()),
404 ValueTone::Plain,
405 ),
406 (
407 "Aliases",
408 format!("{} configured", config.project_aliases.len()),
409 ValueTone::Plain,
410 ),
411 ("API port", config.api.port.to_string(), ValueTone::Plain),
412 ("Remote", remote_agent_display, remote_agent_tone),
413 ],
414 );
415
416 let preserved_model_override_fields = preserved_model_override_fields(&config.llama_cpp);
417 if preserved_model_override_fields.is_empty() {
418 return summary;
419 }
420
421 format!(
422 "{summary}\n\n{}",
423 format_note(
424 "Advanced",
425 &format!(
426 "The following fields are set in {} but are not managed by the wizard: {}. Edit the file directly if you need to change them.",
427 collapse_home_path(config_path),
428 preserved_model_override_fields.join(", "),
429 ),
430 )
431 )
432}
433
434fn preserved_model_override_fields(config: &LlamaCppConfigFile) -> Vec<&'static str> {
435 let mut fields = Vec::new();
436
437 if config.model_path.is_some() {
438 fields.push("llamaCpp.modelPath");
439 }
440
441 if let (Some(repo), Some(file)) = (
442 config.model_hf_repo.as_deref(),
443 config.model_hf_file.as_deref(),
444 ) {
445 let uses_builtin_default =
446 repo == DEFAULT_LLAMACPP_MODEL_HF_REPO && file == DEFAULT_LLAMACPP_MODEL_HF_FILE;
447 if !uses_builtin_default {
448 fields.push("llamaCpp.modelHfRepo");
449 fields.push("llamaCpp.modelHfFile");
450 }
451 }
452
453 fields
454}
455
456pub fn run_configure_command(
457 config_service: &ConfigService,
458 reason: ConfigureReason,
459) -> Result<String, TrackError> {
460 ensure_interactive_terminal(config_service.resolved_path())?;
461 let mut prompter = TerminalPrompter;
462 run_configure_command_with_prompter(config_service, &mut prompter, reason)
463}
464
465pub fn run_configure_command_with_prompter(
466 config_service: &ConfigService,
467 prompter: &mut dyn Prompter,
468 reason: ConfigureReason,
469) -> Result<String, TrackError> {
470 let existing_config = match config_service.load_config_file() {
471 Ok(config) => Some(config),
472 Err(error) if error.code == ErrorCode::ConfigNotFound => None,
473 Err(error) => return Err(error),
474 };
475 let defaults = existing_config.unwrap_or_else(create_default_config_file);
476
477 let intro = match reason {
480 ConfigureReason::FirstRun => format_summary(
481 "Config setup",
482 SummaryTone::Info,
483 &[(
484 "File",
485 collapse_home_path(config_service.resolved_path()),
486 ValueTone::Path,
487 )],
488 ),
489 ConfigureReason::Manual => format_summary(
490 "Config editor",
491 SummaryTone::Info,
492 &[(
493 "File",
494 collapse_home_path(config_service.resolved_path()),
495 ValueTone::Path,
496 )],
497 ),
498 };
499 prompter.println(&intro);
500 prompter.println(&format_note("Enter", "keep current values"));
501 prompter.println(&format_note(NONE_SENTINEL, "clear optional values"));
502
503 let api_port = prompt_api_port(prompter, defaults.api.port)?;
504 let project_roots = prompt_project_roots(prompter, &defaults.project_roots)?;
505 let project_aliases = prompt_project_aliases(prompter, &defaults.project_aliases)?;
506 let remote_agent_host = prompt_remote_agent_host(
507 prompter,
508 defaults
509 .remote_agent
510 .as_ref()
511 .map(|remote_agent| remote_agent.host.as_str()),
512 )?;
513 let remote_agent = if let Some(host) = remote_agent_host {
514 let existing_remote_agent = defaults.remote_agent.as_ref();
515 let remote_user = prompt_required_value(
516 prompter,
517 "Remote agent user",
518 existing_remote_agent.map(|remote_agent| remote_agent.user.as_str()),
519 )?;
520 let remote_port = prompt_remote_agent_port(
521 prompter,
522 existing_remote_agent
523 .map(|remote_agent| remote_agent.port)
524 .unwrap_or(DEFAULT_REMOTE_AGENT_PORT),
525 )?;
526 let remote_workspace_root = prompt_required_value(
527 prompter,
528 "Remote workspace root",
529 existing_remote_agent
530 .map(|remote_agent| remote_agent.workspace_root.as_str())
531 .or(Some(DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT)),
532 )?;
533 let remote_projects_registry_path = prompt_required_value(
534 prompter,
535 "Remote projects registry path",
536 existing_remote_agent
537 .map(|remote_agent| remote_agent.projects_registry_path.as_str())
538 .or(Some(DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH)),
539 )?;
540 prompt_remote_agent_key_import(prompter, managed_remote_agent_key_exists()?)?;
541 let existing_review_follow_up =
542 existing_remote_agent.and_then(|remote_agent| remote_agent.review_follow_up.as_ref());
543 let review_follow_up_enabled = prompt_yes_no(
544 prompter,
545 "Enable automatic GitHub review follow-ups",
546 existing_review_follow_up
547 .map(|review_follow_up| review_follow_up.enabled)
548 .unwrap_or(false),
549 )?;
550
551 let review_follow_up = if review_follow_up_enabled {
558 let main_user = prompt_required_value(
559 prompter,
560 "GitHub user for automatic follow-ups",
561 existing_review_follow_up
562 .and_then(|review_follow_up| review_follow_up.main_user.as_deref()),
563 )?;
564
565 Some(RemoteAgentReviewFollowUpConfigFile {
566 enabled: true,
567 main_user: Some(main_user),
568 default_review_prompt: existing_review_follow_up
569 .and_then(|review_follow_up| review_follow_up.default_review_prompt.clone()),
570 })
571 } else {
572 existing_review_follow_up
573 .and_then(|review_follow_up| review_follow_up.main_user.clone())
574 .map(|main_user| RemoteAgentReviewFollowUpConfigFile {
575 enabled: false,
576 main_user: Some(main_user),
577 default_review_prompt: existing_review_follow_up.and_then(|review_follow_up| {
578 review_follow_up.default_review_prompt.clone()
579 }),
580 })
581 };
582
583 Some(RemoteAgentConfigFile {
584 host,
585 user: remote_user,
586 port: remote_port,
587 workspace_root: remote_workspace_root,
588 projects_registry_path: remote_projects_registry_path,
589 preferred_tool: existing_remote_agent
593 .map(|remote_agent| remote_agent.preferred_tool)
594 .unwrap_or(RemoteAgentPreferredTool::Codex),
595 shell_prelude: existing_remote_agent
601 .and_then(|remote_agent| remote_agent.shell_prelude.clone()),
602 review_follow_up,
603 })
604 } else {
605 None
610 };
611
612 let config = TrackConfigFile {
613 project_roots,
614 project_aliases,
615 api: ApiConfigFile { port: api_port },
616 llama_cpp: defaults.llama_cpp.clone(),
617 remote_agent,
618 };
619
620 config_service.save_config_file(&config)?;
621 Ok(format_config_saved_output(
622 &config,
623 config_service.resolved_path(),
624 reason,
625 ))
626}
627
628#[cfg(test)]
629mod tests {
630 use std::collections::{BTreeMap, VecDeque};
631
632 use tempfile::TempDir;
633
634 use super::{
635 parse_project_aliases_input, parse_project_roots_input,
636 run_configure_command_with_prompter, ConfigureReason, Prompter,
637 };
638 use crate::config::{
639 ConfigService, LlamaCppConfigFile, RemoteAgentReviewFollowUpConfigFile, TrackConfigFile,
640 DEFAULT_LLAMACPP_MODEL_HF_FILE, DEFAULT_LLAMACPP_MODEL_HF_REPO,
641 };
642 use crate::test_support::{set_env_var, track_data_env_lock};
643
644 struct ScriptedPrompter {
645 answers: VecDeque<String>,
646 lines: Vec<String>,
647 }
648
649 impl ScriptedPrompter {
650 fn new(answers: &[&str]) -> Self {
651 Self {
652 answers: answers.iter().map(|value| (*value).to_owned()).collect(),
653 lines: Vec::new(),
654 }
655 }
656 }
657
658 impl Prompter for ScriptedPrompter {
659 fn ask(&mut self, _prompt: &str) -> Result<String, crate::errors::TrackError> {
660 Ok(self
661 .answers
662 .pop_front()
663 .expect("scripted prompt should have enough answers"))
664 }
665
666 fn println(&mut self, line: &str) {
667 self.lines.push(line.to_owned());
668 }
669 }
670
671 fn temp_config_service() -> (TempDir, ConfigService) {
672 let directory = TempDir::new().expect("tempdir should be created");
673 let service = ConfigService::new(Some(directory.path().join("config.json")))
674 .expect("config service should resolve");
675 (directory, service)
676 }
677
678 #[test]
679 fn parses_project_roots() {
680 assert_eq!(
681 parse_project_roots_input("~/work, ~/oss\n~/lab"),
682 vec!["~/work", "~/oss", "~/lab"]
683 );
684 }
685
686 #[test]
687 fn parses_project_aliases() {
688 let aliases = parse_project_aliases_input("proj-x=project-x, infra=platform")
689 .expect("aliases should parse");
690
691 assert_eq!(aliases.get("proj-x"), Some(&"project-x".to_owned()));
692 assert_eq!(aliases.get("infra"), Some(&"platform".to_owned()));
693 }
694
695 #[test]
696 fn writes_first_run_config() {
697 let (_directory, service) = temp_config_service();
698 let mut prompter =
699 ScriptedPrompter::new(&["3210", "~/work, ~/oss", "proj-x=project-x", ""]);
700
701 let output =
702 run_configure_command_with_prompter(&service, &mut prompter, ConfigureReason::FirstRun)
703 .expect("config wizard should succeed");
704
705 assert!(output.contains("Config created"));
706 let raw = std::fs::read_to_string(service.resolved_path()).expect("config should save");
707 assert!(!raw.contains("\"llamaCpp\""));
708 assert!(raw.contains("\"projectRoots\""));
709 assert!(raw.contains("\"api\""));
710 }
711
712 #[test]
713 fn mentions_preserved_manual_model_overrides() {
714 let (_directory, service) = temp_config_service();
715 service
716 .save_config_file(&TrackConfigFile {
717 project_roots: vec!["~/work".to_owned()],
718 project_aliases: BTreeMap::new(),
719 api: crate::config::ApiConfigFile::default(),
720 llama_cpp: LlamaCppConfigFile {
721 model_path: Some("~/.models/custom.gguf".to_owned()),
722 model_hf_repo: None,
723 model_hf_file: None,
724 },
725 remote_agent: None,
726 })
727 .expect("seed config should save");
728
729 let mut prompter = ScriptedPrompter::new(&["", "", "", ""]);
730 let output =
731 run_configure_command_with_prompter(&service, &mut prompter, ConfigureReason::Manual)
732 .expect("config wizard should succeed");
733
734 assert!(output.contains("llamaCpp.modelPath"));
735 }
736
737 #[test]
738 fn does_not_call_out_builtin_hugging_face_defaults() {
739 let (_directory, service) = temp_config_service();
740 service
741 .save_config_file(&TrackConfigFile {
742 project_roots: vec!["~/work".to_owned()],
743 project_aliases: BTreeMap::new(),
744 api: crate::config::ApiConfigFile::default(),
745 llama_cpp: LlamaCppConfigFile {
746 model_path: None,
747 model_hf_repo: Some(DEFAULT_LLAMACPP_MODEL_HF_REPO.to_owned()),
748 model_hf_file: Some(DEFAULT_LLAMACPP_MODEL_HF_FILE.to_owned()),
749 },
750 remote_agent: None,
751 })
752 .expect("seed config should save");
753
754 let mut prompter = ScriptedPrompter::new(&["", "", "", ""]);
755 let output =
756 run_configure_command_with_prompter(&service, &mut prompter, ConfigureReason::Manual)
757 .expect("config wizard should succeed");
758
759 assert!(!output.contains("llamaCpp.modelHfRepo"));
760 assert!(!output.contains("llamaCpp.modelHfFile"));
761 }
762
763 #[test]
764 fn writes_remote_review_follow_up_from_wizard() {
765 let (_directory, service) = temp_config_service();
766 let _track_data_dir_guard = track_data_env_lock()
767 .lock()
768 .expect("track data dir lock should not be poisoned");
769 let data_dir = service
770 .resolved_path()
771 .parent()
772 .expect("config path should have a parent")
773 .join("track-data")
774 .join("issues");
775 let _track_data_dir = set_env_var("TRACK_DATA_DIR", &data_dir);
776
777 let ssh_key_source = data_dir
778 .parent()
779 .expect("data dir should have a parent")
780 .join("id_ed25519.source");
781 std::fs::create_dir_all(
782 ssh_key_source
783 .parent()
784 .expect("SSH key source should have a parent"),
785 )
786 .expect("SSH key source parent should be created");
787 std::fs::write(&ssh_key_source, "not-a-real-private-key")
788 .expect("SSH key source should be written");
789
790 let ssh_key_source = ssh_key_source.to_string_lossy().into_owned();
791 let mut prompter = ScriptedPrompter::new(&[
792 "3210",
793 "~/work",
794 "",
795 "builder.example.com",
796 "codex",
797 "2222",
798 "/srv/track",
799 "/srv/track/projects.json",
800 &ssh_key_source,
801 "yes",
802 "octocat",
803 ]);
804
805 run_configure_command_with_prompter(&service, &mut prompter, ConfigureReason::FirstRun)
806 .expect("config wizard should succeed");
807
808 let saved = service
809 .load_config_file()
810 .expect("saved config should load successfully");
811 let remote_agent = saved
812 .remote_agent
813 .expect("remote agent config should be present");
814
815 assert_eq!(
816 remote_agent.review_follow_up,
817 Some(RemoteAgentReviewFollowUpConfigFile {
818 enabled: true,
819 main_user: Some("octocat".to_owned()),
820 default_review_prompt: None,
821 })
822 );
823 }
824}