Skip to main content

track_core/
backend_config.rs

1use std::fs;
2
3use crate::config::{
4    canonicalize_remote_agent_config, RemoteAgentConfigFile, RemoteAgentReviewFollowUpConfigFile,
5};
6use crate::errors::{ErrorCode, TrackError};
7use crate::migration::{MigrationStatus, MIGRATION_STATUS_SETTING_KEY};
8use crate::paths::{
9    collapse_home_path, get_backend_managed_remote_agent_key_path,
10    get_backend_managed_remote_agent_known_hosts_path,
11};
12use crate::settings_repository::SettingsRepository;
13use crate::types::{
14    RemoteAgentPreferredTool, RemoteAgentReviewFollowUpRuntimeConfig, RemoteAgentRuntimeConfig,
15};
16
17pub(crate) const REMOTE_AGENT_SETTING_KEY: &str = "remote_agent_config";
18
19#[derive(Debug, Clone)]
20pub struct BackendConfigRepository {
21    settings: SettingsRepository,
22}
23
24impl BackendConfigRepository {
25    pub fn new(settings: Option<SettingsRepository>) -> Result<Self, TrackError> {
26        let settings = match settings {
27            Some(settings) => settings,
28            None => SettingsRepository::new(None)?,
29        };
30
31        Ok(Self { settings })
32    }
33
34    pub fn load_remote_agent_config(&self) -> Result<Option<RemoteAgentConfigFile>, TrackError> {
35        self.settings.load_json(REMOTE_AGENT_SETTING_KEY)
36    }
37
38    pub fn save_remote_agent_config(
39        &self,
40        config: Option<&RemoteAgentConfigFile>,
41    ) -> Result<(), TrackError> {
42        match config {
43            Some(config) => {
44                let canonical = canonicalize_remote_agent_config(config.clone())?;
45                self.settings
46                    .save_json(REMOTE_AGENT_SETTING_KEY, &canonical)
47            }
48            None => self.settings.delete(REMOTE_AGENT_SETTING_KEY),
49        }
50    }
51
52    pub fn replace_remote_agent_config(
53        &self,
54        config: RemoteAgentConfigFile,
55        ssh_private_key: &str,
56        known_hosts: Option<&str>,
57    ) -> Result<RemoteAgentConfigFile, TrackError> {
58        let canonical = canonicalize_remote_agent_config(config)?;
59        install_backend_remote_agent_secrets(ssh_private_key, known_hosts)?;
60        self.settings
61            .save_json(REMOTE_AGENT_SETTING_KEY, &canonical)?;
62        Ok(canonical)
63    }
64
65    pub fn save_remote_agent_settings(
66        &self,
67        preferred_tool: RemoteAgentPreferredTool,
68        shell_prelude: Option<String>,
69        review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
70    ) -> Result<RemoteAgentConfigFile, TrackError> {
71        let mut config = self.load_remote_agent_config()?.ok_or_else(|| {
72            TrackError::new(
73                ErrorCode::RemoteAgentNotConfigured,
74                "Remote dispatch is not configured yet. Import legacy data or register remote-agent settings first.",
75            )
76        })?;
77
78        config.preferred_tool = preferred_tool;
79        config.shell_prelude = shell_prelude
80            .map(|value| value.replace("\r\n", "\n").trim().to_owned())
81            .filter(|value| !value.is_empty());
82        config.review_follow_up = review_follow_up;
83        self.save_remote_agent_config(Some(&config))?;
84
85        Ok(config)
86    }
87
88    pub fn load_migration_status(&self) -> Result<MigrationStatus, TrackError> {
89        Ok(self
90            .settings
91            .load_json(MIGRATION_STATUS_SETTING_KEY)?
92            .unwrap_or_else(MigrationStatus::ready))
93    }
94
95    pub fn save_migration_status(&self, status: &MigrationStatus) -> Result<(), TrackError> {
96        self.settings
97            .save_json(MIGRATION_STATUS_SETTING_KEY, status)
98    }
99}
100
101#[derive(Debug, Clone)]
102pub struct RemoteAgentConfigService {
103    repository: BackendConfigRepository,
104}
105
106impl RemoteAgentConfigService {
107    pub fn new(repository: Option<BackendConfigRepository>) -> Result<Self, TrackError> {
108        let repository = match repository {
109            Some(repository) => repository,
110            None => BackendConfigRepository::new(None)?,
111        };
112
113        Ok(Self { repository })
114    }
115
116    pub fn load_remote_agent_config(&self) -> Result<Option<RemoteAgentConfigFile>, TrackError> {
117        self.repository.load_remote_agent_config()
118    }
119
120    pub fn save_remote_agent_config(
121        &self,
122        config: Option<&RemoteAgentConfigFile>,
123    ) -> Result<(), TrackError> {
124        self.repository.save_remote_agent_config(config)
125    }
126
127    pub fn replace_remote_agent_config(
128        &self,
129        config: RemoteAgentConfigFile,
130        ssh_private_key: &str,
131        known_hosts: Option<&str>,
132    ) -> Result<RemoteAgentConfigFile, TrackError> {
133        self.repository
134            .replace_remote_agent_config(config, ssh_private_key, known_hosts)
135    }
136
137    pub fn save_remote_agent_settings(
138        &self,
139        preferred_tool: RemoteAgentPreferredTool,
140        shell_prelude: Option<String>,
141        review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
142    ) -> Result<RemoteAgentConfigFile, TrackError> {
143        self.repository
144            .save_remote_agent_settings(preferred_tool, shell_prelude, review_follow_up)
145    }
146
147    pub fn load_remote_agent_runtime_config(
148        &self,
149    ) -> Result<Option<RemoteAgentRuntimeConfig>, TrackError> {
150        Ok(self
151            .load_remote_agent_config()?
152            .map(build_remote_agent_runtime_config)
153            .transpose()?)
154    }
155
156    pub fn load_migration_status(&self) -> Result<MigrationStatus, TrackError> {
157        self.repository.load_migration_status()
158    }
159
160    pub fn save_migration_status(&self, status: &MigrationStatus) -> Result<(), TrackError> {
161        self.repository.save_migration_status(status)
162    }
163}
164
165fn build_remote_agent_runtime_config(
166    config: RemoteAgentConfigFile,
167) -> Result<RemoteAgentRuntimeConfig, TrackError> {
168    Ok(RemoteAgentRuntimeConfig {
169        host: config.host,
170        user: config.user,
171        port: config.port,
172        workspace_root: config.workspace_root,
173        projects_registry_path: config.projects_registry_path,
174        preferred_tool: config.preferred_tool,
175        shell_prelude: config.shell_prelude,
176        review_follow_up: config.review_follow_up.and_then(|review_follow_up| {
177            review_follow_up
178                .main_user
179                .map(|main_user| RemoteAgentReviewFollowUpRuntimeConfig {
180                    enabled: review_follow_up.enabled,
181                    main_user,
182                    default_review_prompt: review_follow_up.default_review_prompt,
183                })
184        }),
185        managed_key_path: get_backend_managed_remote_agent_key_path()?,
186        managed_known_hosts_path: get_backend_managed_remote_agent_known_hosts_path()?,
187    })
188}
189
190fn install_backend_remote_agent_secrets(
191    ssh_private_key: &str,
192    known_hosts: Option<&str>,
193) -> Result<(), TrackError> {
194    let managed_key_path = get_backend_managed_remote_agent_key_path()?;
195    let known_hosts_path = get_backend_managed_remote_agent_known_hosts_path()?;
196    let Some(parent_directory) = managed_key_path.parent() else {
197        return Err(TrackError::new(
198            ErrorCode::InvalidRemoteAgentConfig,
199            "Could not determine the backend remote-agent secrets directory.",
200        ));
201    };
202
203    fs::create_dir_all(parent_directory).map_err(|error| {
204        TrackError::new(
205            ErrorCode::InvalidRemoteAgentConfig,
206            format!(
207                "Could not create the backend remote-agent secrets directory at {}: {error}",
208                collapse_home_path(parent_directory)
209            ),
210        )
211    })?;
212
213    let normalized_private_key = ssh_private_key.replace("\r\n", "\n");
214    if normalized_private_key.trim().is_empty() {
215        return Err(TrackError::new(
216            ErrorCode::InvalidRemoteAgentConfig,
217            "Remote agent setup requires a non-empty SSH private key.",
218        ));
219    }
220
221    fs::write(&managed_key_path, normalized_private_key).map_err(|error| {
222        TrackError::new(
223            ErrorCode::InvalidRemoteAgentConfig,
224            format!(
225                "Could not write the managed SSH private key at {}: {error}",
226                collapse_home_path(&managed_key_path)
227            ),
228        )
229    })?;
230
231    #[cfg(unix)]
232    {
233        use std::os::unix::fs::PermissionsExt;
234
235        fs::set_permissions(&managed_key_path, fs::Permissions::from_mode(0o600)).map_err(
236            |error| {
237                TrackError::new(
238                    ErrorCode::InvalidRemoteAgentConfig,
239                    format!(
240                        "Could not set permissions on the managed SSH private key at {}: {error}",
241                        collapse_home_path(&managed_key_path)
242                    ),
243                )
244            },
245        )?;
246    }
247
248    match known_hosts {
249        Some(known_hosts) => {
250            fs::write(&known_hosts_path, known_hosts.replace("\r\n", "\n")).map_err(|error| {
251                TrackError::new(
252                    ErrorCode::InvalidRemoteAgentConfig,
253                    format!(
254                        "Could not write the managed known_hosts file at {}: {error}",
255                        collapse_home_path(&known_hosts_path)
256                    ),
257                )
258            })?;
259        }
260        None if !known_hosts_path.exists() => {
261            fs::write(&known_hosts_path, "").map_err(|error| {
262                TrackError::new(
263                    ErrorCode::InvalidRemoteAgentConfig,
264                    format!(
265                        "Could not create the managed known_hosts file at {}: {error}",
266                        collapse_home_path(&known_hosts_path)
267                    ),
268                )
269            })?;
270        }
271        None => {}
272    }
273
274    Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279    use tempfile::TempDir;
280
281    use super::BackendConfigRepository;
282    use crate::database::DatabaseContext;
283    use crate::migration::{LegacyScanSummary, MigrationState, MigrationStatus};
284    use crate::settings_repository::SettingsRepository;
285
286    fn repository() -> (TempDir, BackendConfigRepository) {
287        let directory = TempDir::new().expect("tempdir should be created");
288        let database = DatabaseContext::new(Some(directory.path().join("track.sqlite")))
289            .expect("database should resolve");
290        let settings =
291            SettingsRepository::new(Some(database)).expect("settings repository should resolve");
292
293        (
294            directory,
295            BackendConfigRepository::new(Some(settings))
296                .expect("backend config repository should resolve"),
297        )
298    }
299
300    fn status(state: MigrationState) -> MigrationStatus {
301        let requires_migration = matches!(state, MigrationState::ImportRequired);
302        MigrationStatus {
303            state,
304            requires_migration,
305            can_import: requires_migration,
306            legacy_detected: true,
307            summary: LegacyScanSummary::default(),
308            skipped_records: Vec::new(),
309            cleanup_candidates: Vec::new(),
310        }
311    }
312
313    #[test]
314    fn saves_and_loads_imported_status() {
315        let (_directory, repository) = repository();
316        repository
317            .save_migration_status(&status(MigrationState::Imported))
318            .expect("migration status should save");
319
320        let loaded = repository
321            .load_migration_status()
322            .expect("migration status should load");
323
324        assert_eq!(loaded.state, MigrationState::Imported);
325        assert!(!loaded.requires_migration);
326        assert!(!loaded.can_import);
327    }
328}