regy 0.1.4

Private-by-default desktop agent for the Regy web interface
use crate::{
    config::{
        app::{ProviderId, StoredConfig, validate_frontend_origin},
        store::FileConfigStore,
    },
    domain::errors::AgentResult,
    providers::probe::ProviderProbeResult,
    setup::{
        probes::{PiProbe, PiProbeResult, ProvidersProbe, SystemProbe, VerifiedToolCommands},
        readiness::{CheckId, CheckResult, CheckStatus, ReadinessReport, provider_setup_guidance},
    },
};

pub(crate) const THIRD_PARTY_CODE_WARNING: &str = "Warning: selected provider extensions execute third-party code inside Pi; setup may install and switch to the reviewed managed Node/Pi toolchain first.";

pub(crate) trait SetupConfigStore: Send + Sync {
    fn load(&self) -> AgentResult<StoredConfig>;
    fn save(&self, config: &StoredConfig) -> AgentResult<()>;
}

impl SetupConfigStore for FileConfigStore {
    fn load(&self) -> AgentResult<StoredConfig> {
        FileConfigStore::load(self)
    }

    fn save(&self, config: &StoredConfig) -> AgentResult<()> {
        FileConfigStore::save(self, config)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SetupConsentKind {
    ManagedDependencies,
    ThirdPartyExtensions,
}

pub(crate) trait SetupConsent: Send + Sync {
    fn confirm(&self, kind: SetupConsentKind, warning: &'static str) -> AgentResult<bool>;
    fn notify(&self, kind: SetupConsentKind, warning: &'static str) -> AgentResult<()>;
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SetupOptions {
    pub(crate) interactive: bool,
    pub(crate) assume_yes: bool,
    pub(crate) selected_providers: Option<Vec<ProviderId>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DelegatedReadiness {
    pub(crate) pairing: CheckResult,
    pub(crate) connectivity: CheckResult,
}

impl DelegatedReadiness {
    pub(crate) fn foundation(legacy_ready: bool) -> Self {
        Self {
            pairing: CheckResult {
                id: CheckId::Pairing,
                status: if legacy_ready {
                    CheckStatus::Ready
                } else {
                    CheckStatus::ActionRequired
                },
                required: true,
                detail: None,
                action: (!legacy_ready).then(|| "complete browser pairing setup".to_owned()),
                actions: Vec::new(),
            },
            connectivity: CheckResult {
                id: CheckId::Tunnel,
                status: CheckStatus::Ready,
                required: false,
                detail: None,
                action: None,
                actions: Vec::new(),
            },
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SetupStageOutcome {
    pub(crate) status: CheckStatus,
    pub(crate) warnings: Vec<String>,
    pub(crate) action: Option<String>,
}

impl SetupStageOutcome {
    fn from_check(check: CheckResult) -> Self {
        Self {
            status: check.status,
            warnings: Vec::new(),
            action: check.action,
        }
    }
}

pub(crate) struct SetupFlow<'a> {
    store: &'a dyn SetupConfigStore,
    system: &'a dyn SystemProbe,
    pi: &'a dyn PiProbe,
    providers: &'a dyn ProvidersProbe,
    consent: &'a dyn SetupConsent,
    options: SetupOptions,
    delegated: DelegatedReadiness,
}

impl<'a> SetupFlow<'a> {
    #[allow(clippy::too_many_arguments)]
    pub(crate) fn new(
        store: &'a dyn SetupConfigStore,
        system: &'a dyn SystemProbe,
        pi: &'a dyn PiProbe,
        providers: &'a dyn ProvidersProbe,
        consent: &'a dyn SetupConsent,
        options: SetupOptions,
        delegated: DelegatedReadiness,
    ) -> Self {
        Self {
            store,
            system,
            pi,
            providers,
            consent,
            options,
            delegated,
        }
    }

    pub(crate) async fn check(&self) -> AgentResult<ReadinessReport> {
        let mut config = self.store.load()?;
        let system = self.system.check().await?;
        if system.check.status != CheckStatus::Ready {
            return Ok(ReadinessReport::from_checks(vec![
                system.check,
                prerequisite_check(CheckId::Pi, "Complete System setup before configuring Pi."),
                self.delegated.pairing.clone(),
                self.connectivity_check(&config),
            ]));
        }
        let pi = self.pi.check(&config.tools).await?;
        self.persist_verified_commands(&mut config, &pi)?;
        Ok(ReadinessReport::from_checks(vec![
            system.check,
            pi.check,
            self.delegated.pairing.clone(),
            self.connectivity_check(&config),
        ]))
    }

    pub(crate) async fn run_pi_stage(&self) -> AgentResult<SetupStageOutcome> {
        let mut config = self.store.load()?;
        let system = self.system.check().await?;
        if system.check.status == CheckStatus::ActionRequired {
            return Ok(SetupStageOutcome::from_check(system.check));
        }

        let current = self.pi.check(&config.tools).await?;
        if current.check.status == CheckStatus::Ready {
            self.persist_verified_commands(&mut config, &current)?;
            return Ok(SetupStageOutcome::from_check(current.check));
        }
        if !system.managed_install_supported {
            return Ok(SetupStageOutcome::from_check(current.check));
        }

        let allowed = if self.options.assume_yes {
            self.consent.notify(
                SetupConsentKind::ManagedDependencies,
                "Install reviewed pinned Node and Pi dependencies?",
            )?;
            true
        } else if self.options.interactive {
            self.consent.confirm(
                SetupConsentKind::ManagedDependencies,
                "Install reviewed pinned Node and Pi dependencies?",
            )?
        } else {
            false
        };
        if !allowed {
            return Ok(SetupStageOutcome::from_check(current.check));
        }

        let installed = self.pi.install().await?;
        self.persist_verified_commands(&mut config, &installed)?;
        Ok(SetupStageOutcome::from_check(installed.check))
    }

    #[cfg(test)]
    pub(crate) async fn run_models_stage(&self) -> AgentResult<SetupStageOutcome> {
        let config = self.store.load()?;
        let selected = self
            .options
            .selected_providers
            .clone()
            .unwrap_or_else(|| config.selected_providers.clone());
        self.run_models_stage_with(selected).await
    }

    pub(crate) fn run_connectivity_stage(&self) -> AgentResult<SetupStageOutcome> {
        let config = self.store.load()?;
        let check = self.connectivity_check(&config);
        Ok(SetupStageOutcome {
            status: check.status,
            warnings: Vec::new(),
            action: check.action,
        })
    }

    fn connectivity_check(&self, config: &StoredConfig) -> CheckResult {
        if config
            .frontend_origin
            .as_deref()
            .is_none_or(|origin| validate_frontend_origin(origin).is_err())
        {
            return CheckResult {
                id: CheckId::Tunnel,
                status: CheckStatus::ActionRequired,
                required: true,
                detail: None,
                action: Some("frontend_origin must be an exact HTTPS origin.".to_owned()),
                actions: Vec::new(),
            };
        }
        CheckResult {
            id: CheckId::Tunnel,
            status: CheckStatus::Ready,
            required: true,
            detail: None,
            action: None,
            actions: Vec::new(),
        }
    }

    pub(crate) async fn run_models_stage_with(
        &self,
        selected: Vec<ProviderId>,
    ) -> AgentResult<SetupStageOutcome> {
        let mut config = self.store.load()?;
        if selected.is_empty() {
            return Ok(action_required("Select at least one model provider."));
        }

        let system = self.system.check().await?;
        if system.check.status != CheckStatus::Ready {
            return Ok(action_required(
                "Complete System setup before configuring model providers.",
            ));
        }
        let pi = self.pi.check(&config.tools).await?;
        if pi.check.status != CheckStatus::Ready {
            return Ok(action_required(
                "Complete Pi setup before configuring model providers.",
            ));
        }

        let mut results = self.providers.check(&selected).await?;
        let extensions = results
            .iter()
            .filter(|result| {
                !result.extension_ready
                    && matches!(result.id, ProviderId::ClaudeCode | ProviderId::Antigravity)
            })
            .map(|result| result.id)
            .collect::<Vec<_>>();
        if !extensions.is_empty() {
            let allowed = if self.options.assume_yes {
                self.consent.notify(
                    SetupConsentKind::ThirdPartyExtensions,
                    THIRD_PARTY_CODE_WARNING,
                )?;
                true
            } else if self.options.interactive {
                self.consent.confirm(
                    SetupConsentKind::ThirdPartyExtensions,
                    THIRD_PARTY_CODE_WARNING,
                )?
            } else {
                false
            };
            if allowed {
                self.providers.install_extensions(&extensions).await?;
                results = self.providers.check(&selected).await?;
            }
        }

        if self.options.interactive {
            for result in results.iter_mut().filter(|result| {
                result.executable_ready && result.extension_ready && !result.authenticated
            }) {
                *result = self.providers.login(result.id).await?;
            }
        }

        let ready = results.iter().any(ProviderProbeResult::ready);
        let warnings = provider_warnings(&results);
        if ready {
            if config.selected_providers != selected {
                config.selected_providers = selected;
                self.store.save(&config)?;
            }
            return Ok(SetupStageOutcome {
                status: CheckStatus::Ready,
                warnings,
                action: None,
            });
        }

        Ok(SetupStageOutcome {
            status: CheckStatus::ActionRequired,
            warnings,
            action: results.first().map(non_interactive_provider_action),
        })
    }

    fn persist_verified_commands(
        &self,
        config: &mut StoredConfig,
        result: &PiProbeResult,
    ) -> AgentResult<()> {
        let Some(commands) = &result.commands else {
            return Ok(());
        };
        let changed = apply_commands(config, commands);
        if changed {
            self.store.save(config)?;
        }
        Ok(())
    }
}

fn apply_commands(config: &mut StoredConfig, commands: &VerifiedToolCommands) -> bool {
    let changed = config.tools.node_command.as_ref() != Some(&commands.node)
        || config.tools.pi_command.as_ref() != Some(&commands.pi)
        || config.tools.claude_command != commands.claude;
    if changed {
        config.tools.node_command = Some(commands.node.clone());
        config.tools.pi_command = Some(commands.pi.clone());
        config.tools.claude_command = commands.claude.clone();
    }
    changed
}

fn provider_warnings(results: &[ProviderProbeResult]) -> Vec<String> {
    results
        .iter()
        .filter(|result| !result.ready())
        .map(|result| {
            let guidance = provider_setup_guidance(result);
            format!("{}: {}", guidance.display_name, guidance.summary)
        })
        .collect()
}

fn non_interactive_provider_action(result: &ProviderProbeResult) -> String {
    provider_setup_guidance(result).summary
}

fn action_required(action: impl Into<String>) -> SetupStageOutcome {
    SetupStageOutcome {
        status: CheckStatus::ActionRequired,
        warnings: Vec::new(),
        action: Some(action.into()),
    }
}

fn prerequisite_check(id: CheckId, action: &'static str) -> CheckResult {
    CheckResult {
        id,
        status: CheckStatus::ActionRequired,
        required: true,
        detail: None,
        action: Some(action.to_owned()),
        actions: Vec::new(),
    }
}