//! 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; /// 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, }) } } /// 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, }, } 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 }) } } /// 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 } } } /// 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, /// Motif de **retour-de-prompt** (cadrage « conversation par paire » §6, lot C5). /// /// Donnée **déclarative** (pas de code par CLI — Open/Closed) : un motif **littéral** /// recherché par sous-chaîne dans le flux de sortie PTY du tour courant. Quand il /// apparaît, IdeA considère l'agent **revenu au prompt** et le marque `Idle` /// (`InputMediator::mark_idle`), ce qui fait avancer sa file. C'est l'un des deux /// signaux du « double signal OR » (l'autre étant un `idea_reply` explicite) ; le /// **premier** des deux qui arrive libère le tour. /// /// **Choix tranché : littéral, pas regex.** Une sous-chaîne littérale est /// déterministe, sans dépendance (`regex`), suffisante pour un sigil de prompt /// stable (ex. `"\n> "`), et ne risque pas le faux-positif d'un motif regex mal /// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard /// comme variante déclarative (Open/Closed) si le besoin se confirme. /// /// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par /// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via /// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste /// `Busy` mais la file continue d'accepter » (jamais de faux `Idle`). /// /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation : /// un profil sans motif sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt_ready_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, } /// 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, prompt_ready_pattern: None, submit_sequence: None, submit_delay_ms: 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 le motif de **retour-de-prompt** (§6, lot C5) et renvoie le /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profils sans détection de prompt ne l'appellent simplement pas. #[must_use] pub fn with_prompt_ready_pattern(mut self, pattern: impl Into) -> Self { self.prompt_ready_pattern = Some(pattern.into()); 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 } /// 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() } } #[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() } ); } // -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) -------------- #[test] fn profile_default_has_no_prompt_ready_pattern() { // Existing profiles (built via `new`) carry no pattern ⇒ no false Idle. assert!(profile_without_mcp().prompt_ready_pattern.is_none()); } #[test] fn profile_without_prompt_pattern_omits_key_in_json() { let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); assert!( !json.contains("promptReadyPattern"), "a profile without a prompt pattern must NOT serialise the key (zero regression); got: {json}" ); } #[test] fn legacy_json_without_prompt_pattern_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.prompt_ready_pattern.is_none()); } #[test] fn with_prompt_ready_pattern_sets_and_round_trips() { let profile = profile_without_mcp().with_prompt_ready_pattern("\n> "); assert_eq!(profile.prompt_ready_pattern.as_deref(), Some("\n> ")); let json = serde_json::to_string(&profile).expect("serialise"); assert!(json.contains("promptReadyPattern"), "key present: {json}"); let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); assert_eq!(profile, back); assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> ")); } // -- §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)); } }