Skip to main content

forge_primitives/
permission.rs

1//! Canonical `PermissionMode` enum, used by:
2//!  - `forge_sdk::Options` builder for `--permission-mode` CLI argv.
3//!  - `forge_agent::state` for runtime mode tracking.
4//!  - a view's permission-mode chip and settings.
5
6use serde::{Deserialize, Serialize};
7
8/// Which permission flow the `claude` binary should use for tool
9/// invocations. Mirrors the upstream CLI's six-variant set.
10///
11/// Variant `Ask` maps to wire `"default"`; the rest match wire strings
12/// 1:1 via `#[serde(rename = ...)]`:
13///  - `Ask` -> "default"
14///  - `AcceptEdits` -> "acceptEdits"
15///  - `Plan` -> "plan"
16///  - `DontAsk` -> "dontAsk"
17///  - `Auto` -> "auto"
18///  - `BypassPermissions` -> "bypassPermissions"
19///
20/// `from_wire` also accepts the snake-case aliases the upstream
21/// Python SDK and earlier CLIs emitted (`accept_edits`, `dont_ask`,
22/// `bypass_permissions`, `ask`, `deny`).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub enum PermissionMode {
25    #[serde(rename = "default")]
26    Ask,
27    #[serde(rename = "acceptEdits")]
28    AcceptEdits,
29    #[serde(rename = "plan")]
30    Plan,
31    #[serde(rename = "dontAsk")]
32    DontAsk,
33    #[serde(rename = "auto")]
34    Auto,
35    #[serde(rename = "bypassPermissions")]
36    BypassPermissions,
37}
38
39impl PermissionMode {
40    /// Every wire string [`Self::from_wire`] accepts, quoted for load
41    /// errors naming the accepted set.
42    pub const ACCEPTED: &'static str = "\"default\", \"ask\", \"acceptEdits\", \"accept_edits\", \
43         \"plan\", \"dontAsk\", \"dont_ask\", \"deny\", \"auto\", \"bypassPermissions\", \
44         \"bypass_permissions\"";
45
46    /// The string the `claude` binary expects via `--permission-mode`
47    /// and the JSON wire string.
48    pub fn as_wire(self) -> &'static str {
49        match self {
50            Self::Ask => "default",
51            Self::AcceptEdits => "acceptEdits",
52            Self::Plan => "plan",
53            Self::DontAsk => "dontAsk",
54            Self::Auto => "auto",
55            Self::BypassPermissions => "bypassPermissions",
56        }
57    }
58
59    /// Alias for [`Self::as_wire`]. The SDK options builder calls
60    /// this to drive `--permission-mode <arg>` on the CLI.
61    pub fn as_cli_arg(self) -> &'static str {
62        self.as_wire()
63    }
64
65    /// Parse a wire string into a `PermissionMode`. Accepts both the
66    /// canonical camelCase forms (`"acceptEdits"`, `"dontAsk"`,
67    /// `"bypassPermissions"`) and the snake_case + legacy aliases
68    /// (`"accept_edits"`, `"dont_ask"`, `"bypass_permissions"`,
69    /// `"ask"` -> Ask, `"deny"` -> DontAsk).
70    pub fn from_wire(s: &str) -> Option<Self> {
71        Some(match s {
72            "default" | "ask" => Self::Ask,
73            "acceptEdits" | "accept_edits" => Self::AcceptEdits,
74            "plan" => Self::Plan,
75            "dontAsk" | "dont_ask" | "deny" => Self::DontAsk,
76            "auto" => Self::Auto,
77            "bypassPermissions" | "bypass_permissions" => Self::BypassPermissions,
78            _ => return None,
79        })
80    }
81
82    /// Human-readable display name (used by a view's mode chip
83    /// + settings UI).
84    pub fn display_name(self) -> &'static str {
85        match self {
86            Self::Ask => "Ask",
87            Self::AcceptEdits => "Accept Edits",
88            Self::Plan => "Plan",
89            Self::DontAsk => "Don't Ask",
90            Self::Auto => "Auto",
91            Self::BypassPermissions => "Bypass Permissions",
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn permission_mode_round_trips_through_wire() {
102        for mode in [
103            PermissionMode::Ask,
104            PermissionMode::AcceptEdits,
105            PermissionMode::Plan,
106            PermissionMode::DontAsk,
107            PermissionMode::Auto,
108            PermissionMode::BypassPermissions,
109        ] {
110            assert_eq!(PermissionMode::from_wire(mode.as_wire()), Some(mode));
111        }
112    }
113
114    #[test]
115    fn permission_mode_aliases() {
116        assert_eq!(PermissionMode::from_wire("ask"), Some(PermissionMode::Ask));
117        assert_eq!(PermissionMode::from_wire("accept_edits"), Some(PermissionMode::AcceptEdits));
118        assert_eq!(PermissionMode::from_wire("dont_ask"), Some(PermissionMode::DontAsk));
119        assert_eq!(PermissionMode::from_wire("deny"), Some(PermissionMode::DontAsk));
120        assert_eq!(
121            PermissionMode::from_wire("bypass_permissions"),
122            Some(PermissionMode::BypassPermissions)
123        );
124    }
125
126    #[test]
127    fn permission_mode_as_cli_arg_matches_as_wire() {
128        assert_eq!(PermissionMode::Ask.as_cli_arg(), "default");
129        assert_eq!(PermissionMode::DontAsk.as_cli_arg(), "dontAsk");
130    }
131
132    #[test]
133    fn permission_mode_serde_round_trips() {
134        let value = serde_json::to_string(&PermissionMode::DontAsk).expect("serialize");
135        assert_eq!(value, "\"dontAsk\"");
136        let decoded: PermissionMode = serde_json::from_str("\"acceptEdits\"").expect("deserialize");
137        assert_eq!(decoded, PermissionMode::AcceptEdits);
138    }
139
140    #[test]
141    fn accepted_names_every_wire_form_from_wire_parses() {
142        for wire in [
143            "default",
144            "ask",
145            "acceptEdits",
146            "accept_edits",
147            "plan",
148            "dontAsk",
149            "dont_ask",
150            "deny",
151            "auto",
152            "bypassPermissions",
153            "bypass_permissions",
154        ] {
155            assert!(
156                PermissionMode::ACCEPTED.contains(format!("\"{wire}\"").as_str()),
157                "ACCEPTED must name \"{wire}\", or the load error omits an accepted value",
158            );
159        }
160        // Read back out of ACCEPTED so a value that from_wire no longer
161        // parses cannot survive in the load error's list.
162        for token in PermissionMode::ACCEPTED.split(", ") {
163            let wire = token.trim_matches('"');
164            assert!(
165                PermissionMode::from_wire(wire).is_some(),
166                "from_wire must accept \"{wire}\" or ACCEPTED lists a dead value",
167            );
168        }
169    }
170}