1use crate::error::{Result, SessionError};
6use whycodes_core::types::{Message, MessageContent, Role};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ImportKind {
11 Auto,
12 WhyCodes,
13 Claude,
14 Codex,
15 OpenCode,
16 Pi,
17}
18
19impl ImportKind {
20 pub fn parse(s: &str) -> Self {
21 match s.trim().to_ascii_lowercase().as_str() {
22 "whycodes" | "native" => Self::WhyCodes,
23 "claude" | "claude-code" => Self::Claude,
24 "codex" => Self::Codex,
25 "opencode" => Self::OpenCode,
26 "pi" => Self::Pi,
27 _ => Self::Auto,
28 }
29 }
30}
31
32pub fn import_messages(raw: &str, kind: ImportKind) -> Result<Vec<Message>> {
34 let kind = if kind == ImportKind::Auto {
35 detect(raw)
36 } else {
37 kind
38 };
39 let msgs = match kind {
40 ImportKind::Auto | ImportKind::WhyCodes => parse_whycodes(raw)?,
41 ImportKind::Claude => parse_claude(raw)?,
42 ImportKind::Codex => parse_codex(raw)?,
43 ImportKind::OpenCode => parse_opencode(raw)?,
44 ImportKind::Pi => parse_pi(raw)?,
45 };
46 if msgs.is_empty() {
47 return Err(SessionError::msg(
48 "no user/assistant messages found in import",
49 ));
50 }
51 Ok(msgs)
52}
53
54fn detect(raw: &str) -> ImportKind {
55 let trimmed = raw.trim_start();
56 if trimmed.starts_with('{')
57 && let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed)
58 {
59 if v.get("info").is_some() && v.get("messages").is_some() {
60 return ImportKind::OpenCode;
61 }
62 if v.get("id").is_some() && v.get("messages").is_some() {
63 return ImportKind::WhyCodes;
64 }
65 if v.get("session").is_some() && v.get("messages").is_some() {
66 return ImportKind::WhyCodes;
67 }
68 }
69 let Some(first) = first_jsonl_object(raw) else {
70 return ImportKind::WhyCodes;
71 };
72 let ty = first.get("type").and_then(|t| t.as_str()).unwrap_or("");
73 if ty == "session_meta" || ty.starts_with("event") || first.get("payload").is_some() {
74 return ImportKind::Codex;
75 }
76 if first.get("type").is_some() && first.get("message").is_some() {
77 return ImportKind::Claude;
78 }
79 if first.get("role").is_some() && first.get("content").is_some() {
80 return ImportKind::Pi;
81 }
82 ImportKind::WhyCodes
83}
84
85fn first_jsonl_object(raw: &str) -> Option<serde_json::Value> {
86 for line in raw.lines() {
87 let line = line.trim();
88 if line.is_empty() {
89 continue;
90 }
91 if let Ok(v) = serde_json::from_str(line) {
92 return Some(v);
93 }
94 }
95 None
96}
97
98fn parse_whycodes(raw: &str) -> Result<Vec<Message>> {
99 let v: serde_json::Value = serde_json::from_str(raw)?;
100 if let Some(arr) = v.get("messages").and_then(|m| m.as_array()) {
101 return Ok(arr.iter().filter_map(value_to_message).collect());
102 }
103 if let Some(arr) = v.as_array() {
104 return Ok(arr.iter().filter_map(value_to_message).collect());
105 }
106 Err(SessionError::msg(
107 "not a whycodes session JSON (expected messages array)",
108 ))
109}
110
111fn parse_opencode(raw: &str) -> Result<Vec<Message>> {
112 parse_whycodes(raw)
113}
114
115fn parse_claude(raw: &str) -> Result<Vec<Message>> {
116 let mut out = Vec::new();
117 for line in raw.lines() {
118 let line = line.trim();
119 if line.is_empty() {
120 continue;
121 }
122 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
123 continue;
124 };
125 let msg = v.get("message").unwrap_or(&v);
126 if let Some(m) = value_to_message(msg) {
127 out.push(m);
128 }
129 }
130 Ok(out)
131}
132
133fn parse_codex(raw: &str) -> Result<Vec<Message>> {
134 let mut out = Vec::new();
135 for line in raw.lines() {
136 let line = line.trim();
137 if line.is_empty() {
138 continue;
139 }
140 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
141 continue;
142 };
143 let payload = v.get("payload").unwrap_or(&v);
144 let ty = payload
145 .get("type")
146 .or_else(|| v.get("type"))
147 .and_then(|t| t.as_str())
148 .unwrap_or("");
149 if let Some(text) = payload
150 .get("message")
151 .and_then(|m| m.as_str())
152 .or_else(|| payload.get("text").and_then(|t| t.as_str()))
153 {
154 let role = if ty.contains("user") {
155 Role::User
156 } else if ty.contains("agent") || ty.contains("assistant") {
157 Role::Assistant
158 } else {
159 continue;
160 };
161 out.push(
162 Message {
163 role,
164 content: MessageContent::text(text),
165 tool_call_id: None,
166 name: None,
167 created_at: None,
168 }
169 .stamp(),
170 );
171 } else if let Some(m) = value_to_message(payload) {
172 out.push(m);
173 }
174 }
175 Ok(out)
176}
177
178fn parse_pi(raw: &str) -> Result<Vec<Message>> {
179 let mut out = Vec::new();
180 for line in raw.lines() {
181 let line = line.trim();
182 if line.is_empty() {
183 continue;
184 }
185 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
186 continue;
187 };
188 if let Some(m) = value_to_message(&v) {
189 out.push(m);
190 }
191 }
192 Ok(out)
193}
194
195fn value_to_message(v: &serde_json::Value) -> Option<Message> {
196 let role = v.get("role").and_then(|r| r.as_str())?;
197 let role = match role {
198 "user" => Role::User,
199 "assistant" => Role::Assistant,
200 "system" => return None,
201 "tool" => Role::Tool,
202 _ => return None,
203 };
204 let content = v.get("content")?;
205 let content = if let Some(s) = content.as_str() {
206 MessageContent::text(s)
207 } else if let Some(arr) = content.as_array() {
208 let text = arr
209 .iter()
210 .filter_map(|b| {
211 b.get("text")
212 .and_then(|t| t.as_str())
213 .or_else(|| b.as_str())
214 })
215 .collect::<Vec<_>>()
216 .join("\n");
217 if text.is_empty() {
218 MessageContent::text(content.to_string())
219 } else {
220 MessageContent::text(text)
221 }
222 } else {
223 MessageContent::text(content.to_string())
224 };
225 Some(
226 Message {
227 role,
228 content,
229 tool_call_id: v
230 .get("tool_call_id")
231 .and_then(|t| t.as_str())
232 .map(str::to_string),
233 name: v.get("name").and_then(|n| n.as_str()).map(str::to_string),
234 created_at: None,
235 }
236 .stamp(),
237 )
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn claude_jsonl() {
246 let raw = r#"
247{"type":"user","message":{"role":"user","content":"hello"}}
248{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]}}
249"#;
250 let msgs = import_messages(raw, ImportKind::Auto).unwrap();
251 assert_eq!(msgs.len(), 2);
252 assert_eq!(msgs[0].role, Role::User);
253 assert_eq!(msgs[0].content.as_text(), Some("hello"));
254 assert_eq!(msgs[1].content.as_text(), Some("hi there"));
255 }
256
257 #[test]
258 fn whycodes_share() {
259 let raw = r#"{"id":"abc","title":"t","messages":[{"role":"user","content":"ping"}]}"#;
260 let msgs = import_messages(raw, ImportKind::Auto).unwrap();
261 assert_eq!(msgs.len(), 1);
262 assert_eq!(msgs[0].content.as_text(), Some("ping"));
263 }
264
265 #[test]
266 fn codex_jsonl() {
267 let raw = r#"
268{"type":"session_meta","payload":{"id":"x"}}
269{"type":"event_msg","payload":{"type":"user_message","message":"fix the bug"}}
270{"type":"event_msg","payload":{"type":"agent_message","message":"looking"}}
271"#;
272 let msgs = import_messages(raw, ImportKind::Auto).unwrap();
273 assert_eq!(msgs.len(), 2);
274 assert_eq!(msgs[0].role, Role::User);
275 assert_eq!(msgs[1].role, Role::Assistant);
276 }
277
278 #[test]
279 fn import_kind_parse_covers_aliases_and_fallback() {
280 assert_eq!(ImportKind::parse(" native "), ImportKind::WhyCodes);
281 assert_eq!(ImportKind::parse("whycodes"), ImportKind::WhyCodes);
282 assert_eq!(ImportKind::parse("CLAUDE-CODE"), ImportKind::Claude);
283 assert_eq!(ImportKind::parse("codex"), ImportKind::Codex);
284 assert_eq!(ImportKind::parse("opencode"), ImportKind::OpenCode);
285 assert_eq!(ImportKind::parse("pi"), ImportKind::Pi);
286 assert_eq!(ImportKind::parse("unknown"), ImportKind::Auto);
287 }
288
289 #[test]
290 fn explicit_import_kinds_and_empty_errors() {
291 let native = r#"[{"role":"user","content":"hello"}]"#;
292 assert_eq!(
293 import_messages(native, ImportKind::WhyCodes).unwrap().len(),
294 1
295 );
296 assert_eq!(
297 import_messages(native, ImportKind::OpenCode).unwrap().len(),
298 1
299 );
300
301 let claude = "not json\n{\"role\":\"assistant\",\"content\":\"answer\"}";
302 assert_eq!(
303 import_messages(claude, ImportKind::Claude).unwrap().len(),
304 1
305 );
306 let pi = "\ninvalid\n{\"role\":\"user\",\"content\":\"question\"}";
307 assert_eq!(import_messages(pi, ImportKind::Pi).unwrap().len(), 1);
308
309 let err = import_messages(r#"{"messages":[]}"#, ImportKind::WhyCodes).unwrap_err();
310 assert!(err.to_string().contains("no user/assistant"));
311 assert!(import_messages("{}", ImportKind::WhyCodes).is_err());
312 assert!(import_messages("not json", ImportKind::WhyCodes).is_err());
313 }
314
315 #[test]
316 fn auto_detects_all_supported_shapes() {
317 let cases = [
318 (
319 r#"{"info":{},"messages":[{"role":"user","content":"o"}]}"#,
320 "o",
321 ),
322 (
323 r#"{"session":{},"messages":[{"role":"user","content":"w"}]}"#,
324 "w",
325 ),
326 (
327 "{\"type\":\"event_custom\",\"payload\":{}}\n{\"payload\":{\"type\":\"user_message\",\"text\":\"c\"}}",
328 "c",
329 ),
330 (
331 "{\"payload\":{}}\n{\"payload\":{\"type\":\"assistant_message\",\"message\":\"a\"}}",
332 "a",
333 ),
334 ("{\"role\":\"user\",\"content\":\"p\"}", "p"),
335 ];
336 for (raw, expected) in cases {
337 let messages = import_messages(raw, ImportKind::Auto).unwrap();
338 assert_eq!(messages[0].content.as_text(), Some(expected));
339 }
340 }
341
342 #[test]
343 fn codex_skips_noise_and_accepts_native_payload_messages() {
344 let raw = r#"
345
346not-json
347{"type":"event_msg","payload":{"type":"irrelevant","message":"skip"}}
348{"type":"user_message","text":"direct user"}
349{"payload":{"role":"assistant","content":"native assistant"}}
350{"payload":{"role":"system","content":"hidden"}}
351"#;
352 let msgs = import_messages(raw, ImportKind::Codex).unwrap();
353 assert_eq!(msgs.len(), 2);
354 assert_eq!(msgs[0].role, Role::User);
355 assert_eq!(msgs[1].role, Role::Assistant);
356 }
357
358 #[test]
359 fn value_conversion_handles_roles_content_shapes_and_metadata() {
360 let raw = r#"[
361 {"role":"tool","content":[{"text":"one"},"two",{"other":1}],"tool_call_id":"call","name":"read"},
362 {"role":"assistant","content":[{"image":"x"}]},
363 {"role":"user","content":{"nested":true}},
364 {"role":"system","content":"skip"},
365 {"role":"unknown","content":"skip"},
366 {"content":"skip"},
367 {"role":"user"}
368 ]"#;
369 let msgs = import_messages(raw, ImportKind::WhyCodes).unwrap();
370 assert_eq!(msgs.len(), 3);
371 assert_eq!(msgs[0].role, Role::Tool);
372 assert_eq!(msgs[0].content.as_text(), Some("one\ntwo"));
373 assert_eq!(msgs[0].tool_call_id.as_deref(), Some("call"));
374 assert_eq!(msgs[0].name.as_deref(), Some("read"));
375 assert_eq!(msgs[1].content.as_text(), Some("[{\"image\":\"x\"}]"));
376 assert_eq!(msgs[2].content.as_text(), Some("{\"nested\":true}"));
377 }
378
379 #[test]
380 fn direct_parsers_cover_remaining_detection_and_loop_fallbacks() {
381 assert_eq!(detect("\ninvalid\n{}"), ImportKind::WhyCodes);
382 assert_eq!(
383 detect("{\"unrecognized\":true}\nnot-json"),
384 ImportKind::WhyCodes
385 );
386 assert_eq!(detect("{}\n"), ImportKind::WhyCodes);
387 assert_eq!(detect("not-json\n{}"), ImportKind::WhyCodes);
388 assert_eq!(detect("not-json-only"), ImportKind::WhyCodes);
389 assert!(first_jsonl_object("\ninvalid\nalso-invalid").is_none());
390 assert_eq!(
391 import_messages(
392 "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"a\"}}",
393 ImportKind::Claude,
394 )
395 .unwrap()[0]
396 .role,
397 Role::Assistant
398 );
399 assert_eq!(
400 import_messages("{\"role\":\"tool\",\"content\":\"r\"}\n", ImportKind::Pi,).unwrap()[0]
401 .role,
402 Role::Tool
403 );
404 for kind in [ImportKind::Claude, ImportKind::Codex, ImportKind::Pi] {
405 assert!(import_messages("\ninvalid", kind).is_err());
406 }
407 }
408}