- Backend : clone_profile_from_seed généralisé (non OpenCode) - Backend : catalogue static Claude/Codex (3 modèles chacun, 1 recommandé) - Backend : commandes Tauri list_claude_models/list_codex_models - Frontend : ProfilesSettings refonte en onglets Codex/Claude + create/duplicate/edit/delete - Frontend : ModelSelect searchable partagé + fallback saisie manuelle - Frontend : assignation agent nom · modèle - Tests QA : 4 profils modèles distincts (2 Claude, 2 Codex) assignés à agents
664 lines
24 KiB
Rust
664 lines
24 KiB
Rust
//! Profile use cases (ARCHITECTURE §6, L5). Each is a single-responsibility
|
|
//! struct carrying its ports as `Arc<dyn Port>` 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<AgentProfile>,
|
|
}
|
|
|
|
/// 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<ProfileAvailability>,
|
|
}
|
|
|
|
/// Probes candidate profiles' detection commands and reports availability.
|
|
pub struct DetectProfiles {
|
|
runtime: Arc<dyn AgentRuntime>,
|
|
}
|
|
|
|
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<dyn AgentRuntime>) -> 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<DetectProfilesOutput, AppError> {
|
|
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<AgentProfile>,
|
|
}
|
|
|
|
/// Lists the configured profiles from the store.
|
|
pub struct ListProfiles {
|
|
store: Arc<dyn ProfileStore>,
|
|
}
|
|
|
|
impl ListProfiles {
|
|
/// Builds the use case from the [`ProfileStore`] port.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
|
|
/// Lists configured profiles.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self) -> Result<ListProfilesOutput, AppError> {
|
|
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<String>,
|
|
/// Optional model override. When absent, the seed model is copied as-is.
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
/// 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<dyn ProfileStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
}
|
|
|
|
impl CloneProfileFromSeed {
|
|
/// Builds the use case from the profile store and id generator ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> 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<CloneProfileFromSeedOutput, AppError> {
|
|
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<String>,
|
|
/// Optional OpenCode config override. When absent, the seed config is copied.
|
|
pub opencode: Option<OpenCodeConfig>,
|
|
}
|
|
|
|
/// 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<dyn ProfileStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
}
|
|
|
|
impl CloneOpenCodeProfileFromSeed {
|
|
/// Builds the use case from the profile store and id generator ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> 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<CloneOpenCodeProfileFromSeedOutput, AppError> {
|
|
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<ProfileId, AppError> {
|
|
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<dyn ProfileStore>,
|
|
}
|
|
|
|
impl SaveProfile {
|
|
/// Builds the use case from the [`ProfileStore`] port.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
|
|
/// Saves the profile.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: SaveProfileInput) -> Result<SaveProfileOutput, AppError> {
|
|
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<CustomProviderConfig>,
|
|
}
|
|
|
|
/// 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<dyn ProfileStore>,
|
|
secret_store: Arc<dyn SecretStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
}
|
|
|
|
impl SaveOpenCodeProviderProfile {
|
|
/// Builds the use case from the profile store, secret store and id generator
|
|
/// ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
profile_store: Arc<dyn ProfileStore>,
|
|
secret_store: Arc<dyn SecretStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
) -> 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<SaveOpenCodeProviderProfileOutput, AppError> {
|
|
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<dyn ProfileStore>,
|
|
secret_store: Arc<dyn SecretStore>,
|
|
}
|
|
|
|
impl DeleteProfile {
|
|
/// Builds the use case from the [`ProfileStore`] and [`SecretStore`] ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProfileStore>, secret_store: Arc<dyn SecretStore>) -> 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<AgentProfile>,
|
|
}
|
|
|
|
/// Output of [`ConfigureProfiles::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ConfigureProfilesOutput {
|
|
/// The persisted profiles.
|
|
pub profiles: Vec<AgentProfile>,
|
|
}
|
|
|
|
/// 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<dyn ProfileStore>,
|
|
}
|
|
|
|
impl ConfigureProfiles {
|
|
/// Builds the use case from the [`ProfileStore`] port.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
|
|
/// Persists each chosen profile.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: ConfigureProfilesInput,
|
|
) -> Result<ConfigureProfilesOutput, AppError> {
|
|
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<AgentProfile>,
|
|
}
|
|
|
|
/// 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<ReferenceProfilesOutput, AppError> {
|
|
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<AgentProfile>,
|
|
}
|
|
|
|
/// 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<dyn ProfileStore>,
|
|
}
|
|
|
|
impl FirstRunState {
|
|
/// Builds the use case from the [`ProfileStore`] port.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
|
|
/// Computes the first-run state.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self) -> Result<FirstRunStateOutput, AppError> {
|
|
let configured = self.store.is_configured().await?;
|
|
Ok(FirstRunStateOutput {
|
|
is_first_run: !configured,
|
|
reference_profiles: selectable_reference_profiles(),
|
|
})
|
|
}
|
|
}
|