track-core 0.1.0

Shared backend primitives and repositories for the track issue tracker.
Documentation
use std::fs;

use crate::config::{
    canonicalize_remote_agent_config, RemoteAgentConfigFile, RemoteAgentReviewFollowUpConfigFile,
};
use crate::errors::{ErrorCode, TrackError};
use crate::migration::{MigrationStatus, MIGRATION_STATUS_SETTING_KEY};
use crate::paths::{
    collapse_home_path, get_backend_managed_remote_agent_key_path,
    get_backend_managed_remote_agent_known_hosts_path,
};
use crate::settings_repository::SettingsRepository;
use crate::types::{
    RemoteAgentPreferredTool, RemoteAgentReviewFollowUpRuntimeConfig, RemoteAgentRuntimeConfig,
};

pub(crate) const REMOTE_AGENT_SETTING_KEY: &str = "remote_agent_config";

#[derive(Debug, Clone)]
pub struct BackendConfigRepository {
    settings: SettingsRepository,
}

impl BackendConfigRepository {
    pub fn new(settings: Option<SettingsRepository>) -> Result<Self, TrackError> {
        let settings = match settings {
            Some(settings) => settings,
            None => SettingsRepository::new(None)?,
        };

        Ok(Self { settings })
    }

    pub fn load_remote_agent_config(&self) -> Result<Option<RemoteAgentConfigFile>, TrackError> {
        self.settings.load_json(REMOTE_AGENT_SETTING_KEY)
    }

    pub fn save_remote_agent_config(
        &self,
        config: Option<&RemoteAgentConfigFile>,
    ) -> Result<(), TrackError> {
        match config {
            Some(config) => {
                let canonical = canonicalize_remote_agent_config(config.clone())?;
                self.settings
                    .save_json(REMOTE_AGENT_SETTING_KEY, &canonical)
            }
            None => self.settings.delete(REMOTE_AGENT_SETTING_KEY),
        }
    }

    pub fn replace_remote_agent_config(
        &self,
        config: RemoteAgentConfigFile,
        ssh_private_key: &str,
        known_hosts: Option<&str>,
    ) -> Result<RemoteAgentConfigFile, TrackError> {
        let canonical = canonicalize_remote_agent_config(config)?;
        install_backend_remote_agent_secrets(ssh_private_key, known_hosts)?;
        self.settings
            .save_json(REMOTE_AGENT_SETTING_KEY, &canonical)?;
        Ok(canonical)
    }

    pub fn save_remote_agent_settings(
        &self,
        preferred_tool: RemoteAgentPreferredTool,
        shell_prelude: Option<String>,
        review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
    ) -> Result<RemoteAgentConfigFile, TrackError> {
        let mut config = self.load_remote_agent_config()?.ok_or_else(|| {
            TrackError::new(
                ErrorCode::RemoteAgentNotConfigured,
                "Remote dispatch is not configured yet. Import legacy data or register remote-agent settings first.",
            )
        })?;

        config.preferred_tool = preferred_tool;
        config.shell_prelude = shell_prelude
            .map(|value| value.replace("\r\n", "\n").trim().to_owned())
            .filter(|value| !value.is_empty());
        config.review_follow_up = review_follow_up;
        self.save_remote_agent_config(Some(&config))?;

        Ok(config)
    }

    pub fn load_migration_status(&self) -> Result<MigrationStatus, TrackError> {
        Ok(self
            .settings
            .load_json(MIGRATION_STATUS_SETTING_KEY)?
            .unwrap_or_else(MigrationStatus::ready))
    }

    pub fn save_migration_status(&self, status: &MigrationStatus) -> Result<(), TrackError> {
        self.settings
            .save_json(MIGRATION_STATUS_SETTING_KEY, status)
    }
}

#[derive(Debug, Clone)]
pub struct RemoteAgentConfigService {
    repository: BackendConfigRepository,
}

impl RemoteAgentConfigService {
    pub fn new(repository: Option<BackendConfigRepository>) -> Result<Self, TrackError> {
        let repository = match repository {
            Some(repository) => repository,
            None => BackendConfigRepository::new(None)?,
        };

        Ok(Self { repository })
    }

    pub fn load_remote_agent_config(&self) -> Result<Option<RemoteAgentConfigFile>, TrackError> {
        self.repository.load_remote_agent_config()
    }

    pub fn save_remote_agent_config(
        &self,
        config: Option<&RemoteAgentConfigFile>,
    ) -> Result<(), TrackError> {
        self.repository.save_remote_agent_config(config)
    }

    pub fn replace_remote_agent_config(
        &self,
        config: RemoteAgentConfigFile,
        ssh_private_key: &str,
        known_hosts: Option<&str>,
    ) -> Result<RemoteAgentConfigFile, TrackError> {
        self.repository
            .replace_remote_agent_config(config, ssh_private_key, known_hosts)
    }

    pub fn save_remote_agent_settings(
        &self,
        preferred_tool: RemoteAgentPreferredTool,
        shell_prelude: Option<String>,
        review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
    ) -> Result<RemoteAgentConfigFile, TrackError> {
        self.repository
            .save_remote_agent_settings(preferred_tool, shell_prelude, review_follow_up)
    }

    pub fn load_remote_agent_runtime_config(
        &self,
    ) -> Result<Option<RemoteAgentRuntimeConfig>, TrackError> {
        Ok(self
            .load_remote_agent_config()?
            .map(build_remote_agent_runtime_config)
            .transpose()?)
    }

