Skip to main content

codex_protocol/
config_types.rs

1use codex_utils_absolute_path::AbsolutePathBuf;
2use schemars::JsonSchema;
3use schemars::r#gen::SchemaGenerator;
4use schemars::schema::InstanceType;
5use schemars::schema::Metadata;
6use schemars::schema::Schema;
7use schemars::schema::SchemaObject;
8use serde::Deserialize;
9use serde::Serialize;
10use serde_json::Value;
11use std::collections::HashMap;
12use std::fmt;
13use std::num::NonZeroU64;
14use std::ops::Deref;
15use std::str::FromStr;
16use std::time::Duration;
17use strum_macros::Display;
18use strum_macros::EnumIter;
19use ts_rs::TS;
20use wildmatch::WildMatchPattern;
21
22use crate::openai_models::ReasoningEffort;
23
24/// Selects which part of the active context is charged against
25/// `model_auto_compact_token_limit`.
26#[derive(
27    Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS,
28)]
29#[serde(rename_all = "snake_case")]
30#[strum(serialize_all = "snake_case")]
31pub enum AutoCompactTokenLimitScope {
32    /// Count the full active context against the limit.
33    #[default]
34    Total,
35    /// Count sampled output and later growth after the carried window prefix.
36    BodyAfterPrefix,
37}
38
39/// A summary of the reasoning performed by the model. This can be useful for
40/// debugging and understanding the model's reasoning process.
41/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries
42#[derive(
43    Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS,
44)]
45#[serde(rename_all = "lowercase")]
46#[strum(serialize_all = "lowercase")]
47pub enum ReasoningSummary {
48    #[default]
49    Auto,
50    Concise,
51    Detailed,
52    /// Option to disable reasoning summaries.
53    None,
54}
55
56/// Controls output length/detail on GPT-5 models via the Responses API.
57/// Serialized with lowercase values to match the OpenAI API.
58#[derive(
59    Hash,
60    Debug,
61    Serialize,
62    Deserialize,
63    Default,
64    Clone,
65    Copy,
66    PartialEq,
67    Eq,
68    Display,
69    JsonSchema,
70    TS,
71)]
72#[serde(rename_all = "lowercase")]
73#[strum(serialize_all = "lowercase")]
74pub enum Verbosity {
75    Low,
76    #[default]
77    Medium,
78    High,
79}
80
81#[derive(
82    Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, JsonSchema, TS,
83)]
84#[serde(rename_all = "kebab-case")]
85#[strum(serialize_all = "kebab-case")]
86pub enum SandboxMode {
87    #[serde(rename = "read-only")]
88    #[default]
89    ReadOnly,
90
91    #[serde(rename = "workspace-write")]
92    WorkspaceWrite,
93
94    #[serde(rename = "danger-full-access")]
95    DangerFullAccess,
96}
97
98/// Validated plain profile-v2 name used to select `$CODEX_HOME/<name>.config.toml`.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct ProfileV2Name(String);
101
102impl ProfileV2Name {
103    pub fn as_str(&self) -> &str {
104        &self.0
105    }
106}
107
108#[derive(Debug, PartialEq, Eq)]
109pub struct ProfileV2NameParseError {
110    value: String,
111}
112
113impl fmt::Display for ProfileV2NameParseError {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        write!(
116            f,
117            "invalid --profile value `{}`; pass a plain name such as `work`",
118            self.value
119        )
120    }
121}
122
123impl std::error::Error for ProfileV2NameParseError {}
124
125impl FromStr for ProfileV2Name {
126    type Err = ProfileV2NameParseError;
127
128    fn from_str(value: &str) -> Result<Self, Self::Err> {
129        if value.is_empty()
130            || !value
131                .bytes()
132                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
133        {
134            return Err(ProfileV2NameParseError {
135                value: value.to_string(),
136            });
137        }
138
139        Ok(Self(value.to_string()))
140    }
141}
142
143impl Deref for ProfileV2Name {
144    type Target = str;
145
146    fn deref(&self) -> &Self::Target {
147        self.as_str()
148    }
149}
150
151impl fmt::Display for ProfileV2Name {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        self.0.fmt(f)
154    }
155}
156
157#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Display, TS)]
158#[strum(serialize_all = "snake_case")]
159#[ts(type = r#""user" | "auto_review" | "guardian_subagent""#)]
160/// Configures who approval requests are routed to for review. Examples
161/// include sandbox escapes, blocked network access, MCP approval prompts, and
162/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully
163/// prompted subagent to gather relevant context and apply a risk-based
164/// decision framework before approving or denying the request.
165pub enum ApprovalsReviewer {
166    #[default]
167    #[serde(rename = "user")]
168    User,
169    #[serde(rename = "auto_review", alias = "guardian_subagent")]
170    #[strum(serialize = "auto_review")]
171    AutoReview,
172}
173
174impl JsonSchema for ApprovalsReviewer {
175    fn schema_name() -> String {
176        "ApprovalsReviewer".to_string()
177    }
178
179    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
180        string_enum_schema_with_description(
181            &["user", "auto_review", "guardian_subagent"],
182            "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
183        )
184    }
185}
186
187#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
188#[serde(rename_all = "kebab-case")]
189pub enum ShellEnvironmentPolicyInherit {
190    /// "Core" environment variables for the platform. On UNIX, this would
191    /// include HOME, LOGNAME, PATH, SHELL, and USER, among others.
192    Core,
193
194    /// Inherits the full environment from the parent process.
195    #[default]
196    All,
197
198    /// Do not inherit any environment variables from the parent process.
199    None,
200}
201
202/// Assigns a shell environment variable pattern to the include-only or exclude
203/// set. Includes do not re-add variables removed by another exclude pattern.
204#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
205#[serde(rename_all = "lowercase")]
206#[ts(export_to = "v2/")]
207pub enum ShellEnvironmentPolicyFilter {
208    Include,
209    Exclude,
210}
211
212pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>;
213
214/// Deriving the `env` based on this policy works as follows:
215/// 1. Create an initial map based on the `inherit` policy.
216/// 2. If `ignore_default_excludes` is false, filter the map using the default
217///    exclude pattern(s), which are: `"*KEY*"`, `"*SECRET*"`, and `"*TOKEN*"`.
218/// 3. If `exclude` is not empty, filter the map using the provided patterns.
219/// 4. Insert any entries from `r#set` into the map.
220/// 5. If non-empty, filter the map using the `include_only` patterns.
221#[derive(Debug, Clone, PartialEq)]
222pub struct ShellEnvironmentPolicy {
223    /// Starting point when building the environment.
224    pub inherit: ShellEnvironmentPolicyInherit,
225
226    /// True to skip the check to exclude default environment variables that
227    /// contain "KEY", "SECRET", or "TOKEN" in their name. Defaults to true.
228    pub ignore_default_excludes: bool,
229
230    /// Environment variable names to exclude from the environment.
231    pub exclude: Vec<EnvironmentVariablePattern>,
232
233    /// (key, value) pairs to insert in the environment.
234    pub r#set: HashMap<String, String>,
235
236    /// Environment variable names to retain in the environment.
237    pub include_only: Vec<EnvironmentVariablePattern>,
238
239    /// If true, the shell profile will be used to run the command.
240    pub use_profile: bool,
241}
242
243impl Default for ShellEnvironmentPolicy {
244    fn default() -> Self {
245        Self {
246            inherit: ShellEnvironmentPolicyInherit::All,
247            ignore_default_excludes: true,
248            exclude: Vec::new(),
249            r#set: HashMap::new(),
250            include_only: Vec::new(),
251            use_profile: false,
252        }
253    }
254}
255
256fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema {
257    let mut schema = SchemaObject {
258        instance_type: Some(InstanceType::String.into()),
259        metadata: Some(Box::new(Metadata {
260            description: Some(description.to_string()),
261            ..Default::default()
262        })),
263        ..Default::default()
264    };
265    schema.enum_values = Some(
266        values
267            .iter()
268            .map(|value| Value::String((*value).to_string()))
269            .collect(),
270    );
271    Schema::Object(schema)
272}
273
274#[derive(
275    Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Display, JsonSchema, TS,
276)]
277#[serde(rename_all = "kebab-case")]
278#[strum(serialize_all = "kebab-case")]
279pub enum WindowsSandboxLevel {
280    #[default]
281    Disabled,
282    RestrictedToken,
283    Elevated,
284}
285
286#[derive(
287    Debug,
288    Serialize,
289    Deserialize,
290    Clone,
291    Copy,
292    PartialEq,
293    Eq,
294    Display,
295    JsonSchema,
296    TS,
297    PartialOrd,
298    Ord,
299    EnumIter,
300)]
301#[serde(rename_all = "lowercase")]
302#[strum(serialize_all = "lowercase")]
303pub enum Personality {
304    None,
305    Friendly,
306    Pragmatic,
307}
308
309/// Controls the effective multi-agent delegation instructions for a turn. `custom` means the
310/// configured mode hint defines the policy instead of a built-in policy.
311#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Display, JsonSchema, TS, Default)]
312#[serde(rename_all = "camelCase", from = "MultiAgentModeWire")]
313#[ts(rename_all = "camelCase")]
314#[strum(serialize_all = "camelCase")]
315pub enum MultiAgentMode {
316    Custom(String),
317    #[default]
318    ExplicitRequestOnly,
319    Proactive,
320}
321
322#[derive(Deserialize)]
323#[serde(rename_all = "camelCase")]
324enum MultiAgentModeWire {
325    None,
326    Custom(String),
327    ExplicitRequestOnly,
328    Proactive,
329}
330
331impl From<MultiAgentModeWire> for MultiAgentMode {
332    fn from(value: MultiAgentModeWire) -> Self {
333        match value {
334            MultiAgentModeWire::None => Self::Custom(String::new()),
335            MultiAgentModeWire::Custom(hint_text) => Self::Custom(hint_text),
336            MultiAgentModeWire::ExplicitRequestOnly => Self::ExplicitRequestOnly,
337            MultiAgentModeWire::Proactive => Self::Proactive,
338        }
339    }
340}
341
342#[derive(
343    Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default,
344)]
345#[serde(rename_all = "snake_case")]
346#[strum(serialize_all = "snake_case")]
347pub enum WebSearchMode {
348    Disabled,
349    #[default]
350    Cached,
351    Indexed,
352    Live,
353}
354
355#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
356#[serde(rename_all = "lowercase")]
357#[strum(serialize_all = "lowercase")]
358pub enum WebSearchContextSize {
359    Low,
360    Medium,
361    High,
362}
363
364#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
365#[schemars(deny_unknown_fields)]
366pub struct WebSearchLocation {
367    pub country: Option<String>,
368    pub region: Option<String>,
369    pub city: Option<String>,
370    pub timezone: Option<String>,
371}
372
373impl WebSearchLocation {
374    pub fn merge(&self, other: &Self) -> Self {
375        Self {
376            country: other.country.clone().or_else(|| self.country.clone()),
377            region: other.region.clone().or_else(|| self.region.clone()),
378            city: other.city.clone().or_else(|| self.city.clone()),
379            timezone: other.timezone.clone().or_else(|| self.timezone.clone()),
380        }
381    }
382}
383
384#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
385#[schemars(deny_unknown_fields)]
386pub struct WebSearchToolConfig {
387    pub context_size: Option<WebSearchContextSize>,
388    pub allowed_domains: Option<Vec<String>>,
389    pub location: Option<WebSearchLocation>,
390}
391
392impl WebSearchToolConfig {
393    pub fn merge(&self, other: &Self) -> Self {
394        Self {
395            context_size: other.context_size.or(self.context_size),
396            allowed_domains: other
397                .allowed_domains
398                .clone()
399                .or_else(|| self.allowed_domains.clone()),
400            location: match (&self.location, &other.location) {
401                (Some(location), Some(other_location)) => Some(location.merge(other_location)),
402                (Some(location), None) => Some(location.clone()),
403                (None, Some(other_location)) => Some(other_location.clone()),
404                (None, None) => None,
405            },
406        }
407    }
408}
409
410#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
411#[schemars(deny_unknown_fields)]
412pub struct WebSearchFilters {
413    pub allowed_domains: Option<Vec<String>>,
414}
415
416#[derive(
417    Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Display, JsonSchema, TS,
418)]
419#[serde(rename_all = "lowercase")]
420#[strum(serialize_all = "lowercase")]
421pub enum WebSearchUserLocationType {
422    #[default]
423    Approximate,
424}
425
426#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
427#[schemars(deny_unknown_fields)]
428pub struct WebSearchUserLocation {
429    #[serde(default)]
430    pub r#type: WebSearchUserLocationType,
431    pub country: Option<String>,
432    pub region: Option<String>,
433    pub city: Option<String>,
434    pub timezone: Option<String>,
435}
436
437#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
438#[schemars(deny_unknown_fields)]
439pub struct WebSearchConfig {
440    pub filters: Option<WebSearchFilters>,
441    pub user_location: Option<WebSearchUserLocation>,
442    pub search_context_size: Option<WebSearchContextSize>,
443}
444
445impl From<WebSearchLocation> for WebSearchUserLocation {
446    fn from(location: WebSearchLocation) -> Self {
447        Self {
448            r#type: WebSearchUserLocationType::Approximate,
449            country: location.country,
450            region: location.region,
451            city: location.city,
452            timezone: location.timezone,
453        }
454    }
455}
456
457impl From<WebSearchToolConfig> for WebSearchConfig {
458    fn from(config: WebSearchToolConfig) -> Self {
459        Self {
460            filters: config
461                .allowed_domains
462                .map(|allowed_domains| WebSearchFilters {
463                    allowed_domains: Some(allowed_domains),
464                }),
465            user_location: config.location.map(Into::into),
466            search_context_size: config.context_size,
467        }
468    }
469}
470
471#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
472#[serde(rename_all = "lowercase")]
473#[strum(serialize_all = "lowercase")]
474pub enum ServiceTier {
475    Fast,
476    Flex,
477}
478
479/// Request/config sentinel for explicit standard routing.
480///
481/// This is not a catalog service tier id. It means the user intentionally
482/// selected no service tier, so model catalog defaults should not apply.
483pub const SERVICE_TIER_DEFAULT_REQUEST_VALUE: &str = "default";
484
485impl ServiceTier {
486    pub const fn request_value(self) -> &'static str {
487        match self {
488            Self::Fast => "priority",
489            Self::Flex => "flex",
490        }
491    }
492
493    pub fn from_request_value(value: &str) -> Option<Self> {
494        match value {
495            "fast" | "priority" => Some(Self::Fast),
496            "flex" => Some(Self::Flex),
497            _ => None,
498        }
499    }
500}
501
502#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
503#[serde(rename_all = "lowercase")]
504#[strum(serialize_all = "lowercase")]
505pub enum ForcedLoginMethod {
506    Chatgpt,
507    Api,
508}
509
510const DEFAULT_PROVIDER_AUTH_TIMEOUT_MS: u64 = 5_000;
511const DEFAULT_PROVIDER_AUTH_REFRESH_INTERVAL_MS: u64 = 300_000;
512
513/// Configuration for obtaining a provider bearer token from a command.
514#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
515#[schemars(deny_unknown_fields)]
516pub struct ModelProviderAuthInfo {
517    /// Command to execute. Bare names are resolved via `PATH`; paths are resolved against `cwd`.
518    pub command: String,
519
520    /// Command arguments.
521    #[serde(default)]
522    pub args: Vec<String>,
523
524    /// Maximum time to wait for the token command to exit successfully.
525    #[serde(default = "default_provider_auth_timeout_ms")]
526    pub timeout_ms: NonZeroU64,
527
528    /// Maximum age for the cached token before rerunning the command.
529    /// Set to `0` to disable proactive refresh and only rerun after a 401 retry path.
530    #[serde(default = "default_provider_auth_refresh_interval_ms")]
531    pub refresh_interval_ms: u64,
532
533    /// Working directory used when running the token command.
534    #[serde(default = "default_provider_auth_cwd")]
535    #[schemars(skip_serializing_if = "is_default_provider_auth_cwd")]
536    pub cwd: AbsolutePathBuf,
537}
538
539impl ModelProviderAuthInfo {
540    pub fn timeout(&self) -> Duration {
541        Duration::from_millis(self.timeout_ms.get())
542    }
543
544    pub fn refresh_interval(&self) -> Option<Duration> {
545        NonZeroU64::new(self.refresh_interval_ms).map(|value| Duration::from_millis(value.get()))
546    }
547}
548
549fn default_provider_auth_timeout_ms() -> NonZeroU64 {
550    non_zero_u64(
551        DEFAULT_PROVIDER_AUTH_TIMEOUT_MS,
552        "model_providers.<id>.auth.timeout_ms",
553    )
554}
555
556fn default_provider_auth_refresh_interval_ms() -> u64 {
557    DEFAULT_PROVIDER_AUTH_REFRESH_INTERVAL_MS
558}
559
560fn non_zero_u64(value: u64, field_name: &str) -> NonZeroU64 {
561    match NonZeroU64::new(value) {
562        Some(value) => value,
563        None => panic!("{field_name} must be non-zero"),
564    }
565}
566
567fn default_provider_auth_cwd() -> AbsolutePathBuf {
568    let deserializer = serde::de::value::StrDeserializer::<serde::de::value::Error>::new(".");
569    if let Ok(cwd) = AbsolutePathBuf::deserialize(deserializer) {
570        return cwd;
571    }
572
573    match AbsolutePathBuf::current_dir() {
574        Ok(cwd) => cwd,
575        Err(err) => panic!("provider auth cwd must resolve: {err}"),
576    }
577}
578
579fn is_default_provider_auth_cwd(path: &AbsolutePathBuf) -> bool {
580    path == &default_provider_auth_cwd()
581}
582
583/// Represents the trust level for a project directory.
584/// This determines the approval policy and sandbox mode applied.
585#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
586#[serde(rename_all = "lowercase")]
587#[strum(serialize_all = "lowercase")]
588pub enum TrustLevel {
589    Trusted,
590    Untrusted,
591}
592
593/// Controls whether the TUI uses the terminal's alternate screen buffer.
594///
595/// - `auto` (default): Use alternate screen mode.
596/// - `always`: Always use alternate screen mode.
597/// - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback.
598///
599/// The CLI flag `--no-alt-screen` can override this setting at runtime.
600#[derive(
601    Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS,
602)]
603#[serde(rename_all = "lowercase")]
604#[strum(serialize_all = "lowercase")]
605pub enum AltScreenMode {
606    /// Use alternate screen mode.
607    #[default]
608    Auto,
609    /// Always use alternate screen mode.
610    Always,
611    /// Never use alternate screen (inline mode only).
612    Never,
613}
614
615/// Initial collaboration mode to use when the TUI starts.
616#[derive(
617    Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, TS, Default,
618)]
619#[serde(rename_all = "snake_case")]
620pub enum ModeKind {
621    Plan,
622    #[default]
623    #[serde(
624        alias = "code",
625        alias = "pair_programming",
626        alias = "execute",
627        alias = "custom"
628    )]
629    Default,
630    #[doc(hidden)]
631    #[serde(skip_serializing, skip_deserializing)]
632    #[schemars(skip)]
633    #[ts(skip)]
634    PairProgramming,
635    #[doc(hidden)]
636    #[serde(skip_serializing, skip_deserializing)]
637    #[schemars(skip)]
638    #[ts(skip)]
639    Execute,
640}
641
642pub const TUI_VISIBLE_COLLABORATION_MODES: [ModeKind; 2] = [ModeKind::Default, ModeKind::Plan];
643
644impl ModeKind {
645    pub const fn display_name(self) -> &'static str {
646        match self {
647            Self::Plan => "Plan",
648            Self::Default => "Default",
649            Self::PairProgramming => "Pair Programming",
650            Self::Execute => "Execute",
651        }
652    }
653
654    pub const fn is_tui_visible(self) -> bool {
655        matches!(self, Self::Plan | Self::Default)
656    }
657
658    pub const fn allows_request_user_input(self) -> bool {
659        matches!(self, Self::Plan)
660    }
661}
662
663/// Collaboration mode for a Codex session.
664#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)]
665#[serde(rename_all = "lowercase")]
666pub struct CollaborationMode {
667    pub mode: ModeKind,
668    pub settings: Settings,
669}
670
671impl CollaborationMode {
672    /// Returns a reference to the settings.
673    fn settings_ref(&self) -> &Settings {
674        &self.settings
675    }
676
677    pub fn model(&self) -> &str {
678        self.settings_ref().model.as_str()
679    }
680
681    pub fn reasoning_effort(&self) -> Option<ReasoningEffort> {
682        self.settings_ref().reasoning_effort.clone()
683    }
684
685    /// Updates the collaboration mode with new model and/or effort values.
686    ///
687    /// - `model`: `Some(s)` to update the model, `None` to keep the current model
688    /// - `effort`: `Some(Some(e))` to set effort to `e`, `Some(None)` to clear effort, `None` to keep current effort
689    /// - `developer_instructions`: `Some(Some(s))` to set instructions, `Some(None)` to clear them, `None` to keep current
690    ///
691    /// Returns a new `CollaborationMode` with updated values, preserving the mode.
692    pub fn with_updates(
693        &self,
694        model: Option<String>,
695        effort: Option<Option<ReasoningEffort>>,
696        developer_instructions: Option<Option<String>>,
697    ) -> Self {
698        let settings = self.settings_ref();
699        let updated_settings = Settings {
700            model: model.unwrap_or_else(|| settings.model.clone()),
701            reasoning_effort: effort.unwrap_or_else(|| settings.reasoning_effort.clone()),
702            developer_instructions: developer_instructions
703                .unwrap_or_else(|| settings.developer_instructions.clone()),
704        };
705
706        CollaborationMode {
707            mode: self.mode,
708            settings: updated_settings,
709        }
710    }
711
712    /// Applies a mask to this collaboration mode, returning a new collaboration mode
713    /// with the mask values applied. Fields in the mask that are `Some` will override
714    /// the corresponding fields, while `None` values will preserve the original values.
715    ///
716    /// The `name` field in the mask is ignored as it's metadata for the mask itself.
717    pub fn apply_mask(&self, mask: &CollaborationModeMask) -> Self {
718        let settings = self.settings_ref();
719        CollaborationMode {
720            mode: mask.mode.unwrap_or(self.mode),
721            settings: Settings {
722                model: mask.model.clone().unwrap_or_else(|| settings.model.clone()),
723                reasoning_effort: mask
724                    .reasoning_effort
725                    .clone()
726                    .unwrap_or_else(|| settings.reasoning_effort.clone()),
727                developer_instructions: mask
728                    .developer_instructions
729                    .clone()
730                    .unwrap_or_else(|| settings.developer_instructions.clone()),
731            },
732        }
733    }
734}
735
736/// Settings for a collaboration mode.
737#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)]
738pub struct Settings {
739    pub model: String,
740    pub reasoning_effort: Option<ReasoningEffort>,
741    pub developer_instructions: Option<String>,
742}
743
744/// A mask for collaboration mode settings, allowing partial updates.
745/// All fields except `name` are optional, enabling selective updates.
746#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)]
747pub struct CollaborationModeMask {
748    pub name: String,
749    pub mode: Option<ModeKind>,
750    pub model: Option<String>,
751    pub reasoning_effort: Option<Option<ReasoningEffort>>,
752    pub developer_instructions: Option<Option<String>>,
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use pretty_assertions::assert_eq;
759
760    #[test]
761    fn apply_mask_can_clear_optional_fields() {
762        let mode = CollaborationMode {
763            mode: ModeKind::Default,
764            settings: Settings {
765                model: "gpt-5.2-codex".to_string(),
766                reasoning_effort: Some(ReasoningEffort::High),
767                developer_instructions: Some("stay focused".to_string()),
768            },
769        };
770        let mask = CollaborationModeMask {
771            name: "Clear".to_string(),
772            mode: None,
773            model: None,
774            reasoning_effort: Some(None),
775            developer_instructions: Some(None),
776        };
777
778        let expected = CollaborationMode {
779            mode: ModeKind::Default,
780            settings: Settings {
781                model: "gpt-5.2-codex".to_string(),
782                reasoning_effort: None,
783                developer_instructions: None,
784            },
785        };
786        assert_eq!(expected, mode.apply_mask(&mask));
787    }
788
789    #[test]
790    fn mode_kind_deserializes_alias_values_to_default() {
791        for alias in ["code", "pair_programming", "execute", "custom"] {
792            let json = format!("\"{alias}\"");
793            let mode: ModeKind = serde_json::from_str(&json).expect("deserialize mode");
794            assert_eq!(ModeKind::Default, mode);
795        }
796    }
797
798    #[test]
799    fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() {
800        assert_eq!(ApprovalsReviewer::User.to_string(), "user");
801        assert_eq!(
802            serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"),
803            "\"user\""
804        );
805        assert_eq!(
806            serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"),
807            "\"auto_review\""
808        );
809
810        for value in ["user", "auto_review", "guardian_subagent"] {
811            let json = format!("\"{value}\"");
812            let reviewer: ApprovalsReviewer =
813                serde_json::from_str(&json).expect("deserialize reviewer");
814            let expected = if value == "user" {
815                ApprovalsReviewer::User
816            } else {
817                ApprovalsReviewer::AutoReview
818            };
819            assert_eq!(expected, reviewer);
820        }
821    }
822
823    #[test]
824    fn profile_v2_name_rejects_paths_and_empty_names() {
825        assert_eq!(
826            ProfileV2Name::from_str("../foo"),
827            Err(ProfileV2NameParseError {
828                value: "../foo".to_string(),
829            }),
830            "dots and slashes are disallowed to prevent reading arbitrary files"
831        );
832        assert_eq!(
833            ProfileV2Name::from_str(""),
834            Err(ProfileV2NameParseError {
835                value: String::new(),
836            }),
837            "profile name cannot be empty"
838        );
839    }
840
841    #[test]
842    fn tui_visible_collaboration_modes_match_mode_kind_visibility() {
843        let expected = [ModeKind::Default, ModeKind::Plan];
844        assert_eq!(expected, TUI_VISIBLE_COLLABORATION_MODES);
845
846        for mode in TUI_VISIBLE_COLLABORATION_MODES {
847            assert!(mode.is_tui_visible());
848        }
849
850        assert!(!ModeKind::PairProgramming.is_tui_visible());
851        assert!(!ModeKind::Execute.is_tui_visible());
852    }
853
854    #[test]
855    fn web_search_location_merge_prefers_overlay_values() {
856        let base = WebSearchLocation {
857            country: Some("US".to_string()),
858            region: Some("CA".to_string()),
859            city: None,
860            timezone: Some("America/Los_Angeles".to_string()),
861        };
862        let overlay = WebSearchLocation {
863            country: None,
864            region: Some("WA".to_string()),
865            city: Some("Seattle".to_string()),
866            timezone: None,
867        };
868
869        let expected = WebSearchLocation {
870            country: Some("US".to_string()),
871            region: Some("WA".to_string()),
872            city: Some("Seattle".to_string()),
873            timezone: Some("America/Los_Angeles".to_string()),
874        };
875
876        assert_eq!(expected, base.merge(&overlay));
877    }
878
879    #[test]
880    fn web_search_tool_config_merge_prefers_overlay_values() {
881        let base = WebSearchToolConfig {
882            context_size: Some(WebSearchContextSize::Low),
883            allowed_domains: Some(vec!["openai.com".to_string()]),
884            location: Some(WebSearchLocation {
885                country: Some("US".to_string()),
886                region: Some("CA".to_string()),
887                city: None,
888                timezone: Some("America/Los_Angeles".to_string()),
889            }),
890        };
891        let overlay = WebSearchToolConfig {
892            context_size: Some(WebSearchContextSize::High),
893            allowed_domains: None,
894            location: Some(WebSearchLocation {
895                country: None,
896                region: Some("WA".to_string()),
897                city: Some("Seattle".to_string()),
898                timezone: None,
899            }),
900        };
901
902        let expected = WebSearchToolConfig {
903            context_size: Some(WebSearchContextSize::High),
904            allowed_domains: Some(vec!["openai.com".to_string()]),
905            location: Some(WebSearchLocation {
906                country: Some("US".to_string()),
907                region: Some("WA".to_string()),
908                city: Some("Seattle".to_string()),
909                timezone: Some("America/Los_Angeles".to_string()),
910            }),
911        };
912
913        assert_eq!(expected, base.merge(&overlay));
914    }
915}