//! Profile use cases (ARCHITECTURE §6, L5). Each is a single-responsibility //! struct carrying its ports as `Arc` and exposing one `execute`. //! //! - [`DetectProfiles`] — probe a set of candidate profiles via [`AgentRuntime`] //! and report which CLIs are installed (first-run availability ✓/✗). //! - [`ListProfiles`] / [`SaveProfile`] / [`DeleteProfile`] — CRUD over the //! persisted profiles through the [`ProfileStore`]. //! - [`ConfigureProfiles`] — persist a batch of chosen/edited/custom profiles //! (closes the first-run wizard). //! - [`ReferenceProfiles`] — expose the pre-filled, editable catalogue. //! - [`FirstRunState`] — tell the UI whether the first-run wizard should show //! (no `profiles.json` yet) and hand it the reference catalogue. use std::sync::Arc; use domain::ids::ProfileId; use domain::ports::{AgentRuntime, IdGenerator, ProfileStore, SecretRef, SecretStore}; use domain::profile::{ AgentProfile, CustomProviderConfig, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter, }; use crate::error::AppError; use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles}; use super::provider_catalogue::catalogue_custom_provider; // --------------------------------------------------------------------------- // DetectProfiles // --------------------------------------------------------------------------- /// Input for [`DetectProfiles::execute`]: the candidate profiles to probe. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DetectProfilesInput { /// Profiles whose `detect` command should be run. pub candidates: Vec, } /// Availability of a single candidate after detection. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProfileAvailability { /// The probed profile. pub profile: AgentProfile, /// Whether its CLI was detected as installed (exit code 0). pub available: bool, } /// Output of [`DetectProfiles::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DetectProfilesOutput { /// One entry per candidate (same order), with its availability. pub results: Vec, } /// Probes candidate profiles' detection commands and reports availability. pub struct DetectProfiles { runtime: Arc, } impl DetectProfiles { /// Builds the use case from the [`AgentRuntime`] port. The runtime itself /// holds the [`domain::ports::ProcessSpawner`] used for detection. #[must_use] pub fn new(runtime: Arc) -> Self { Self { runtime } } /// Runs detection for each candidate. A detection *error* (e.g. the command /// could not even be launched) is reported as `available: false`, not a /// hard failure — the wizard just shows ✗ and the user can still keep the /// profile. /// /// # Errors /// Currently never returns `Err` (failures degrade to `available: false`); /// the `Result` keeps the signature uniform with the other use cases. pub async fn execute( &self, input: DetectProfilesInput, ) -> Result { let mut results = Vec::with_capacity(input.candidates.len()); let mut probes = Vec::with_capacity(input.candidates.len()); for profile in input.candidates { let runtime = Arc::clone(&self.runtime); probes.push(tokio::spawn(async move { let available = runtime.detect(&profile).await.unwrap_or(false); ProfileAvailability { profile, available } })); } for probe in probes { results.push(probe.await.map_err(|err| { AppError::Process(format!("profile detection task failed: {err}")) })?); } Ok(DetectProfilesOutput { results }) } } // --------------------------------------------------------------------------- // ListProfiles // --------------------------------------------------------------------------- /// Output of [`ListProfiles::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListProfilesOutput { /// All configured profiles. pub profiles: Vec, } /// Lists the configured profiles from the store. pub struct ListProfiles { store: Arc, } impl ListProfiles { /// Builds the use case from the [`ProfileStore`] port. #[must_use] pub fn new(store: Arc) -> Self { Self { store } } /// Lists configured profiles. /// /// # Errors /// [`AppError::Store`] on persistence failure. pub async fn execute(&self) -> Result { Ok(ListProfilesOutput { profiles: self.store.list().await?, }) } } // --------------------------------------------------------------------------- // SaveProfile // --------------------------------------------------------------------------- /// Input for [`SaveProfile::execute`]: the profile to upsert. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SaveProfileInput { /// The profile to create or replace (by id). pub profile: AgentProfile, } /// Output of [`SaveProfile::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SaveProfileOutput { /// The saved profile (echoed back). pub profile: AgentProfile, } // --------------------------------------------------------------------------- // CloneProfileFromSeed // --------------------------------------------------------------------------- /// Input for [`CloneProfileFromSeed::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CloneProfileFromSeedInput { /// Id of the persisted or reference profile to clone. pub seed_profile_id: ProfileId, /// Optional display name for the cloned profile. When absent, a copy label is /// derived from the seed name. pub name: Option, /// Optional model override. When absent, the seed model is copied as-is. pub model: Option, } /// Output of [`CloneProfileFromSeed::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CloneProfileFromSeedOutput { /// The newly persisted profile. pub profile: AgentProfile, } /// Creates a new profile instance from an existing persisted/reference seed. /// /// Persisted profiles are preferred over reference seeds so user edits to the /// seed are preserved. The clone always receives a fresh [`ProfileId`] from the /// backend [`IdGenerator`]; callers can override the display name and model /// without minting ids client-side. pub struct CloneProfileFromSeed { store: Arc, ids: Arc, } impl CloneProfileFromSeed { /// Builds the use case from the profile store and id generator ports. #[must_use] pub fn new(store: Arc, ids: Arc) -> Self { Self { store, ids } } /// Clones the requested seed into a new persisted profile. /// /// # Errors /// [`AppError::NotFound`] if no persisted/reference profile has the seed id, /// [`AppError::Invalid`] if `name` or `model` is blank, [`AppError::Store`] /// on persistence failure. pub async fn execute( &self, input: CloneProfileFromSeedInput, ) -> Result { let existing = self.store.list().await?; let seed = existing .iter() .find(|profile| profile.id == input.seed_profile_id) .cloned() .or_else(|| { reference_profiles() .into_iter() .find(|profile| profile.id == input.seed_profile_id) }) .ok_or(AppError::NotFound("profile seed not found".into()))?; let mut profile = seed; profile.id = fresh_profile_id(&*self.ids, &existing)?; profile.name = match input.name { Some(name) => { if name.trim().is_empty() { return Err(AppError::Invalid("profile.name must not be empty".into())); } name } None => format!("{} copy", profile.name), }; if let Some(model) = input.model { if model.trim().is_empty() { return Err(AppError::Invalid("profile.model must not be empty".into())); } profile.model = Some(model); } self.store.save(&profile).await?; Ok(CloneProfileFromSeedOutput { profile }) } } // --------------------------------------------------------------------------- // CloneOpenCodeProfileFromSeed // --------------------------------------------------------------------------- /// Input for [`CloneOpenCodeProfileFromSeed::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CloneOpenCodeProfileFromSeedInput { /// Optional display name for the cloned profile. When absent, a copy label is /// derived from the seed name. pub name: Option, /// Optional OpenCode config override. When absent, the seed config is copied. pub opencode: Option, } /// Output of [`CloneOpenCodeProfileFromSeed::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CloneOpenCodeProfileFromSeedOutput { /// The newly persisted profile. pub profile: AgentProfile, } /// Creates a new OpenCode profile instance from the canonical seed/template. /// /// The persisted canonical `opencode-llamacpp` profile is preferred when present, /// so local edits are preserved as the clone template. If it is absent, the /// in-memory reference catalogue seed is used. The new profile always receives a /// fresh [`ProfileId`], which is the only identity constraint; multiple profiles /// with `StructuredAdapter::OpenCode` are therefore valid. pub struct CloneOpenCodeProfileFromSeed { store: Arc, ids: Arc, } impl CloneOpenCodeProfileFromSeed { /// Builds the use case from the profile store and id generator ports. #[must_use] pub fn new(store: Arc, ids: Arc) -> Self { Self { store, ids } } /// Clones the canonical OpenCode seed into a new persisted profile. /// /// # Errors /// [`AppError::Store`] on persistence failure, [`AppError::Invalid`] if the /// requested name is blank, or [`AppError::Internal`] if the seed is malformed. pub async fn execute( &self, input: CloneOpenCodeProfileFromSeedInput, ) -> Result { let existing = self.store.list().await?; let seed_id = reference_profile_id("opencode-llamacpp"); // A valid OpenCode seed carries the OpenCode structured adapter and at // least one backend: local (`opencode`, llamacpp) OR cloud // (`opencode_provider`, ticket #92). The `opencode_backend_is_consistent` // invariant (#97) guarantees at most one of the two is set; a profile // carrying neither is not a usable seed. // // A persisted profile with the canonical seed id is preferred when it is // still a valid OpenCode seed, so local edits — including a cloud // conversion that reused the deterministic seed id — are preserved as the // clone template. When the persisted slot is NOT a valid OpenCode seed // (an unrelated profile squatting the id, or an OpenCode profile missing // both backends), fall back to the in-memory reference catalogue seed, // which is always the valid local llamacpp — instead of erroring on an // unexpected store state. let seed = existing .iter() .find(|profile| { profile.id == seed_id && profile.structured_adapter == Some(StructuredAdapter::OpenCode) && (profile.opencode.is_some() || profile.opencode_provider.is_some()) }) .cloned() .or_else(|| { reference_profiles() .into_iter() .find(|profile| profile.id == seed_id) }) .ok_or_else(|| { AppError::Internal("canonical OpenCode seed `opencode-llamacpp` is missing".into()) })?; let mut profile = seed; profile.id = fresh_profile_id(&*self.ids, &existing)?; profile.name = match input.name { Some(name) => { if name.trim().is_empty() { return Err(AppError::Invalid("profile.name must not be empty".into())); } name } None => format!("{} copy", profile.name), }; if let Some(config) = input.opencode { // Honour the `opencode_backend_is_consistent` invariant (#97): // switching the clone to a local llamacpp backend evicts any cloud // backend carried by the seed (mirrors `AgentProfile::with_opencode`). profile.opencode = Some(config); profile.opencode_provider = None; } self.store.save(&profile).await?; Ok(CloneOpenCodeProfileFromSeedOutput { profile }) } } fn fresh_profile_id( ids: &dyn IdGenerator, existing: &[AgentProfile], ) -> Result { for _ in 0..16 { let id = ProfileId::from_uuid(ids.new_uuid()); if existing.iter().all(|profile| profile.id != id) { return Ok(id); } } Err(AppError::Internal( "could not allocate a unique profile id".into(), )) } /// Persists (creates or replaces) a single profile. pub struct SaveProfile { store: Arc, } impl SaveProfile { /// Builds the use case from the [`ProfileStore`] port. #[must_use] pub fn new(store: Arc) -> Self { Self { store } } /// Saves the profile. /// /// # Errors /// [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: SaveProfileInput) -> Result { self.store.save(&input.profile).await?; Ok(SaveProfileOutput { profile: input.profile, }) } } // --------------------------------------------------------------------------- // SaveOpenCodeProviderProfile // --------------------------------------------------------------------------- /// Input for [`SaveOpenCodeProviderProfile::execute`]: the profile to upsert /// (with [`AgentProfile::opencode_provider`] left as-is — this use case fills it /// in) plus the **literal** provider fields. The literal `api_key` never reaches /// [`ProfileStore`] — only [`SaveOpenCodeProviderProfile`] is allowed to touch it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SaveOpenCodeProviderProfileInput { /// The profile to create or replace (by id). Its `opencode_provider` field is /// overwritten by this use case; any value set on it is ignored. pub profile: AgentProfile, /// Provider id in the OpenCode registry (e.g. `"anthropic"`). pub provider_id: String, /// Model name served by this provider. pub model: String, /// Literal API key. Minted into a fresh [`SecretRef`] on first save, or /// re-sealed under the profile's existing `SecretRef` on edit — never /// persisted as a literal in `profiles.json`. pub api_key: String, /// Optional custom-provider configuration (endpoint outside the OpenCode /// registry). `None` = known provider (unchanged behaviour). pub custom: Option, } /// Output of [`SaveOpenCodeProviderProfile::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SaveOpenCodeProviderProfileOutput { /// The saved profile (echoed back), with `opencode_provider` set. pub profile: AgentProfile, } /// Persists an OpenCode profile backed by a **cloud** provider (ticket #92, lot /// B3), keeping the literal API key out of `profiles.json`: it is sealed into the /// [`SecretStore`] under an opaque [`SecretRef`], and only the ref is persisted on /// [`domain::profile::OpenCodeProviderConfig`]. pub struct SaveOpenCodeProviderProfile { profile_store: Arc, secret_store: Arc, ids: Arc, } impl SaveOpenCodeProviderProfile { /// Builds the use case from the profile store, secret store and id generator /// ports. #[must_use] pub fn new( profile_store: Arc, secret_store: Arc, ids: Arc, ) -> Self { Self { profile_store, secret_store, ids, } } /// Seals `input.api_key` under a [`SecretRef`] (minted fresh, or reused from /// the profile's existing `opencode_provider` when editing) and persists the /// profile with `opencode_provider` set to the resulting /// [`domain::profile::OpenCodeProviderConfig`]. /// /// # Errors /// [`AppError::Invalid`] if `provider_id`/`model` is empty, [`AppError::Store`] /// on secret or profile persistence failure. pub async fn execute( &self, input: SaveOpenCodeProviderProfileInput, ) -> Result { let custom = match input.custom { Some(custom) => Some(custom), None => catalogue_custom_provider(&input.provider_id, &input.model) .map_err(AppError::Invalid)?, }; let secret_ref = input .profile .opencode_provider .as_ref() .map(|config| config.api_key_ref.clone()) .unwrap_or_else(|| SecretRef::new(self.ids.new_uuid().to_string())); self.secret_store.put(&secret_ref, &input.api_key).await?; let mut provider = OpenCodeProviderConfig::new(input.provider_id, input.model, secret_ref) .map_err(|e| AppError::Invalid(e.to_string()))?; if let Some(custom) = custom { provider = provider.with_custom(custom); } // Route through the builder (ticket #97): `with_opencode_provider` clears // any stale `opencode` (llamacpp) carried by the input profile, so the // persisted profile honours the mutual-exclusion invariant instead of // keeping both backends — which previously forced a wrong llamacpp // fallback even when the user chose a cloud provider. let profile = input.profile.with_opencode_provider(provider); self.profile_store.save(&profile).await?; Ok(SaveOpenCodeProviderProfileOutput { profile }) } } // --------------------------------------------------------------------------- // DeleteProfile // --------------------------------------------------------------------------- /// Input for [`DeleteProfile::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeleteProfileInput { /// Id of the profile to delete. pub id: domain::ids::ProfileId, } /// Deletes a profile by id. If the profile carries an /// [`domain::profile::OpenCodeProviderConfig`], its secret is removed from the /// [`SecretStore`] first, so no orphaned secret is left behind (ticket #92, lot /// B3). pub struct DeleteProfile { store: Arc, secret_store: Arc, } impl DeleteProfile { /// Builds the use case from the [`ProfileStore`] and [`SecretStore`] ports. #[must_use] pub fn new(store: Arc, secret_store: Arc) -> Self { Self { store, secret_store, } } /// Deletes the profile (and its secret, if any). /// /// # Errors /// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on /// persistence failure. pub async fn execute(&self, input: DeleteProfileInput) -> Result<(), AppError> { let profiles = self.store.list().await?; if let Some(profile) = profiles.into_iter().find(|p| p.id == input.id) { if let Some(config) = &profile.opencode_provider { self.secret_store.delete(&config.api_key_ref).await?; } } self.store.delete(input.id).await?; Ok(()) } } // --------------------------------------------------------------------------- // ConfigureProfiles // --------------------------------------------------------------------------- /// Input for [`ConfigureProfiles::execute`]: the chosen/edited/custom profiles. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigureProfilesInput { /// All profiles the user decided to keep (closes the first run). pub profiles: Vec, } /// Output of [`ConfigureProfiles::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigureProfilesOutput { /// The persisted profiles. pub profiles: Vec, } /// Persists the batch of profiles chosen at the end of the first-run wizard. /// /// Saving even an empty list creates `profiles.json`, which marks the first run /// as done (so the wizard does not reappear). pub struct ConfigureProfiles { store: Arc, } impl ConfigureProfiles { /// Builds the use case from the [`ProfileStore`] port. #[must_use] pub fn new(store: Arc) -> Self { Self { store } } /// Persists each chosen profile. /// /// # Errors /// [`AppError::Store`] on persistence failure. pub async fn execute( &self, input: ConfigureProfilesInput, ) -> Result { for profile in &input.profiles { self.store.save(profile).await?; } // Ensure `profiles.json` exists even when the user kept nothing, so the // first run is recorded as complete. if input.profiles.is_empty() { self.store.mark_configured().await?; } Ok(ConfigureProfilesOutput { profiles: input.profiles, }) } } // --------------------------------------------------------------------------- // ReferenceProfiles (catalogue accessor) // --------------------------------------------------------------------------- /// Output of [`ReferenceProfiles::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReferenceProfilesOutput { /// The pre-filled, editable reference catalogue, **restricted to the /// selectable profiles** (§17.3, D7): only profiles drivable in structured /// mode are offered to selection/creation. Today: Claude + Codex. pub profiles: Vec, } /// Exposes the **selectable** reference catalogue for the agent-creation menu /// (§17.3, D7): the structured-drivable profiles only (Claude/Codex). Gemini and /// Aider remain in the raw catalogue data but are not proposed here. #[derive(Default)] pub struct ReferenceProfiles; impl ReferenceProfiles { /// Builds the (stateless) use case. #[must_use] pub fn new() -> Self { Self } /// Returns the reference catalogue. Infallible. /// /// # Errors /// Never; the `Result` keeps the call site uniform. #[allow(clippy::unused_async)] pub async fn execute(&self) -> Result { Ok(ReferenceProfilesOutput { profiles: selectable_reference_profiles(), }) } } // --------------------------------------------------------------------------- // FirstRunState // --------------------------------------------------------------------------- /// Output of [`FirstRunState::execute`]: whether to show the wizard + catalogue. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FirstRunStateOutput { /// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard. pub is_first_run: bool, /// The pre-filled reference catalogue to seed the wizard, **restricted to the /// selectable profiles** (§17.3, D7): only structured-drivable profiles /// (Claude/Codex) are offered. No custom-profile entry. pub reference_profiles: Vec, } /// Reports whether the IDE is on its first run (no profiles configured yet) and /// provides the reference catalogue to seed the wizard. pub struct FirstRunState { store: Arc, } impl FirstRunState { /// Builds the use case from the [`ProfileStore`] port. #[must_use] pub fn new(store: Arc) -> Self { Self { store } } /// Computes the first-run state. /// /// # Errors /// [`AppError::Store`] on persistence failure. pub async fn execute(&self) -> Result { let configured = self.store.is_configured().await?; Ok(FirstRunStateOutput { is_first_run: !configured, reference_profiles: selectable_reference_profiles(), }) } }