    pub fn load_migration_status(&self) -> Result<MigrationStatus, TrackError> {
        self.repository.load_migration_status()
    }

    pub fn save_migration_status(&self, status: &MigrationStatus) -> Result<(), TrackError> {
        self.repository.save_migration_status(status)
    }
}

fn build_remote_agent_runtime_config(
    config: RemoteAgentConfigFile,
) -> Result<RemoteAgentRuntimeConfig, TrackError> {
    Ok(RemoteAgentRuntimeConfig {
        host: config.host,
        user: config.user,
        port: config.port,
        workspace_root: config.workspace_root,
        projects_registry_path: config.projects_registry_path,
        preferred_tool: config.preferred_tool,
        shell_prelude: config.shell_prelude,
        review_follow_up: config.review_follow_up.and_then(|review_follow_up| {
            review_follow_up
                .main_user
                .map(|main_user| RemoteAgentReviewFollowUpRuntimeConfig {
                    enabled: review_follow_up.enabled,
                    main_user,
                    default_review_prompt: review_follow_up.default_review_prompt,
                })
        }),
        managed_key_path: get_backend_managed_remote_agent_key_path()?,
        managed_known_hosts_path: get_backend_managed_remote_agent_known_hosts_path()?,
    })
}

fn install_backend_remote_agent_secrets(
    ssh_private_key: &str,
    known_hosts: Option<&str>,
) -> Result<(), TrackError> {
    let managed_key_path = get_backend_managed_remote_agent_key_path()?;
    let known_hosts_path = get_backend_managed_remote_agent_known_hosts_path()?;
    let Some(parent_directory) = managed_key_path.parent() else {
        return Err(TrackError::new(
            ErrorCode::InvalidRemoteAgentConfig,
            "Could not determine the backend remote-agent secrets directory.",
        ));
    };

    fs::create_dir_all(parent_directory).map_err(|error| {
        TrackError::new(
            ErrorCode::InvalidRemoteAgentConfig,
            format!(
                "Could not create the backend remote-agent secrets directory at {}: {error}",
                collapse_home_path(parent_directory)
            ),
        )
    })?;

    let normalized_private_key = ssh_private_key.replace("\r\n", "\n");
    if normalized_private_key.trim().is_empty() {
        return Err(TrackError::new(
            ErrorCode::InvalidRemoteAgentConfig,
            "Remote agent setup requires a non-empty SSH private key.",
        ));
    }

    fs::write(&managed_key_path, normalized_private_key).map_err(|error| {
        TrackError::new(
            ErrorCode::InvalidRemoteAgentConfig,
            format!(
                "Could not write the managed SSH private key at {}: {error}",
                collapse_home_path(&managed_key_path)
            ),
        )
    })?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        fs::set_permissions(&managed_key_path, fs::Permissions::from_mode(0o600)).map_err(
            |error| {
                TrackError::new(
                    ErrorCode::InvalidRemoteAgentConfig,
                    format!(
                        "Could not set permissions on the managed SSH private key at {}: {error}",
                        collapse_home_path(&managed_key_path)
                    ),
                )
            },
        )?;
    }

    match known_hosts {
        Some(known_hosts) => {
            fs::write(&known_hosts_path, known_hosts.replace("\r\n", "\n")).map_err(|error| {
                TrackError::new(
                    ErrorCode::InvalidRemoteAgentConfig,
                    format!(
                        "Could not write the managed known_hosts file at {}: {error}",
                        collapse_home_path(&known_hosts_path)
                    ),
                )
            })?;
        }
        None if !known_hosts_path.exists() => {
            fs::write(&known_hosts_path, "").map_err(|error| {
                TrackError::new(
                    ErrorCode::InvalidRemoteAgentConfig,
                    format!(
                        "Could not create the managed known_hosts file at {}: {error}",
                        collapse_home_path(&known_hosts_path)
                    ),
                )
            })?;
        }
        None => {}
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use super::BackendConfigRepository;
    use crate::database::DatabaseContext;
    use crate::migration::{LegacyScanSummary, MigrationState, MigrationStatus};
    use crate::settings_repository::SettingsRepository;

    fn repository() -> (TempDir, BackendConfigRepository) {
        let directory = TempDir::new().expect("tempdir should be created");
        let database = DatabaseContext::new(Some(directory.path().join("track.sqlite")))
            .expect("database should resolve");
        let settings =
            SettingsRepository::new(Some(database)).expect("settings repository should resolve");

        (
            directory,
            BackendConfigRepository::new(Some(settings))
                .expect("backend config repository should resolve"),
        )
    }

    fn status(state: MigrationState) -> MigrationStatus {
        let requires_migration = matches!(state, MigrationState::ImportRequired);
        MigrationStatus {
            state,
            requires_migration,
            can_import: requires_migration,
            legacy_detected: true,
            summary: LegacyScanSummary::default(),
            skipped_records: Vec::new(),
            cleanup_candidates: Vec::new(),
        }
    }

    #[test]
    fn saves_and_loads_imported_status() {
        let (_directory, repository) = repository();
        repository
            .save_migration_status(&status(MigrationState::Imported))
            .expect("migration status should save");

        let loaded = repository
            .load_migration_status()
            .expect("migration status should load");

        assert_eq!(loaded.state, MigrationState::Imported);
        assert!(!loaded.requires_migration);
        assert!(!loaded.can_import);
    }
}