Skip to main content

whycodes_import/
types.rs

1//! Shared types for settings import.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use whycodes_config::{HookConfig, McpServerConfig};
7use whycodes_core::types::PermissionAction;
8
9/// A competing coding agent whose user-level settings we know how to read.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum Product {
12    Claude,
13    OpenCode,
14    Grok,
15    Codex,
16}
17
18impl Product {
19    pub fn parse(s: &str) -> Option<Self> {
20        match s.trim().to_ascii_lowercase().as_str() {
21            "claude" | "claude-code" => Some(Self::Claude),
22            "opencode" => Some(Self::OpenCode),
23            "grok" | "grok-build" => Some(Self::Grok),
24            "codex" => Some(Self::Codex),
25            _ => None,
26        }
27    }
28
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::Claude => "claude",
32            Self::OpenCode => "opencode",
33            Self::Grok => "grok",
34            Self::Codex => "codex",
35        }
36    }
37
38    pub fn label(self) -> &'static str {
39        match self {
40            Self::Claude => "Claude Code",
41            Self::OpenCode => "OpenCode",
42            Self::Grok => "Grok Build",
43            Self::Codex => "Codex CLI",
44        }
45    }
46}
47
48/// Consent state of one discovered file.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum SourceState {
51    /// Present; never approved nor denied — contents not yet read.
52    New,
53    /// The user approved this exact path; it may be read and imported.
54    Approved,
55    /// The user declined this exact path; leave it alone.
56    Denied,
57    /// The path is a symlink — refused regardless of consent.
58    Symlink,
59}
60
61/// One located settings file.
62#[derive(Debug, Clone)]
63pub struct FoundSource {
64    pub product: Product,
65    pub rel_path: &'static str,
66    pub path: PathBuf,
67    pub state: SourceState,
68}
69
70/// Parsed payload from one source file. No secrets; MCP env values are copied
71/// as written (same as a user pasting config.toml).
72#[derive(Debug, Clone)]
73pub struct Extracted {
74    pub product: Product,
75    pub path: PathBuf,
76    pub mcp: Vec<(String, McpServerConfig)>,
77    pub permission: HashMap<String, PermissionAction>,
78    pub hooks: Vec<HookConfig>,
79    pub skipped: Vec<String>,
80}
81
82impl Default for Extracted {
83    fn default() -> Self {
84        Self {
85            product: Product::Claude,
86            path: PathBuf::new(),
87            mcp: Vec::new(),
88            permission: HashMap::new(),
89            hooks: Vec::new(),
90            skipped: Vec::new(),
91        }
92    }
93}
94
95impl Extracted {
96    pub fn is_empty(&self) -> bool {
97        self.mcp.is_empty() && self.permission.is_empty() && self.hooks.is_empty()
98    }
99
100    pub fn counts_label(&self) -> String {
101        let mut parts = Vec::new();
102        if !self.mcp.is_empty() {
103            parts.push(format!("MCP×{}", self.mcp.len()));
104        }
105        if !self.permission.is_empty() {
106            parts.push(format!("permission×{}", self.permission.len()));
107        }
108        if !self.hooks.is_empty() {
109            parts.push(format!("hooks×{}", self.hooks.len()));
110        }
111        if parts.is_empty() {
112            "nothing mapped".into()
113        } else {
114            parts.join("  ")
115        }
116    }
117}
118
119/// What would be written into WhyCodes config.
120#[derive(Debug, Clone, Default)]
121pub struct ImportPlan {
122    pub mcp_add: Vec<(String, McpServerConfig)>,
123    pub mcp_skip: Vec<(String, String)>,
124    pub permission_add: Vec<(String, PermissionAction)>,
125    pub permission_skip: Vec<(String, String)>,
126    pub hooks_add: Vec<HookConfig>,
127    pub hooks_skip: Vec<String>,
128    pub warnings: Vec<String>,
129}
130
131/// Kind of a user-selectable import row (MCP / permission / hook).
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum ImportItemKind {
134    Mcp,
135    Permission,
136    Hook,
137}
138
139/// One addable row shown in the TUI picker or CLI item prompt.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct ImportItem {
142    pub kind: ImportItemKind,
143    pub label: String,
144    pub detail: String,
145}
146
147impl ImportPlan {
148    pub fn is_empty(&self) -> bool {
149        self.mcp_add.is_empty() && self.permission_add.is_empty() && self.hooks_add.is_empty()
150    }
151
152    /// Rows the user can check/uncheck, in apply order (MCP, permission, hooks).
153    pub fn selectable_items(&self) -> Vec<ImportItem> {
154        let mut items = Vec::with_capacity(
155            self.mcp_add.len() + self.permission_add.len() + self.hooks_add.len(),
156        );
157        for (name, server) in &self.mcp_add {
158            let detail = server
159                .command
160                .clone()
161                .or_else(|| server.url.clone())
162                .unwrap_or_default();
163            items.push(ImportItem {
164                kind: ImportItemKind::Mcp,
165                label: format!("MCP `{name}`"),
166                detail,
167            });
168        }
169        for (tool, action) in &self.permission_add {
170            let action = match action {
171                PermissionAction::Allow => "allow",
172                PermissionAction::Ask => "ask",
173                PermissionAction::Deny => "deny",
174            };
175            items.push(ImportItem {
176                kind: ImportItemKind::Permission,
177                label: format!("permission `{tool}` = {action}"),
178                detail: String::new(),
179            });
180        }
181        for hook in &self.hooks_add {
182            let event = hook.event.as_str();
183            items.push(ImportItem {
184                kind: ImportItemKind::Hook,
185                label: format!("hook {event} `{}`", hook.command),
186                detail: hook.tool_match.clone(),
187            });
188        }
189        items
190    }
191
192    /// Keep add-lists in lockstep with [`selectable_items`] booleans.
193    /// A short `selected` slice drops the remaining rows.
194    pub fn retain_selected(&mut self, selected: &[bool]) {
195        let mut i = 0;
196        let mut next = || {
197            let keep = selected.get(i).copied().unwrap_or(false);
198            i += 1;
199            keep
200        };
201        self.mcp_add.retain(|_| next());
202        self.permission_add.retain(|_| next());
203        self.hooks_add.retain(|_| next());
204    }
205
206    pub fn summary(&self) -> String {
207        format!(
208            "MCP +{} (skip {}) · permission +{} (skip {}) · hooks +{} (skip {})",
209            self.mcp_add.len(),
210            self.mcp_skip.len(),
211            self.permission_add.len(),
212            self.permission_skip.len(),
213            self.hooks_add.len(),
214            self.hooks_skip.len()
215        )
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn product_parse_and_labels() {
225        assert_eq!(Product::parse("claude-code"), Some(Product::Claude));
226        assert_eq!(Product::parse("OpenCode"), Some(Product::OpenCode));
227        assert_eq!(Product::parse("grok-build"), Some(Product::Grok));
228        assert_eq!(Product::parse("codex"), Some(Product::Codex));
229        assert_eq!(Product::parse("cursor"), None);
230        assert_eq!(Product::Claude.as_str(), "claude");
231        assert_eq!(Product::OpenCode.as_str(), "opencode");
232        assert_eq!(Product::Grok.as_str(), "grok");
233        assert_eq!(Product::Codex.as_str(), "codex");
234        assert_eq!(Product::parse("claude"), Some(Product::Claude));
235        assert_eq!(Product::parse("grok"), Some(Product::Grok));
236        assert_eq!(Product::OpenCode.label(), "OpenCode");
237        assert_eq!(Product::Grok.label(), "Grok Build");
238        assert_eq!(Product::Codex.label(), "Codex CLI");
239        assert_eq!(Product::Claude.label(), "Claude Code");
240    }
241
242    #[test]
243    fn extracted_and_plan_summaries() {
244        let empty = Extracted {
245            product: Product::Claude,
246            path: PathBuf::from("/x"),
247            ..Default::default()
248        };
249        assert!(empty.is_empty());
250        assert_eq!(empty.counts_label(), "nothing mapped");
251        let mut full = empty.clone();
252        full.mcp.push((
253            "fs".into(),
254            McpServerConfig {
255                transport: None,
256                command: Some("npx".into()),
257                args: vec![],
258                env: None,
259                cwd: None,
260                url: None,
261                headers: None,
262            },
263        ));
264        full.permission.insert("bash".into(), PermissionAction::Ask);
265        full.hooks.push(HookConfig::default());
266        assert!(!full.is_empty());
267        assert!(full.counts_label().contains("MCP×1"));
268        let plan = ImportPlan::default();
269        assert!(plan.is_empty());
270        assert!(plan.summary().contains("MCP +0"));
271        assert!(plan.selectable_items().is_empty());
272    }
273
274    #[test]
275    fn counts_label_permission_or_hooks_only() {
276        let mut only_perm = Extracted::default();
277        only_perm
278            .permission
279            .insert("bash".into(), PermissionAction::Ask);
280        assert_eq!(only_perm.counts_label(), "permission×1");
281        let mut only_hooks = Extracted::default();
282        only_hooks.hooks.push(HookConfig::default());
283        assert_eq!(only_hooks.counts_label(), "hooks×1");
284    }
285
286    fn sample_plan() -> ImportPlan {
287        let mut plan = ImportPlan::default();
288        plan.mcp_add.push((
289            "fs".into(),
290            McpServerConfig {
291                transport: None,
292                command: Some("npx".into()),
293                args: vec![],
294                env: None,
295                cwd: None,
296                url: None,
297                headers: None,
298            },
299        ));
300        plan.mcp_add.push((
301            "remote".into(),
302            McpServerConfig {
303                transport: None,
304                command: None,
305                args: vec![],
306                env: None,
307                cwd: None,
308                url: Some("https://example".into()),
309                headers: None,
310            },
311        ));
312        plan.permission_add
313            .push(("bash".into(), PermissionAction::Ask));
314        plan.permission_add
315            .push(("read".into(), PermissionAction::Allow));
316        plan.hooks_add.push(HookConfig {
317            event: whycodes_config::HookEvent::PreTool,
318            tool_match: "bash".into(),
319            command: "echo hi".into(),
320            block_on_failure: true,
321            feedback: false,
322            timeout_secs: 30,
323        });
324        plan
325    }
326
327    #[test]
328    fn selectable_items_cover_mcp_permission_hook() {
329        let plan = sample_plan();
330        let items = plan.selectable_items();
331        assert_eq!(items.len(), 5);
332        assert_eq!(items[0].kind, ImportItemKind::Mcp);
333        assert_eq!(items[0].label, "MCP `fs`");
334        assert_eq!(items[0].detail, "npx");
335        assert_eq!(items[1].kind, ImportItemKind::Mcp);
336        assert_eq!(items[1].detail, "https://example");
337        assert_eq!(items[2].kind, ImportItemKind::Permission);
338        assert!(items[2].label.contains("ask"));
339        assert_eq!(items[3].kind, ImportItemKind::Permission);
340        assert!(items[3].label.contains("allow"));
341        assert_eq!(items[4].kind, ImportItemKind::Hook);
342        assert!(items[4].label.contains("pre_tool"));
343        assert_eq!(items[4].detail, "bash");
344    }
345
346    #[test]
347    fn retain_selected_keeps_checked_rows_in_order() {
348        let mut plan = sample_plan();
349        plan.retain_selected(&[true, false, false, true, true]);
350        assert_eq!(plan.mcp_add.len(), 1);
351        assert_eq!(plan.mcp_add[0].0, "fs");
352        assert_eq!(plan.permission_add.len(), 1);
353        assert_eq!(plan.permission_add[0].0, "read");
354        assert_eq!(plan.hooks_add.len(), 1);
355        let mut empty = sample_plan();
356        empty.retain_selected(&[]);
357        assert!(empty.is_empty());
358        let mut deny = sample_plan();
359        deny.permission_add[0].1 = PermissionAction::Deny;
360        assert!(
361            deny.selectable_items()[2]
362                .label
363                .contains("permission `bash` = deny")
364        );
365        let mut post = sample_plan();
366        post.hooks_add[0].event = whycodes_config::HookEvent::PostTool;
367        assert!(post.selectable_items()[4].label.contains("post_tool"));
368    }
369
370    #[test]
371    fn selectable_item_falls_back_to_empty_detail() {
372        let mut plan = ImportPlan::default();
373        plan.mcp_add.push((
374            "bare".into(),
375            McpServerConfig {
376                transport: None,
377                command: None,
378                args: vec![],
379                env: None,
380                cwd: None,
381                url: None,
382                headers: None,
383            },
384        ));
385        let items = plan.selectable_items();
386        assert_eq!(items.len(), 1);
387        assert_eq!(items[0].detail, "");
388        assert_eq!(items[0].kind, ImportItemKind::Mcp);
389    }
390}