Skip to main content

track_core/
config.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::errors::{ErrorCode, TrackError};
8use crate::paths::{
9    collapse_home_path, get_config_path, get_managed_remote_agent_key_path,
10    get_managed_remote_agent_known_hosts_path, resolve_path_from_config_file,
11};
12use crate::types::{
13    ApiRuntimeConfig, LlamaCppModelSource, LlamaCppRuntimeConfig, RemoteAgentPreferredTool,
14    RemoteAgentReviewFollowUpRuntimeConfig, RemoteAgentRuntimeConfig, TrackRuntimeConfig,
15};
16
17pub const DEFAULT_API_PORT: u16 = 3210;
18pub const DEFAULT_REMOTE_AGENT_PORT: u16 = 22;
19pub const DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT: &str = "~/workspace";
20pub const DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH: &str = "~/track-projects.json";
21pub const DEFAULT_LLAMACPP_MODEL_HF_REPO: &str = "bartowski/Meta-Llama-3-8B-Instruct-GGUF";
22pub const DEFAULT_LLAMACPP_MODEL_HF_FILE: &str = "Meta-Llama-3-8B-Instruct-Q4_K_M.gguf";
23
24// =============================================================================
25// Config File Contract
26// =============================================================================
27//
28// The config format stays intentionally small and explicit. Because the
29// project is still in active development, we prefer one clear supported shape
30// over compatibility branches or implicit migration logic.
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
32pub struct TrackConfigFile {
33    #[serde(rename = "projectRoots", default)]
34    pub project_roots: Vec<String>,
35    #[serde(rename = "projectAliases", default)]
36    pub project_aliases: BTreeMap<String, String>,
37    #[serde(default)]
38    pub api: ApiConfigFile,
39    #[serde(
40        rename = "llamaCpp",
41        default,
42        skip_serializing_if = "LlamaCppConfigFile::is_empty"
43    )]
44    pub llama_cpp: LlamaCppConfigFile,
45    #[serde(
46        rename = "remoteAgent",
47        default,
48        skip_serializing_if = "Option::is_none"
49    )]
50    pub remote_agent: Option<RemoteAgentConfigFile>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ApiConfigFile {
55    #[serde(default = "default_api_port")]
56    pub port: u16,
57}
58
59impl Default for ApiConfigFile {
60    fn default() -> Self {
61        Self {
62            port: default_api_port(),
63        }
64    }
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct LlamaCppConfigFile {
69    #[serde(rename = "modelPath", default, skip_serializing_if = "Option::is_none")]
70    pub model_path: Option<String>,
71    #[serde(
72        rename = "modelHfRepo",
73        default,
74        skip_serializing_if = "Option::is_none"
75    )]
76    pub model_hf_repo: Option<String>,
77    #[serde(
78        rename = "modelHfFile",
79        default,
80        skip_serializing_if = "Option::is_none"
81    )]
82    pub model_hf_file: Option<String>,
83}
84
85impl LlamaCppConfigFile {
86    fn is_empty(&self) -> bool {
87        self.model_path.is_none() && self.model_hf_repo.is_none() && self.model_hf_file.is_none()
88    }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct RemoteAgentConfigFile {
93    pub host: String,
94    pub user: String,
95    #[serde(default = "default_remote_agent_port")]
96    pub port: u16,
97    #[serde(
98        rename = "workspaceRoot",
99        default = "default_remote_agent_workspace_root"
100    )]
101    pub workspace_root: String,
102    #[serde(
103        rename = "projectsRegistryPath",
104        default = "default_remote_projects_registry_path"
105    )]
106    pub projects_registry_path: String,
107    #[serde(
108        rename = "preferredTool",
109        default,
110        skip_serializing_if = "RemoteAgentPreferredTool::is_codex"
111    )]
112    pub preferred_tool: RemoteAgentPreferredTool,
113    #[serde(
114        rename = "shellPrelude",
115        default,
116        skip_serializing_if = "Option::is_none"
117    )]
118    pub shell_prelude: Option<String>,
119    #[serde(
120        rename = "reviewFollowUp",
121        default,
122        skip_serializing_if = "Option::is_none"
123    )]
124    pub review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
128pub struct RemoteAgentReviewFollowUpConfigFile {
129    #[serde(default)]
130    pub enabled: bool,
131    #[serde(rename = "mainUser", default, skip_serializing_if = "Option::is_none")]
132    pub main_user: Option<String>,
133    #[serde(
134        rename = "defaultReviewPrompt",
135        default,
136        skip_serializing_if = "Option::is_none"
137    )]
138    pub default_review_prompt: Option<String>,
139}
140
141fn default_api_port() -> u16 {
142    DEFAULT_API_PORT
143}
144
145fn default_remote_agent_port() -> u16 {
146    DEFAULT_REMOTE_AGENT_PORT
147}
148
149fn default_remote_agent_workspace_root() -> String {
150    DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT.to_owned()
151}
152
153fn default_remote_projects_registry_path() -> String {
154    DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH.to_owned()
155}
156
157fn default_llama_cpp_model_source() -> LlamaCppModelSource {
158    LlamaCppModelSource::HuggingFace {
159        repo: DEFAULT_LLAMACPP_MODEL_HF_REPO.to_owned(),
160        file: DEFAULT_LLAMACPP_MODEL_HF_FILE.to_owned(),
161    }
162}
163
164pub(crate) fn canonicalize_optional_multiline_value(value: Option<String>) -> Option<String> {
165    value
166        .map(|value| value.replace("\r\n", "\n").trim().to_owned())
167        .filter(|value| !value.is_empty())
168}
169
170pub(crate) fn canonicalize_remote_agent_config(
171    remote_agent: RemoteAgentConfigFile,
172) -> Result<RemoteAgentConfigFile, TrackError> {
173    let host = remote_agent.host.trim().to_owned();
174    let user = remote_agent.user.trim().to_owned();
175    let workspace_root = remote_agent.workspace_root.trim().to_owned();
176    let projects_registry_path = remote_agent.projects_registry_path.trim().to_owned();
177    let shell_prelude = canonicalize_optional_multiline_value(remote_agent.shell_prelude);
178    let review_follow_up = remote_agent
179        .review_follow_up
180        .map(|review_follow_up| {
181            let main_user = review_follow_up
182                .main_user
183                .map(|value| value.trim().to_owned())
184                .filter(|value| !value.is_empty());
185            let default_review_prompt =
186                canonicalize_optional_multiline_value(review_follow_up.default_review_prompt);
187
188            if review_follow_up.enabled && main_user.is_none() {
189                return Err(TrackError::new(
190                    ErrorCode::InvalidRemoteAgentConfig,
191                    "Remote review follow-up requires `mainUser` when the feature is enabled.",
192                ));
193            }
194
195            if !review_follow_up.enabled && main_user.is_none() && default_review_prompt.is_none() {
196                return Ok(None);
197            }
198
199            Ok(Some(RemoteAgentReviewFollowUpConfigFile {
200                enabled: review_follow_up.enabled,
201                main_user,
202                default_review_prompt,
203            }))
204        })
205        .transpose()?
206        .flatten();
207
208    if host.is_empty()
209        || user.is_empty()
210        || workspace_root.is_empty()
211        || projects_registry_path.is_empty()
212        || remote_agent.port == 0
213    {
214        return Err(TrackError::new(
215            ErrorCode::InvalidRemoteAgentConfig,
216            "Remote agent config requires host, user, workspace root, projects registry path, and a valid SSH port.",
217        ));
218    }
219
220    Ok(RemoteAgentConfigFile {
221        host,
222        user,
223        port: remote_agent.port,
224        workspace_root,
225        projects_registry_path,
226        preferred_tool: remote_agent.preferred_tool,
227        shell_prelude,
228        review_follow_up,
229    })
230}
231
232fn canonicalize_config_file(config: TrackConfigFile) -> Result<TrackConfigFile, TrackError> {
233    let project_roots = config
234        .project_roots
235        .into_iter()
236        .map(|value| value.trim().to_owned())
237        .filter(|value| !value.is_empty())
238        .collect::<Vec<_>>();
239
240    let project_aliases = config
241        .project_aliases
242        .into_iter()
243        .map(|(alias, canonical_name)| (alias.trim().to_owned(), canonical_name.trim().to_owned()))
244        .filter(|(alias, canonical_name)| !alias.is_empty() && !canonical_name.is_empty())
245        .collect::<BTreeMap<_, _>>();
246
247    let model_path = config
248        .llama_cpp
249        .model_path
250        .map(|value| value.trim().to_owned())
251        .filter(|value| !value.is_empty());
252    let model_hf_repo = config
253        .llama_cpp
254        .model_hf_repo
255        .map(|value| value.trim().to_owned())
256        .filter(|value| !value.is_empty());
257    let model_hf_file = config
258        .llama_cpp
259        .model_hf_file
260        .map(|value| value.trim().to_owned())
261        .filter(|value| !value.is_empty());
262    if model_hf_repo.is_some() != model_hf_file.is_some() {
263        return Err(TrackError::new(
264            ErrorCode::InvalidConfig,
265            "Config file requires both `llamaCpp.modelHfRepo` and `llamaCpp.modelHfFile` when using a Hugging Face model.",
266        ));
267    }
268
269    let api_port = config.api.port;
270    if api_port == 0 {
271        return Err(TrackError::new(
272            ErrorCode::InvalidConfig,
273            "Config file does not match the expected format.",
274        ));
275    }
276
277    let remote_agent = config
278        .remote_agent
279        .map(canonicalize_remote_agent_config)
280        .transpose()?;
281
282    Ok(TrackConfigFile {
283        project_roots,
284        project_aliases,
285        api: ApiConfigFile { port: api_port },
286        llama_cpp: LlamaCppConfigFile {
287            model_path,
288            model_hf_repo,
289            model_hf_file,
290        },
291        remote_agent,
292    })
293}
294
295pub struct ConfigService {
296    config_path: PathBuf,
297}
298
299impl ConfigService {
300    pub fn new(config_path: Option<PathBuf>) -> Result<Self, TrackError> {
301        Ok(Self {
302            config_path: match config_path {
303                Some(path) => path,
304                None => get_config_path()?,
305            },
306        })
307    }
308
309    pub fn resolved_path(&self) -> &Path {
310        &self.config_path
311    }
312
313    pub fn load_config_file(&self) -> Result<TrackConfigFile, TrackError> {
314        let raw_config = fs::read_to_string(&self.config_path).map_err(|error| {
315            if error.kind() == std::io::ErrorKind::NotFound {
316                return TrackError::new(
317                    ErrorCode::ConfigNotFound,
318                    format!(
319                        "Config file not found at {}",
320                        collapse_home_path(&self.config_path)
321                    ),
322                );
323            }
324
325            TrackError::new(
326                ErrorCode::InvalidConfig,
327                format!("Could not read the track config file: {error}"),
328            )
329        })?;
330
331        let parsed = serde_json::from_str::<TrackConfigFile>(&raw_config).map_err(|error| {
332            TrackError::new(
333                ErrorCode::InvalidConfig,
334                format!("Config file is not valid JSON: {error}"),
335            )
336        })?;
337
338        canonicalize_config_file(parsed)
339    }
340
341    pub fn save_config_file(&self, config: &TrackConfigFile) -> Result<(), TrackError> {
342        let canonical = canonicalize_config_file(config.clone())?;
343        let serialized = serde_json::to_string_pretty(&canonical).map_err(|error| {
344            TrackError::new(
345                ErrorCode::InvalidConfig,
346                format!("Could not serialize the track config file: {error}"),
347            )
348        })?;
349
350        if let Some(parent) = self.config_path.parent() {
351            fs::create_dir_all(parent).map_err(|error| {
352                TrackError::new(
353                    ErrorCode::InvalidConfig,
354                    format!(
355                        "Could not create the config directory for {}: {error}",
356                        collapse_home_path(&self.config_path)
357                    ),
358                )
359            })?;
360        }
361
362        fs::write(&self.config_path, format!("{serialized}\n")).map_err(|error| {
363            TrackError::new(
364                ErrorCode::InvalidConfig,
365                format!(
366                    "Could not write the track config file at {}: {error}",
367                    collapse_home_path(&self.config_path)
368                ),
369            )
370        })
371    }
372
373    pub fn load_runtime_config(&self) -> Result<TrackRuntimeConfig, TrackError> {
374        let config = self.load_config_file()?;
375
376        // Relative config values should keep working no matter where the user
377        // invokes `track` from, so we resolve them relative to the config file
378        // itself instead of the caller's current working directory.
379        let project_roots = config
380            .project_roots
381            .iter()
382            .map(|value| resolve_path_from_config_file(value, &self.config_path))
383            .collect::<Result<Vec<_>, _>>()?;
384
385        let project_aliases = config.project_aliases;
386        let model_source = if let (Some(repo), Some(file)) = (
387            config.llama_cpp.model_hf_repo.clone(),
388            config.llama_cpp.model_hf_file.clone(),
389        ) {
390            LlamaCppModelSource::HuggingFace { repo, file }
391        } else if let Some(model_path) = config.llama_cpp.model_path.as_deref() {
392            LlamaCppModelSource::LocalPath(resolve_path_from_config_file(
393                model_path,
394                &self.config_path,
395            )?)
396        } else {
397            default_llama_cpp_model_source()
398        };
399        let remote_agent = config
400            .remote_agent
401            .map(|remote_agent| {
402                Ok(RemoteAgentRuntimeConfig {
403                    host: remote_agent.host,
404                    user: remote_agent.user,
405                    port: remote_agent.port,
406                    workspace_root: remote_agent.workspace_root,
407                    projects_registry_path: remote_agent.projects_registry_path,
408                    preferred_tool: remote_agent.preferred_tool,
409                    shell_prelude: remote_agent.shell_prelude,
410                    review_follow_up: remote_agent.review_follow_up.and_then(|review_follow_up| {
411                        review_follow_up.main_user.map(|main_user| {
412                            RemoteAgentReviewFollowUpRuntimeConfig {
413                                enabled: review_follow_up.enabled,
414                                main_user,
415                                default_review_prompt: review_follow_up.default_review_prompt,
416                            }
417                        })
418                    }),
419                    managed_key_path: get_managed_remote_agent_key_path()?,
420                    managed_known_hosts_path: get_managed_remote_agent_known_hosts_path()?,
421                })
422            })
423            .transpose()?;
424
425        Ok(TrackRuntimeConfig {
426            project_roots,
427            project_aliases,
428            api: ApiRuntimeConfig {
429                port: config.api.port,
430            },
431            llama_cpp: LlamaCppRuntimeConfig { model_source },
432            remote_agent,
433        })
434    }
435
436    pub fn load_remote_agent_config(&self) -> Result<Option<RemoteAgentConfigFile>, TrackError> {
437        Ok(self.load_config_file()?.remote_agent)
438    }
439
440    pub fn save_remote_agent_settings(
441        &self,
442        preferred_tool: RemoteAgentPreferredTool,
443        shell_prelude: Option<String>,
444        review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
445    ) -> Result<RemoteAgentConfigFile, TrackError> {
446        let mut config = self.load_config_file()?;
447        let Some(remote_agent) = config.remote_agent.as_mut() else {
448            return Err(TrackError::new(
449                ErrorCode::RemoteAgentNotConfigured,
450                "Remote dispatch is not configured yet. Re-run `track` and add a remote agent host plus SSH key.",
451            ));
452        };
453
454        remote_agent.preferred_tool = preferred_tool;
455        remote_agent.shell_prelude = canonicalize_optional_multiline_value(shell_prelude);
456        remote_agent.review_follow_up = review_follow_up;
457        self.save_config_file(&config)?;
458
459        self.load_config_file()?
460            .remote_agent
461            .ok_or_else(|| {
462                TrackError::new(
463                    ErrorCode::RemoteAgentNotConfigured,
464                    "Remote dispatch is not configured yet. Re-run `track` and add a remote agent host plus SSH key.",
465                )
466            })
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use std::collections::BTreeMap;
473    use std::fs;
474
475    use tempfile::TempDir;
476
477    use super::{
478        default_llama_cpp_model_source, ConfigService, RemoteAgentConfigFile,
479        RemoteAgentReviewFollowUpConfigFile, TrackConfigFile, DEFAULT_API_PORT,
480        DEFAULT_LLAMACPP_MODEL_HF_FILE, DEFAULT_LLAMACPP_MODEL_HF_REPO, DEFAULT_REMOTE_AGENT_PORT,
481        DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT, DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH,
482    };
483    use crate::errors::ErrorCode;
484    use crate::types::LlamaCppModelSource;
485    use crate::types::RemoteAgentPreferredTool;
486
487    fn temp_config_service() -> (TempDir, ConfigService) {
488        let directory = TempDir::new().expect("tempdir should be created");
489        let config_path = directory.path().join("config.json");
490        let service = ConfigService::new(Some(config_path)).expect("config service should resolve");
491        (directory, service)
492    }
493
494    #[test]
495    fn saves_current_local_only_shape() {
496        let (_directory, service) = temp_config_service();
497
498        service
499            .save_config_file(&TrackConfigFile {
500                project_roots: vec!["~/work".to_owned()],
501                project_aliases: BTreeMap::new(),
502                api: super::ApiConfigFile {
503                    port: DEFAULT_API_PORT,
504                },
505                llama_cpp: super::LlamaCppConfigFile {
506                    model_path: Some("~/.models/parser.gguf".to_owned()),
507                    model_hf_repo: None,
508                    model_hf_file: None,
509                },
510                remote_agent: Some(RemoteAgentConfigFile {
511                    host: "192.0.2.25".to_owned(),
512                    user: "builder".to_owned(),
513                    port: DEFAULT_REMOTE_AGENT_PORT,
514                    workspace_root: DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT.to_owned(),
515                    projects_registry_path: DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH.to_owned(),
516                    preferred_tool: RemoteAgentPreferredTool::Codex,
517                    shell_prelude: Some("export PATH=\"$PATH:/opt/tools/bin\"".to_owned()),
518                    review_follow_up: None,
519                }),
520            })
521            .expect("config should save");
522
523        let raw =
524            fs::read_to_string(service.resolved_path()).expect("saved config should be readable");
525        assert!(raw.contains("\"llamaCpp\""));
526        assert!(raw.contains("\"remoteAgent\""));
527        assert!(raw.contains("\"shellPrelude\""));
528        assert!(!raw.contains("\"preferredTool\""));
529        assert!(!raw.contains("\"modelHfRepo\""));
530        assert!(!raw.contains("\"ai\""));
531    }
532
533    #[test]
534    fn omits_llama_cpp_block_when_no_manual_override_is_configured() {
535        let (_directory, service) = temp_config_service();
536
537        service
538            .save_config_file(&TrackConfigFile {
539                project_roots: vec!["~/work".to_owned()],
540                project_aliases: BTreeMap::new(),
541                api: super::ApiConfigFile {
542                    port: DEFAULT_API_PORT,
543                },
544                llama_cpp: super::LlamaCppConfigFile::default(),
545                remote_agent: None,
546            })
547            .expect("config should save");
548
549        let raw =
550            fs::read_to_string(service.resolved_path()).expect("saved config should be readable");
551        assert!(!raw.contains("\"llamaCpp\""));
552    }
553
554    #[test]
555    fn resolves_relative_runtime_paths_from_the_config_file_location() {
556        let directory = TempDir::new().expect("tempdir should be created");
557        let config_path = directory.path().join(".config/track/config.json");
558        let service =
559            ConfigService::new(Some(config_path.clone())).expect("config service should resolve");
560
561        service
562            .save_config_file(&TrackConfigFile {
563                project_roots: vec!["../work".to_owned()],
564                project_aliases: BTreeMap::new(),
565                api: super::ApiConfigFile { port: 4210 },
566                llama_cpp: super::LlamaCppConfigFile {
567                    model_path: Some("./models/parser.gguf".to_owned()),
568                    model_hf_repo: None,
569                    model_hf_file: None,
570                },
571                remote_agent: Some(RemoteAgentConfigFile {
572                    host: "192.0.2.25".to_owned(),
573                    user: "builder".to_owned(),
574                    port: 2222,
575                    workspace_root: "~/workspace".to_owned(),
576                    projects_registry_path: "~/track-projects.json".to_owned(),
577                    preferred_tool: RemoteAgentPreferredTool::Codex,
578                    shell_prelude: Some("export PATH=\"$PATH:/opt/tools/bin\"".to_owned()),
579                    review_follow_up: None,
580                }),
581            })
582            .expect("config should save");
583
584        let runtime = service
585            .load_runtime_config()
586            .expect("runtime config should resolve");
587        let config_directory = config_path
588            .parent()
589            .expect("config path should have a parent");
590
591        assert_eq!(
592            runtime.project_roots,
593            vec![config_directory.join("../work")]
594        );
595        assert_eq!(runtime.api.port, 4210);
596        assert_eq!(
597            runtime.llama_cpp.model_source,
598            LlamaCppModelSource::LocalPath(config_directory.join("./models/parser.gguf"))
599        );
600        let remote_agent = runtime
601            .remote_agent
602            .expect("remote agent runtime config should resolve");
603        assert_eq!(remote_agent.host, "192.0.2.25");
604        assert_eq!(remote_agent.user, "builder");
605        assert_eq!(remote_agent.port, 2222);
606        assert_eq!(remote_agent.preferred_tool, RemoteAgentPreferredTool::Codex);
607        assert_eq!(
608            remote_agent.shell_prelude,
609            Some("export PATH=\"$PATH:/opt/tools/bin\"".to_owned())
610        );
611    }
612
613    #[test]
614    fn prefers_hugging_face_model_when_both_sources_are_configured() {
615        let (_directory, service) = temp_config_service();
616
617        service
618            .save_config_file(&TrackConfigFile {
619                project_roots: vec!["~/work".to_owned()],
620                project_aliases: BTreeMap::new(),
621                api: super::ApiConfigFile {
622                    port: DEFAULT_API_PORT,
623                },
624                llama_cpp: super::LlamaCppConfigFile {
625                    model_path: Some("~/.models/custom-parser.gguf".to_owned()),
626                    model_hf_repo: Some(DEFAULT_LLAMACPP_MODEL_HF_REPO.to_owned()),
627                    model_hf_file: Some(DEFAULT_LLAMACPP_MODEL_HF_FILE.to_owned()),
628                },
629                remote_agent: None,
630            })
631            .expect("config should save");
632
633        let runtime = service
634            .load_runtime_config()
635            .expect("runtime config should resolve");
636
637        assert_eq!(
638            runtime.llama_cpp.model_source,
639            LlamaCppModelSource::HuggingFace {
640                repo: DEFAULT_LLAMACPP_MODEL_HF_REPO.to_owned(),
641                file: DEFAULT_LLAMACPP_MODEL_HF_FILE.to_owned(),
642            }
643        );
644    }
645
646    #[test]
647    fn defaults_to_the_builtin_hugging_face_model_when_no_override_is_configured() {
648        let (_directory, service) = temp_config_service();
649
650        service
651            .save_config_file(&TrackConfigFile {
652                project_roots: vec!["~/work".to_owned()],
653                project_aliases: BTreeMap::new(),
654                api: super::ApiConfigFile {
655                    port: DEFAULT_API_PORT,
656                },
657                llama_cpp: super::LlamaCppConfigFile::default(),
658                remote_agent: None,
659            })
660            .expect("config should save");
661
662        let runtime = service
663            .load_runtime_config()
664            .expect("runtime config should resolve");
665
666        assert_eq!(
667            runtime.llama_cpp.model_source,
668            default_llama_cpp_model_source()
669        );
670    }
671
672    #[test]
673    fn rejects_enabled_review_follow_up_without_a_main_user() {
674        let (_directory, service) = temp_config_service();
675
676        let error = service
677            .save_config_file(&TrackConfigFile {
678                project_roots: vec!["~/work".to_owned()],
679                project_aliases: BTreeMap::new(),
680                api: super::ApiConfigFile {
681                    port: DEFAULT_API_PORT,
682                },
683                llama_cpp: super::LlamaCppConfigFile::default(),
684                remote_agent: Some(RemoteAgentConfigFile {
685                    host: "192.0.2.25".to_owned(),
686                    user: "builder".to_owned(),
687                    port: DEFAULT_REMOTE_AGENT_PORT,
688                    workspace_root: DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT.to_owned(),
689                    projects_registry_path: DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH.to_owned(),
690                    preferred_tool: RemoteAgentPreferredTool::Codex,
691                    shell_prelude: Some("export PATH=\"$PATH:/opt/tools/bin\"".to_owned()),
692                    review_follow_up: Some(RemoteAgentReviewFollowUpConfigFile {
693                        enabled: true,
694                        main_user: None,
695                        default_review_prompt: None,
696                    }),
697                }),
698            })
699            .expect_err("enabled review follow-up without a main user should fail");
700
701        assert_eq!(error.code, ErrorCode::InvalidRemoteAgentConfig);
702    }
703}