fix(backend): exclusion mutuelle des backends OpenCode (llamacpp vs cloud) (#97)

Les builders `with_opencode` / `with_opencode_provider` s'évacuent
réciproquement (dernier appel gagnant) pour honorer l'invariant
`opencode_backend_is_consistent`. Le use case SaveOpenCodeProviderProfile
route désormais via le builder au lieu de muter le champ directement — c'est
ce qui dupliquait `opencode` + `opencodeProvider` et forçait un repli sur
llamacpp même quand l'utilisateur choisissait un provider cloud.

Défense en profondeur côté store :
- `read_doc` répare les profils corrompus sur disque (drop du stale `opencode`)
- `save` refuse de persister un profil violant l'invariant via le nouveau
  variant `StoreError::Invalid` (mappé vers `AppError::Invalid`)

Tests verts : builders last-wins (domain), save rejets + read repair
(infra, profile_store 10/10).

Refs #97
This commit is contained in:
2026-07-24 19:52:34 +02:00
parent 7fee56acf5
commit 0f0a76d806
6 changed files with 173 additions and 10 deletions

View File

@ -6,8 +6,8 @@ use std::path::PathBuf;
use std::sync::Arc;
use domain::ids::ProfileId;
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
use domain::profile::{AgentProfile, ContextInjection};
use domain::ports::{FileSystem, ProfileStore, RemotePath, SecretRef, StoreError};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter};
use infrastructure::{FsProfileStore, LocalFileSystem};
use uuid::Uuid;
@ -168,3 +168,88 @@ async fn profiles_file_is_camelcase_versioned() {
assert_eq!(entry["contextInjection"]["strategy"], "conventionFile");
assert_eq!(entry["contextInjection"]["target"], "CLAUDE.md");
}
/// Fixture pair for ticket #97: the local llama.cpp backend (`opencode`) and the
/// cloud provider (`opencodeProvider`) — the two mutually exclusive OpenCode
/// backends. A profile carrying both is inconsistent.
fn opencode_backends() -> (OpenCodeConfig, OpenCodeProviderConfig) {
let local =
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder-30b", None, None).unwrap();
let cloud =
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-cloud"))
.unwrap();
(local, cloud)
}
/// Ticket #97 — read repair (`read_doc`, profile.rs:90-105): a profile corrupted
/// on disk (both `opencode` and `opencodeProvider` present) is silently repaired
/// on read by dropping the stale `opencode`, converging with the enforced
/// builder invariant. The cloud provider — the user's actual intent — is kept.
#[tokio::test]
async fn read_doc_repairs_stale_opencode_when_both_sections_present() {
let tmp = TempDir::new();
let (local, cloud) = opencode_backends();
// The `save` guard would refuse this, so plant the corruption directly on
// disk (bypassing the guard), exactly like a stale file from an older build.
let mut corrupted = sample(1, "OpenCode Cloud", "opencode")
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(local);
corrupted.opencode_provider = Some(cloud.clone());
assert!(
!corrupted.opencode_backend_is_consistent(),
"fixture must violate the invariant"
);
let fs = LocalFileSystem::new();
let doc = serde_json::json!({ "version": 1, "profiles": [corrupted] });
fs.write(&tmp.child("profiles.json"), &serde_json::to_vec_pretty(&doc).unwrap())
.await
.unwrap();
let store = store(&tmp);
let listed = store.list().await.expect("read repairs instead of failing");
assert_eq!(listed.len(), 1, "corrupted profile preserved as an entry");
let repaired = &listed[0];
assert!(repaired.opencode.is_none(), "stale local `opencode` dropped on read");
assert_eq!(
repaired.opencode_provider.as_ref().unwrap().provider_id,
"anthropic",
"cloud `opencodeProvider` preserved"
);
assert!(
repaired.opencode_backend_is_consistent(),
"read repair restored the mutual-exclusion invariant"
);
}
/// Ticket #97 — write guard (`save`, profile.rs:129-139): persisting a profile
/// that violates the OpenCode backend mutual-exclusion invariant is rejected
/// with [`StoreError::Invalid`], and nothing is written to disk.
#[tokio::test]
async fn save_rejects_profile_violating_backend_exclusion() {
let tmp = TempDir::new();
let store = store(&tmp);
let (local, cloud) = opencode_backends();
// Only reachable via direct mutation: the builders enforce exclusion
// (last-wins), so an inconsistent profile cannot be built through the API.
let mut inconsistent = sample(1, "OpenCode Both", "opencode")
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(local);
inconsistent.opencode_provider = Some(cloud);
assert!(!inconsistent.opencode_backend_is_consistent());
let err = store
.save(&inconsistent)
.await
.expect_err("save must reject an invariant-violating profile");
assert!(
matches!(err, StoreError::Invalid(_)),
"defense-in-depth guard maps to Invalid; got {err:?}"
);
assert!(
!store.is_configured().await.unwrap(),
"rejected save must not materialise profiles.json"
);
}