//! AI runtime profile and its context-injection strategy. //! //! A profile is *declarative configuration* (see CONTEXT.md §9): adding an AI //! means adding data, not code (Open/Closed). The [`crate::ports::AgentRuntime`] //! port is parameterised by an [`AgentProfile`]. use serde::{Deserialize, Serialize}; use crate::error::DomainError; use crate::ids::ProfileId; use crate::permission::ProjectorKey; /// Strategy for injecting an agent's `.md` context into the launched CLI. /// /// Invariants: /// - `ConventionFile.target` is a relative file name without `..` and not absolute, /// - `Env.var` is a valid environment-variable identifier, /// - `Flag.flag` is non-empty. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "strategy")] pub enum ContextInjection { /// Write/symlink the `.md` to a conventional file (e.g. `CLAUDE.md`). ConventionFile { /// Relative target file name. target: String, }, /// Pass the context file path through a CLI flag. Flag { /// The flag template (e.g. `--context-file {path}` or `-f`). flag: String, }, /// Pipe the Markdown content on stdin. Stdin, /// Pass the context via an environment variable. Env { /// Environment variable name. var: String, }, } impl ContextInjection { /// Validated `ConventionFile` constructor. /// /// # Errors /// Returns [`DomainError::PathNotRelativeSafe`] if `target` is absolute or /// contains `..`. pub fn convention_file(target: impl Into) -> Result { let target = target.into(); crate::validation::relative_safe(&target)?; Ok(Self::ConventionFile { target }) } /// Validated `Flag` constructor. /// /// # Errors /// Returns [`DomainError::EmptyField`] if `flag` is empty. pub fn flag(flag: impl Into) -> Result { let flag = flag.into(); crate::validation::non_empty(&flag, "contextInjection.flag")?; Ok(Self::Flag { flag }) } /// `Stdin` constructor (no validation needed). #[must_use] pub const fn stdin() -> Self { Self::Stdin } /// Validated `Env` constructor. /// /// # Errors /// Returns [`DomainError::InvalidEnvVar`] if `var` is not a valid identifier. pub fn env(var: impl Into) -> Result { let var = var.into(); crate::validation::valid_env_var(&var)?; Ok(Self::Env { var }) } } /// Declares how IdeA assigns and resumes a CLI conversation, without parsing /// the model's output (universal, declarative — see CONTEXT.md §9). /// /// Optional on a profile: a profile without a `session` block behaves as today /// (no resume). /// /// Invariants: /// - `resume_flag` is non-empty, /// - if `assign_flag` is `Some`, it is non-empty. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionStrategy { /// Flag passed on the FIRST launch with a UUID generated by IdeA /// (e.g. `"--session-id"`). `None` => degraded mode (resume without id). #[serde(default, skip_serializing_if = "Option::is_none")] pub assign_flag: Option, /// Resume flag (e.g. `"--resume"` or `"--continue"`). pub resume_flag: String, } impl SessionStrategy { /// Builds a validated session strategy. /// /// # Errors /// Returns [`DomainError::EmptyField`] if `resume_flag` is empty, or if /// `assign_flag` is `Some` but empty. pub fn new( assign_flag: Option, resume_flag: impl Into, ) -> Result { let resume_flag = resume_flag.into(); crate::validation::non_empty(&resume_flag, "session.resumeFlag")?; if let Some(flag) = &assign_flag { crate::validation::non_empty(flag, "session.assignFlag")?; } Ok(Self { assign_flag, resume_flag, }) } } /// Réglages de **vivacité** (readiness/heartbeat) d'un profil IA — place ménagée /// pour le lot 2 (chantier readiness/heartbeat). Donnée **déclarative** (pas de code /// par CLI — Open/Closed), calquée sur [`SessionStrategy`] / [`McpCapability`]. /// /// Deux seuils optionnels : /// - `stall_after_ms` : délai sans **aucune** preuve de vivacité (delta / activité / /// `ReplyEvent::Heartbeat`) au bout duquel l'agent est présumé **bloqué** /// (`ReadinessSignal::Stalled`) — détection au lot 2 ; /// - `turn_timeout_ms` : durée maximale d'**un tour** avant le garde-fou /// (`ReadinessSignal::TimedOut`) — remplacement des timeouts en dur au lot 2. /// /// **Lot 1 : champs présents mais non consommés** — on ne fait que ménager la place /// (le modèle de sérialisation et l'API sont figés ici pour éviter une migration au /// lot 2). `None` (défaut, et valeur des profils existants) ⇒ comportement actuel. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LivenessStrategy { /// Délai (ms) sans preuve de vivacité avant de présumer l'agent bloqué. /// `None` ⇒ pas de détection de stagnation (lot 2). #[serde(default, skip_serializing_if = "Option::is_none")] pub stall_after_ms: Option, /// Durée maximale (ms) d'un tour avant le garde-fou de timeout. `None` ⇒ pas de /// garde-fou par profil (lot 2). #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_timeout_ms: Option, } impl LivenessStrategy { /// Construit une stratégie de vivacité validée (parse-don't-validate, comme /// [`SessionStrategy::new`]). /// /// # Errors /// Renvoie [`DomainError::EmptyField`] si un seuil fourni vaut `0` (un seuil de /// `0 ms` n'a pas de sens : `None` est la façon d'exprimer « pas de seuil »). pub const fn new( stall_after_ms: Option, turn_timeout_ms: Option, ) -> Result { if let Some(0) = stall_after_ms { return Err(DomainError::EmptyField { field: "liveness.stallAfterMs", }); } if let Some(0) = turn_timeout_ms { return Err(DomainError::EmptyField { field: "liveness.turnTimeoutMs", }); } Ok(Self { stall_after_ms, turn_timeout_ms, }) } } /// Motif déclaratif de détection d'une **limite de session/débit** pour un agent /// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection). /// /// Donnée **pure** (pas de code par CLI — Open/Closed, §9), un motif déclaratif /// littéral **plus riche** qu'une simple sous-chaîne : là où un sigil de prompt /// serait une simple sous-chaîne littérale, la limite de session a /// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke /// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en /// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul /// `Cargo.toml` d'`infrastructure`, cf. §21.2-T2). **Aucune** regex ni heure parsée /// ne franchit la frontière domaine : l'infra émet un /// [`crate::ports::ReplyEvent::RateLimited`] avec une époche-ms normalisée. /// /// Invariant (garanti par le constructeur) : `pattern` est non vide. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RateLimitPattern { /// Le **motif brut** (interprété comme une regex par l'infra) recherché dans la /// sortie PTY pour reconnaître l'épisode de limite. Le domaine ne le compile /// jamais — il le transporte tel quel jusqu'à l'adapter (§21.2-T2). pub pattern: String, /// Nom du **groupe de capture** (ou indication équivalente) d'où l'infra extrait /// l'heure de reset. `None` ⇒ le motif détecte la limite **sans** heure de reset /// ⇒ [`crate::ports::ReplyEvent::RateLimited`]`{ resets_at_ms: None }` (filet /// humain). Donnée opaque au domaine. #[serde(default, skip_serializing_if = "Option::is_none")] pub reset_capture: Option, /// **Format d'heure** (ex. style `strftime`) que l'infra utilise pour parser la /// chaîne capturée par `reset_capture` en une heure murale, qu'elle compose /// ensuite avec la date du jour + le fuseau via `Clock` pour obtenir une /// époche-ms (§21.10-2). `None` ⇒ l'infra applique sa stratégie de parsing par /// défaut. Donnée opaque au domaine. #[serde(default, skip_serializing_if = "Option::is_none")] pub time_format: Option, } impl RateLimitPattern { /// Construit un motif validé (parse-don't-validate, comme /// [`SessionStrategy::new`] / [`LivenessStrategy::new`]). /// /// # Errors /// Renvoie [`DomainError::EmptyField`] (`"rateLimitPattern.pattern"`) si /// `pattern` est vide. pub fn new( pattern: impl Into, reset_capture: Option, time_format: Option, ) -> Result { let pattern = pattern.into(); crate::validation::non_empty(&pattern, "rateLimitPattern.pattern")?; Ok(Self { pattern, reset_capture, time_format, }) } } /// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17). /// /// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel /// adapter le pilote en mode programmatique. Ajouter un moteur structuré = une /// variante ici + un adapter infra (`infrastructure/src/session/`), sans toucher /// au cœur. Un profil **sans** `structured_adapter` reste un profil **TUI/PTY** /// (terminal brut, comportement historique). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum StructuredAdapter { /// Piloté par `ClaudeSdkSession` (mode `-p --output-format stream-json` / SDK). Claude, /// Piloté par `CodexExecSession` (`codex exec` structuré). Codex, } impl StructuredAdapter { /// Clé **stable par famille de moteur** (provider), indépendante de l'uuid /// d'instance d'un profil. Sert de clé dans `providers.json` (lot P8b) : un swap /// d'un profil vers un autre profil de **la même famille** réutilise la même /// entrée (resumable partagé), et le fichier reste lisible à l'œil. /// /// Mapping : `Claude → "claude"`, `Codex → "codex"` (aligné sur la sérialisation /// camelCase de l'enum, mais figé ici comme **contrat de persistance** : ce /// mapping ne doit pas dériver si la représentation serde change). #[must_use] pub const fn provider_key(self) -> &'static str { match self { Self::Claude => "claude", Self::Codex => "codex", } } } /// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine). /// /// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert). /// Déclaratif, Open/Closed (comme [`StructuredAdapter`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum McpTransport { /// Pont stdio (défaut) : IdeA parle au serveur MCP via stdin/stdout. #[default] Stdio, /// Socket partagé (adresse) : optimisation, point ouvert (spike S-MCP). Socket, } /// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI /// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code, /// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9). /// /// Invariants (garantis par les constructeurs) : /// - `ConfigFile.target` est un chemin relatif sûr (pas de `..`, pas d'absolu), /// - `Flag.flag` est non vide, /// - `Env.var` est un identifiant de variable d'environnement valide. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "strategy")] pub enum McpConfigStrategy { /// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1) /// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`). ConfigFile { /// Chemin relatif sûr du fichier de conf MCP. target: String, }, /// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`). Flag { /// Le flag (template) non vide. flag: String, }, /// Passer via une variable d'environnement. Env { /// Nom de la variable d'environnement. var: String, }, /// Écrire un fichier de conf MCP **TOML** au chemin (relatif au run dir isolé /// §14.1) attendu par la CLI, et pousser `home_env` (le nom d'une variable /// d'environnement) vers le **dossier parent** de `target` pour isoler la CLI /// de sa config globale. C'est le pendant Codex de [`Self::ConfigFile`] : Codex /// lit ses serveurs MCP dans `$CODEX_HOME/config.toml` (défaut `~/.codex`), table /// TOML `[mcp_servers.]`. IdeA écrit ce `config.toml` DANS le run dir de /// l'agent et pointe `CODEX_HOME={runDir}/.codex` pour ne JAMAIS toucher au /// `~/.codex` global (isolation par agent, miroir du `.mcp.json` de Claude). TomlConfigHome { /// Chemin relatif sûr du fichier `config.toml` (convention : /// `".codex/config.toml"`). target: String, /// Nom de la variable d'environnement pointée sur le **dossier parent** de /// `target` (ex. `"CODEX_HOME"`). home_env: String, }, } impl McpConfigStrategy { /// Constructeur validé `ConfigFile`. /// /// # Errors /// Renvoie [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou /// contient `..`. pub fn config_file(target: impl Into) -> Result { let target = target.into(); crate::validation::relative_safe(&target)?; Ok(Self::ConfigFile { target }) } /// Constructeur validé `Flag`. /// /// # Errors /// Renvoie [`DomainError::EmptyField`] si `flag` est vide. pub fn flag(flag: impl Into) -> Result { let flag = flag.into(); crate::validation::non_empty(&flag, "mcp.config.flag")?; Ok(Self::Flag { flag }) } /// Constructeur validé `Env`. /// /// # Errors /// Renvoie [`DomainError::InvalidEnvVar`] si `var` n'est pas un identifiant /// valide. pub fn env(var: impl Into) -> Result { let var = var.into(); crate::validation::valid_env_var(&var)?; Ok(Self::Env { var }) } /// Constructeur validé `TomlConfigHome` (pendant Codex de [`Self::config_file`]). /// /// # Errors /// - [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou contient `..` /// (même validation que [`Self::config_file`]), /// - [`DomainError::InvalidEnvVar`] si `home_env` n'est pas un identifiant de /// variable d'environnement valide. pub fn toml_config_home( target: impl Into, home_env: impl Into, ) -> Result { let target = target.into(); let home_env = home_env.into(); crate::validation::relative_safe(&target)?; crate::validation::valid_env_var(&home_env)?; Ok(Self::TomlConfigHome { target, home_env }) } } /// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI, /// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` + /// prose (comportement actuel, zéro régression). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpCapability { /// Comment matérialiser la config MCP au lancement (relatif au run dir). pub config: McpConfigStrategy, /// Transport du serveur MCP IdeA (détail invisible au domaine). /// `stdio` = défaut robuste cross-OS ; `socket` = optimisation. #[serde(default)] pub transport: McpTransport, } impl McpCapability { /// Construit une capacité MCP. La validation est portée par le constructeur /// de [`McpConfigStrategy`] (parse, don't validate) ; `transport` n'a pas /// d'invariant. #[must_use] pub const fn new(config: McpConfigStrategy, transport: McpTransport) -> Self { Self { config, transport } } } /// Donnée de **wiring** du serveur MCP IdeA (`command` + `args` + `transport`), /// factorisée pour servir de **source unique** aux deux sérialisations qui en /// dérivaient séparément (et risquaient de diverger) : la déclaration `.mcp.json` /// de Claude (JSON) et la table `[mcp_servers.idea]` de Codex (TOML). /// /// Pure (aucune I/O, aucune dépendance) : les deux encodeurs construisent une /// chaîne à la main, donc le domaine reste sans dépendance (`serde_json`/`toml` /// non requis). Le contenu (exe, endpoint, …) est calculé par l'appelant — le /// domaine ne fait que la **mise en forme**. #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpServerWiring { /// Commande à lancer (le binaire IdeA en mode `mcp-server`, ou `"idea"` en /// déclaration minimale dégradée). pub command: String, /// Arguments passés à `command` (ex. `["mcp-server", "--endpoint", …]`). pub args: Vec, /// Transport du serveur MCP (surfacé dans les deux formats). pub transport: McpTransport, } impl McpServerWiring { /// Codex's MCP client defaults tool calls to a short interactive timeout (120s on /// the versions observed in IdeA). `idea_ask_agent` is a synchronous rendezvous /// that can legitimately span a full delegated dev/test turn, so the generated /// server config must widen the per-tool timeout. pub const IDEA_TOOL_TIMEOUT_SEC: u32 = 24 * 60 * 60; /// Construit le wiring depuis ses parties. #[must_use] pub const fn new(command: String, args: Vec, transport: McpTransport) -> Self { Self { command, args, transport, } } /// Étiquette stable du transport, identique pour les deux formats. #[must_use] const fn transport_label(&self) -> &'static str { match self.transport { McpTransport::Stdio => "stdio", McpTransport::Socket => "socket", } } /// Encode un document **`.mcp.json`** complet (Claude Code et CLIs apparentées) : /// `{ "mcpServers": { "idea": { command, args, transport } } }`. Chaque chaîne est /// échappée en littéral JSON (chemins avec espaces/backslash/quotes restent /// valides). #[must_use] pub fn to_mcp_json(&self) -> String { let command = json_string(&self.command); let args = if self.args.is_empty() { String::new() } else { let joined = self .args .iter() .map(|a| format!("\n {}", json_string(a))) .collect::>() .join(","); format!("{joined}\n ") }; let transport = self.transport_label(); format!( r#"{{ "mcpServers": {{ "idea": {{ "command": {command}, "args": [{args}], "transport": "{transport}" }} }} }} "# ) } /// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex : /// `command`, `args` (tableau TOML), `transport`, et l'approbation automatique /// des outils IdeA. Chaque chaîne est échappée en chaîne basique TOML /// (équivalent du `json_string` : espaces/backslash/quotes restent valides). #[must_use] pub fn to_config_toml(&self) -> String { let command = toml_string(&self.command); let args = self .args .iter() .map(|a| toml_string(a)) .collect::>() .join(", "); let transport = self.transport_label(); let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC; format!( "[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n" ) } } /// Échappe `s` en **littéral de chaîne JSON** (guillemets inclus) pour les chemins /// exe/endpoint avec espaces, backslash ou quotes. fn json_string(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), c => out.push(c), } } out.push('"'); out } /// Échappe `s` en **chaîne basique TOML** (guillemets inclus). Les chaînes /// basiques TOML utilisent les mêmes séquences d'échappement que JSON pour `"`, /// `\`, et les contrôles. fn toml_string(s: &str) -> String { // Le jeu d'échappement requis par une chaîne basique TOML coïncide avec celui de // JSON pour les caractères qui nous concernent (chemins, flags). json_string(s) } /// Declarative runtime configuration for one AI CLI. /// /// Invariants: /// - `name` and `command` non-empty, /// - `context_injection` is itself valid (guaranteed by its constructors), /// - `session` is itself valid (guaranteed by its constructor). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentProfile { /// Stable identifier. pub id: ProfileId, /// Display name. pub name: String, /// Launch command (e.g. `claude`, `codex`, `gemini`, `aider`). pub command: String, /// Static arguments. pub args: Vec, /// Context-injection strategy. pub context_injection: ContextInjection, /// Optional detection command (e.g. `claude --version`). pub detect: Option, /// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1): /// an agent's PTY cwd is its isolated `.ideai/run//` directory, /// **never** the project root, so that N agents of the same profile never /// collide on a single conventional file (`CLAUDE.md`, …) at the root. pub cwd_template: String, /// Optional conversation assign/resume strategy. Absent (legacy / no resume) /// keeps today's behaviour. #[serde(default, skip_serializing_if = "Option::is_none")] pub session: Option, /// Adapter d'exécution **structurée** (ARCHITECTURE §17). `None` ⇒ agent /// **TUI/PTY** (cellule terminal brut, comportement historique). `Some(_)` ⇒ /// agent **IA structuré** (cellule chat + port [`crate::ports::AgentSession`]). /// Open/Closed : ajouter un moteur = une variante + un adapter. /// /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de /// sérialisation : un profil sans adapter sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub structured_adapter: Option, /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1). /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel). /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et /// l'agent voit les outils `idea_*`. Additif, Open/Closed. /// /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de /// sérialisation : un profil sans MCP sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp: Option, /// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut, /// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ /// présent mais **non consommé** (place ménagée pour les seuils de stagnation / /// timeout de tour du lot 2). /// /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation : /// un profil sans cette clé sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub liveness: Option, /// Motif déclaratif de détection d'une **limite de session/débit** (ARCHITECTURE /// §21, niveau 2). `None` (défaut, et valeur des profils existants) ⇒ **aucune** /// détection par motif : seuls les agents structurés (niveau 1) ou le filet /// humain (niveau 3) couvrent la limite. `Some(_)` ⇒ pour un agent **PTY/TUI sans /// adapter structuré**, IdeA observe la sortie et émet un /// [`crate::ports::ReplyEvent::RateLimited`] sur match. /// /// Le domaine ne porte que la **donnée** ([`RateLimitPattern`]) ; le moteur regex /// + le parsing d'heure vivent en infra (§21.2-T2) — domaine dépendance-zéro /// préservé. /// /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation : /// un profil sans cette clé sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub rate_limit_pattern: Option, /// Séquence de soumission écrite **après** le texte d'une délégation pour la /// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front) /// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de /// la TUI), puis **cette séquence seule** après un court délai. /// /// `None` ⇒ défaut `"\r"` appliqué **au point d'usage** (front), jamais codé /// en dur dans le domaine (model-agnostic, déclaratif). Une autre TUI peut /// exiger une séquence différente → c'est précisément pourquoi c'est donnée. /// /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** : un profil /// sans cette clé sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub submit_sequence: Option, /// Délai (ms) entre l'écriture du texte et celle de [`Self::submit_sequence`] /// (§20.3, fix Bug 1). Évite que la TUI absorbe la soumission comme un paste. /// /// `None` ⇒ défaut (~60 ms) appliqué **au point d'usage** (front), jamais codé /// en dur dans le domaine. /// /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation. #[serde(default, skip_serializing_if = "Option::is_none")] pub submit_delay_ms: Option, /// Clé du **projecteur de permissions** par-CLI (lot LP3) : QUEL adapter /// traduit les [`crate::permission::EffectivePermissions`] résolues en config /// de permission concrète de cette CLI au lancement. Donnée **déclarative** /// (Open/Closed), calquée sur [`StructuredAdapter`]. /// /// `None` (défaut, et valeur des profils existants) ⇒ **aucune** projection : /// la CLI garde son prompting natif (invariant produit de /// [`crate::permission::resolve`]). `Some(_)` ⇒ IdeA matérialise la config de /// permission de cette CLI au lancement. /// /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de /// sérialisation : un profil sans cette clé sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub projector: Option, } /// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel). /// /// Declarative, Open/Closed: a new engine family is a new variant *only* if it /// changes the adapter's dispatch; otherwise it is pure data on the profile /// (`model`, `endpoint`, …). The default product posture is [`None`](Self::None): /// no embedding ⇒ no heavy dependency, recall stays the naïve étage 1. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum EmbedderStrategy { /// A local ONNX model run in-process (real integration is a follow-up). LocalOnnx, /// A local embedding server reached over HTTP (real integration is a follow-up). LocalServer, /// A remote embedding API (real integration is a follow-up). Api, /// No embedding engine: recall stays the dependency-free naïve étage 1. None, } /// Declarative configuration for one embedding engine (LOT C, étage 2). /// /// Stored in the global IDE store as `embedder.json`, mirroring `profiles.json` /// for [`AgentProfile`]s: adding an engine is **data, not code** (Open/Closed). /// /// Invariants: /// - `id` and `name` non-empty, /// - `dimension` is non-zero (an embedder always produces fixed-length vectors). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmbedderProfile { /// Stable identifier (e.g. `"local-onnx-minilm"`). pub id: String, /// Display name. pub name: String, /// Embedding strategy driving which concrete adapter is used. pub strategy: EmbedderStrategy, /// Model identifier (e.g. an ONNX model name), when the strategy needs one. #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, /// Endpoint URL for a server/API strategy. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint: Option, /// Name of the environment variable carrying the API key (never the key /// itself), for an `api` strategy. #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key_env: Option, /// Length of the vectors this engine produces. pub dimension: usize, } impl EmbedderProfile { /// Builds a validated embedder profile. /// /// # Errors /// - [`DomainError::EmptyField`] if `id` or `name` is empty, /// - [`DomainError::EmptyField`] (`"embedder.dimension"`) if `dimension` is `0`. pub fn new( id: impl Into, name: impl Into, strategy: EmbedderStrategy, model: Option, endpoint: Option, api_key_env: Option, dimension: usize, ) -> Result { let id = id.into(); let name = name.into(); crate::validation::non_empty(&id, "embedder.id")?; crate::validation::non_empty(&name, "embedder.name")?; if dimension == 0 { return Err(DomainError::EmptyField { field: "embedder.dimension", }); } Ok(Self { id, name, strategy, model, endpoint, api_key_env, dimension, }) } /// A dependency-free default profile: strategy [`EmbedderStrategy::None`]. /// Recall stays the naïve étage 1 — nothing is imposed. #[must_use] pub fn none() -> Self { Self { id: "none".to_owned(), name: "None".to_owned(), strategy: EmbedderStrategy::None, model: None, endpoint: None, api_key_env: None, dimension: 1, } } } impl AgentProfile { /// Builds a validated profile. /// /// # Errors /// Returns [`DomainError::EmptyField`] if `name` or `command` is empty. #[allow(clippy::too_many_arguments)] pub fn new( id: ProfileId, name: impl Into, command: impl Into, args: Vec, context_injection: ContextInjection, detect: Option, cwd_template: impl Into, session: Option, ) -> Result { let name = name.into(); let command = command.into(); let cwd_template = cwd_template.into(); crate::validation::non_empty(&name, "profile.name")?; crate::validation::non_empty(&command, "profile.command")?; Ok(Self { id, name, command, args, context_injection, detect, cwd_template, session, structured_adapter: None, mcp: None, liveness: None, rate_limit_pattern: None, submit_sequence: None, submit_delay_ms: None, projector: None, }) } /// Builder : fixe l'[`StructuredAdapter`] d'exécution structurée (§17) et /// renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression /// d'appel) : les profils TUI/PTY ne l'appellent simplement pas. #[must_use] pub fn with_structured_adapter(mut self, adapter: StructuredAdapter) -> Self { self.structured_adapter = Some(adapter); self } /// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profils sans MCP ne l'appellent simplement pas. #[must_use] pub fn with_mcp(mut self, mcp: McpCapability) -> Self { self.mcp = Some(mcp); self } /// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie /// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profils sans réglage de vivacité ne l'appellent simplement pas. #[must_use] pub const fn with_liveness(mut self, liveness: LivenessStrategy) -> Self { self.liveness = Some(liveness); self } /// Builder : fixe le [`RateLimitPattern`] de détection de limite (§21, niveau 2) /// et renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression /// d'appel) : les profils sans détection par motif ne l'appellent simplement pas. #[must_use] pub fn with_rate_limit_pattern(mut self, pattern: RateLimitPattern) -> Self { self.rate_limit_pattern = Some(pattern); self } /// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas. #[must_use] pub fn with_submit_sequence(mut self, sequence: impl Into) -> Self { self.submit_sequence = Some(sequence.into()); self } /// Builder : fixe le [`Self::submit_delay_ms`] (§20.3, fix Bug 1) et renvoie le /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel). #[must_use] pub const fn with_submit_delay_ms(mut self, delay_ms: u32) -> Self { self.submit_delay_ms = Some(delay_ms); self } /// Builder : fixe la [`ProjectorKey`] de permissions (lot LP3) et renvoie le /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profils sans projection de permission ne l'appellent simplement pas. #[must_use] pub const fn with_projector(mut self, projector: ProjectorKey) -> Self { self.projector = Some(projector); self } /// Indique si ce profil peut être **proposé à la sélection/création** d'un /// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est /// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il /// est pilotable en mode structuré. C'est exactement le prédicat que /// l'`AgentSessionFactory` utilise pour décider qu'il sait piloter le profil /// (`supports`) : les deux doivent rester d'accord. /// /// Ceci ne restreint **que** ce qui est offert à la création : un profil /// sans adapter reste un profil PTY/legacy valide, persistable et exécutable /// (zéro régression sur l'existant). #[must_use] pub fn is_selectable(&self) -> bool { self.structured_adapter.is_some() } /// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré /// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils /// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation /// inter-agents (`idea_ask_agent`/`idea_reply`) peut router une cible. /// /// Un profil déclare bien une [`McpConfigStrategy`], mais ce n'est honoré que si /// IdeA sait écrire la config que **cette** CLI lit nativement : /// - `Claude` + `ConfigFile { target == ".mcp.json" }` ⇒ Claude lit `.mcp.json` /// dans son cwd (run dir isolé) ; /// - `Codex` + `TomlConfigHome { .. }` ⇒ Codex lit `$CODEX_HOME/config.toml`, /// qu'IdeA isole dans le run dir via `home_env` ; /// - tout autre couple (y compris `mcp` absent) ⇒ `false` : repli fichier /// `.ideai/requests` + prose, le pont natif n'est pas branché. /// /// Cette fonction centralise le critère pour que la garde applicative /// ([`crate`] côté application) et la matérialisation ne puissent pas diverger. #[must_use] pub fn materializes_idea_bridge(&self) -> bool { match ( self.structured_adapter, self.mcp.as_ref().map(|c| &c.config), ) { (Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => { target == ".mcp.json" } (Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => { true } _ => false, } } } #[cfg(test)] mod mcp_tests { use super::*; use crate::ids::ProfileId; /// A reference profile without any MCP capability (the historical shape). fn profile_without_mcp() -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::nil()), "Dev", "claude", vec!["-p".to_owned()], ContextInjection::convention_file("CLAUDE.md").expect("valid target"), Some("claude --version".to_owned()), "{agentRunDir}", None, ) .expect("valid profile") } // -- Critère 1 : zéro régression de sérialisation (mcp = None) -------------- #[test] fn profile_without_mcp_omits_mcp_key_in_json() { let profile = profile_without_mcp(); assert!(profile.mcp.is_none()); let json = serde_json::to_string(&profile).expect("serialise"); assert!( !json.contains("\"mcp\""), "a profile without MCP must NOT serialise an `mcp` key (zero regression); got: {json}" ); } #[test] fn profile_without_mcp_round_trips_identically() { let profile = profile_without_mcp(); let json = serde_json::to_string(&profile).expect("serialise"); let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); assert_eq!(profile, back); assert!(back.mcp.is_none()); } #[test] fn legacy_json_without_mcp_field_deserialises_to_none() { // JSON produced before the `mcp` field existed: no `mcp` key at all. let legacy = r#"{ "id": "00000000-0000-0000-0000-000000000000", "name": "Dev", "command": "claude", "args": [], "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, "detect": null, "cwdTemplate": "{agentRunDir}" }"#; let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); assert!(profile.mcp.is_none()); } // -- Critère 2 : round-trip avec MCP (les 3 stratégies) --------------------- #[test] fn profile_with_mcp_config_file_round_trips() { let cap = McpCapability::new( McpConfigStrategy::config_file(".mcp.json").expect("valid target"), McpTransport::Stdio, ); let profile = profile_without_mcp().with_mcp(cap.clone()); assert_eq!(profile.mcp, Some(cap)); let json = serde_json::to_string(&profile).expect("serialise"); assert!(json.contains("\"mcp\""), "mcp key must be present: {json}"); let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); assert_eq!(profile, back); } #[test] fn mcp_capability_flag_strategy_round_trips() { let cap = McpCapability::new( McpConfigStrategy::flag("--mcp-config {path}").expect("valid flag"), McpTransport::Socket, ); let json = serde_json::to_string(&cap).expect("serialise"); let back: McpCapability = serde_json::from_str(&json).expect("deserialise"); assert_eq!(cap, back); assert_eq!(back.transport, McpTransport::Socket); assert!(matches!(back.config, McpConfigStrategy::Flag { .. })); } #[test] fn mcp_capability_env_strategy_round_trips() { let cap = McpCapability::new( McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid env"), McpTransport::Stdio, ); let json = serde_json::to_string(&cap).expect("serialise"); let back: McpCapability = serde_json::from_str(&json).expect("deserialise"); assert_eq!(cap, back); assert!(matches!(back.config, McpConfigStrategy::Env { .. })); } #[test] fn mcp_config_strategy_uses_tagged_camel_case() { // The `strategy` tag and camelCase rename are part of the wire contract. let json = serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid")) .expect("serialise"); assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}"); } // -- Critère 4 : transport par défaut = Stdio ------------------------------- #[test] fn transport_default_is_stdio() { assert_eq!(McpTransport::default(), McpTransport::Stdio); } #[test] fn mcp_capability_without_transport_defaults_to_stdio() { // `transport` is `#[serde(default)]`: an MCP capability serialised without // it must deserialise to Stdio. let json = r#"{ "config": { "strategy": "configFile", "target": ".mcp.json" } }"#; let cap: McpCapability = serde_json::from_str(json).expect("deserialise"); assert_eq!(cap.transport, McpTransport::Stdio); } // -- Critère 3 : validation des constructeurs ------------------------------- #[test] fn config_file_rejects_absolute_target() { let err = McpConfigStrategy::config_file("/etc/mcp.json").unwrap_err(); assert!(matches!(err, DomainError::PathNotRelativeSafe { .. })); } #[test] fn config_file_rejects_parent_traversal() { let err = McpConfigStrategy::config_file("../escape/.mcp.json").unwrap_err(); assert!(matches!(err, DomainError::PathNotRelativeSafe { .. })); } #[test] fn config_file_accepts_safe_relative_target() { let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative"); assert_eq!( s, McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() } ); } #[test] fn flag_rejects_empty() { let err = McpConfigStrategy::flag("").unwrap_err(); assert!(matches!(err, DomainError::EmptyField { .. })); } #[test] fn flag_accepts_non_empty() { let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty"); assert_eq!( s, McpConfigStrategy::Flag { flag: "--mcp-config {path}".to_owned() } ); } #[test] fn env_rejects_invalid_name() { let err = McpConfigStrategy::env("1BAD-NAME").unwrap_err(); assert!(matches!(err, DomainError::InvalidEnvVar { .. })); } #[test] fn env_accepts_valid_name() { let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid"); assert_eq!( s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() } ); } // -- Backstop no-reply : le champ `promptReadyPattern` a été retiré ---------- #[test] fn legacy_json_with_obsolete_prompt_ready_pattern_is_ignored() { // Le watcher prompt-ready PTY est supprimé (remplacé par le TurnWatcher // transcript). Un `profiles.json` antérieur portant encore la clé doit // continuer à se désérialiser (pas de `deny_unknown_fields`) : la clé obsolète // est simplement ignorée — zéro régression de lecture des profils existants. let legacy = r#"{ "id": "00000000-0000-0000-0000-000000000000", "name": "Dev", "command": "claude", "args": [], "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, "detect": null, "cwdTemplate": "{agentRunDir}", "promptReadyPattern": "? for shortcuts" }"#; let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); assert_eq!(profile.command, "claude"); } // -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------ #[test] fn profile_default_has_no_submit_fields() { let p = profile_without_mcp(); assert!(p.submit_sequence.is_none()); assert!(p.submit_delay_ms.is_none()); } #[test] fn profile_without_submit_fields_omits_keys_in_json() { let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); assert!( !json.contains("submitSequence"), "no submitSequence key when None (zero regression); got: {json}" ); assert!( !json.contains("submitDelayMs"), "no submitDelayMs key when None (zero regression); got: {json}" ); } #[test] fn legacy_json_without_submit_fields_deserialises_to_none() { let legacy = r#"{ "id": "00000000-0000-0000-0000-000000000000", "name": "Dev", "command": "claude", "args": [], "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, "detect": null, "cwdTemplate": "{agentRunDir}" }"#; let p: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); assert!(p.submit_sequence.is_none()); assert!(p.submit_delay_ms.is_none()); } #[test] fn with_submit_fields_set_and_round_trip_camel_case() { let p = profile_without_mcp() .with_submit_sequence("\r") .with_submit_delay_ms(60); assert_eq!(p.submit_sequence.as_deref(), Some("\r")); assert_eq!(p.submit_delay_ms, Some(60)); let json = serde_json::to_string(&p).expect("serialise"); assert!(json.contains("submitSequence"), "key present: {json}"); assert!(json.contains("submitDelayMs"), "key present: {json}"); let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); assert_eq!(p, back); assert_eq!(back.submit_sequence.as_deref(), Some("\r")); assert_eq!(back.submit_delay_ms, Some(60)); } // -- Lot 1 : liveness (readiness/heartbeat) — non-régression de sérialisation -- #[test] fn profile_default_has_no_liveness() { // Profils existants (via `new`) : aucun réglage de vivacité. assert!(profile_without_mcp().liveness.is_none()); } #[test] fn profile_without_liveness_omits_key_in_json() { let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); assert!( !json.contains("liveness"), "a profile without liveness must NOT serialise the key (zero regression); got: {json}" ); } #[test] fn legacy_json_without_liveness_deserialises_to_none() { let legacy = r#"{ "id": "00000000-0000-0000-0000-000000000000", "name": "Dev", "command": "claude", "args": [], "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, "detect": null, "cwdTemplate": "{agentRunDir}" }"#; let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); assert!(profile.liveness.is_none()); } #[test] fn with_liveness_sets_and_round_trips_camel_case() { let liveness = LivenessStrategy::new(Some(30_000), Some(600_000)).expect("valid liveness"); let profile = profile_without_mcp().with_liveness(liveness); assert_eq!(profile.liveness, Some(liveness)); let json = serde_json::to_string(&profile).expect("serialise"); assert!(json.contains("liveness"), "key present: {json}"); assert!(json.contains("stallAfterMs"), "camelCase field: {json}"); assert!(json.contains("turnTimeoutMs"), "camelCase field: {json}"); let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); assert_eq!(profile, back); assert_eq!(back.liveness, Some(liveness)); } #[test] fn liveness_omits_unset_thresholds_in_json() { // Un seul seuil fixé : l'autre est `None` ⇒ sa clé est omise. let liveness = LivenessStrategy::new(None, Some(600_000)).expect("valid liveness"); let json = serde_json::to_string(&liveness).expect("serialise"); assert!( !json.contains("stallAfterMs"), "an unset stall threshold must be omitted; got: {json}" ); assert!( json.contains("turnTimeoutMs"), "set threshold present: {json}" ); } #[test] fn liveness_new_rejects_zero_thresholds() { assert!(matches!( LivenessStrategy::new(Some(0), None).unwrap_err(), DomainError::EmptyField { .. } )); assert!(matches!( LivenessStrategy::new(None, Some(0)).unwrap_err(), DomainError::EmptyField { .. } )); // Les deux None : valide (= « aucun seuil »). assert!(LivenessStrategy::new(None, None).is_ok()); } // -- Codex : surface MCP `TomlConfigHome` (pont inter-agents Codex) ---------- #[test] fn toml_config_home_round_trips_with_tagged_strategy() { let strategy = McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME") .expect("valid toml config home"); let json = serde_json::to_string(&strategy).expect("serialise"); // Wire contract: tagged enum (`strategy` tag) + camelCase variant & fields. assert!( json.contains("\"strategy\":\"tomlConfigHome\""), "tagged camelCase variant expected; got: {json}" ); assert!( json.contains("\"target\":\".codex/config.toml\""), "target field expected; got: {json}" ); // NB: `rename_all = "camelCase"` on this enum renames *variants*, not the // fields of a struct variant, so `home_env` stays snake_case on the wire. assert!( json.contains("\"home_env\":\"CODEX_HOME\""), "home_env field expected; got: {json}" ); let back: McpConfigStrategy = serde_json::from_str(&json).expect("deserialise"); assert_eq!(strategy, back); } #[test] fn toml_config_home_rejects_absolute_and_parent_target() { let abs = McpConfigStrategy::toml_config_home("/abs/x", "CODEX_HOME").unwrap_err(); assert!(matches!(abs, DomainError::PathNotRelativeSafe { .. })); let parent = McpConfigStrategy::toml_config_home("../escape", "CODEX_HOME").unwrap_err(); assert!(matches!(parent, DomainError::PathNotRelativeSafe { .. })); } #[test] fn toml_config_home_rejects_invalid_home_env() { // Empty home_env: first char is None ⇒ invalid identifier. let empty = McpConfigStrategy::toml_config_home(".codex/config.toml", "").unwrap_err(); assert!(matches!(empty, DomainError::InvalidEnvVar { .. })); // Illegal character in the env var name. let illegal = McpConfigStrategy::toml_config_home(".codex/config.toml", "BAD-NAME").unwrap_err(); assert!(matches!(illegal, DomainError::InvalidEnvVar { .. })); } #[test] fn materializes_idea_bridge_matrix() { // Claude + `.mcp.json` ConfigFile ⇒ bridge materialised. let claude = profile_without_mcp() .with_structured_adapter(StructuredAdapter::Claude) .with_mcp(McpCapability::new( McpConfigStrategy::config_file(".mcp.json").expect("valid target"), McpTransport::Stdio, )); assert!(claude.materializes_idea_bridge()); // Codex + TomlConfigHome ⇒ bridge materialised (the key point: Codex used to // be refused before this surface existed). let codex = profile_without_mcp() .with_structured_adapter(StructuredAdapter::Codex) .with_mcp(McpCapability::new( McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME") .expect("valid toml config home"), McpTransport::Stdio, )); assert!(codex.materializes_idea_bridge()); // Codex WITHOUT any MCP capability ⇒ no bridge. let codex_no_mcp = profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex); assert!(!codex_no_mcp.materializes_idea_bridge()); // Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge. let codex_wrong_strategy = profile_without_mcp() .with_structured_adapter(StructuredAdapter::Codex) .with_mcp(McpCapability::new( McpConfigStrategy::config_file(".mcp.json").expect("valid target"), McpTransport::Stdio, )); assert!(!codex_wrong_strategy.materializes_idea_bridge()); } // -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ---------- #[test] fn profile_default_has_no_projector() { // Profils existants (via `new`) : aucune clé de projecteur ⇒ prompting natif. assert!(profile_without_mcp().projector.is_none()); } #[test] fn profile_without_projector_omits_key_in_json() { let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); assert!( !json.contains("projector"), "a profile without a projector must NOT serialise the key (zero regression); got: {json}" ); } #[test] fn legacy_json_without_projector_deserialises_to_none() { // JSON produit avant l'existence du champ `projector` : aucune clé `projector`. let legacy = r#"{ "id": "00000000-0000-0000-0000-000000000000", "name": "Dev", "command": "claude", "args": [], "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, "detect": null, "cwdTemplate": "{agentRunDir}" }"#; let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); assert!(profile.projector.is_none()); } #[test] fn with_projector_sets_and_round_trips_camel_case() { let profile = profile_without_mcp().with_projector(ProjectorKey::Claude); assert_eq!(profile.projector, Some(ProjectorKey::Claude)); let json = serde_json::to_string(&profile).expect("serialise"); assert!( json.contains("\"projector\":\"claude\""), "projector key present in stable wire form: {json}" ); let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); assert_eq!(profile, back); assert_eq!(back.projector, Some(ProjectorKey::Claude)); } #[test] fn mcp_server_wiring_encodes_expected_toml() { // A command path with a space and a backslash exercises TOML escaping. let wiring = McpServerWiring::new( "/opt/My Apps\\idea".to_owned(), vec![ "mcp-server".to_owned(), "--endpoint".to_owned(), "/tmp/sock 1".to_owned(), ], McpTransport::Stdio, ); let toml = wiring.to_config_toml(); // Table header present. assert!( toml.contains("[mcp_servers.idea]"), "table header expected; got: {toml}" ); // Command path escaped as a TOML basic string (backslash doubled, space kept). assert!( toml.contains("command = \"/opt/My Apps\\\\idea\""), "escaped command path expected; got: {toml}" ); // Args preserved in order, as a TOML array. assert!( toml.contains("args = [\"mcp-server\", \"--endpoint\", \"/tmp/sock 1\"]"), "args in order expected; got: {toml}" ); // Transport surfaced. assert!( toml.contains("transport = \"stdio\""), "transport expected; got: {toml}" ); // IdeA-owned MCP tools are pre-approved, matching Claude's non-prompting // `.mcp.json` path. assert!( toml.contains("default_tools_approval_mode = \"approve\""), "IdeA MCP tools should not prompt for approval; got: {toml}" ); assert!( toml.contains("tool_timeout_sec = 86400"), "IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}" ); } // -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------ #[test] fn rate_limit_pattern_new_rejects_empty_pattern() { let err = RateLimitPattern::new("", None, None).unwrap_err(); assert!( matches!(err, DomainError::EmptyField { field } if field == "rateLimitPattern.pattern"), "un motif vide doit être rejeté; got: {err:?}" ); } #[test] fn rate_limit_pattern_new_accepts_non_empty_pattern() { let p = RateLimitPattern::new( "rate limit.*resets at (?P.+)", Some("reset".to_owned()), Some("%H:%M".to_owned()), ) .expect("valid pattern"); assert_eq!(p.pattern, "rate limit.*resets at (?P.+)"); assert_eq!(p.reset_capture.as_deref(), Some("reset")); assert_eq!(p.time_format.as_deref(), Some("%H:%M")); } #[test] fn profile_default_has_no_rate_limit_pattern() { // Profils existants (via `new`) : aucun motif de limite. assert!(profile_without_mcp().rate_limit_pattern.is_none()); } #[test] fn profile_without_rate_limit_pattern_omits_key_in_json() { let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); assert!( !json.contains("rateLimitPattern"), "a profile without a rate-limit pattern must NOT serialise the key (zero regression); got: {json}" ); } #[test] fn legacy_json_without_rate_limit_pattern_deserialises_to_none() { // JSON produit avant l'existence du champ : aucune clé `rateLimitPattern`. let legacy = r#"{ "id": "00000000-0000-0000-0000-000000000000", "name": "Dev", "command": "claude", "args": [], "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, "detect": null, "cwdTemplate": "{agentRunDir}" }"#; let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); assert!(profile.rate_limit_pattern.is_none()); } #[test] fn with_rate_limit_pattern_sets_and_round_trips_camel_case() { let pattern = RateLimitPattern::new( "limit reached, resets (?P.+)", Some("resetAt".to_owned()), Some("%-I%p".to_owned()), ) .expect("valid pattern"); let profile = profile_without_mcp().with_rate_limit_pattern(pattern.clone()); assert_eq!(profile.rate_limit_pattern, Some(pattern)); let json = serde_json::to_string(&profile).expect("serialise"); assert!(json.contains("rateLimitPattern"), "key present: {json}"); // camelCase respecté sur les champs de RateLimitPattern. assert!( json.contains("resetCapture"), "camelCase field resetCapture: {json}" ); assert!( json.contains("timeFormat"), "camelCase field timeFormat: {json}" ); let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); assert_eq!(profile, back); } #[test] fn rate_limit_pattern_omits_unset_optional_fields_in_json() { // reset_capture / time_format à None ⇒ leurs clés sont omises. let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern"); let json = serde_json::to_string(&pattern).expect("serialise"); assert!( json.contains("\"pattern\""), "pattern field present: {json}" ); assert!( !json.contains("resetCapture"), "an unset resetCapture must be omitted; got: {json}" ); assert!( !json.contains("timeFormat"), "an unset timeFormat must be omitted; got: {json}" ); } }