1use std::collections::HashMap;
4use std::path::PathBuf;
5
6use whycodes_config::{HookConfig, McpServerConfig};
7use whycodes_core::types::PermissionAction;
8
9#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum SourceState {
51 New,
53 Approved,
55 Denied,
57 Symlink,
59}
60
61#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum ImportItemKind {
134 Mcp,
135 Permission,
136 Hook,
137}
138
139#[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 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 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}