1use std::fmt::Display;
2use std::fmt::Formatter;
3use std::path::PathBuf;
4use std::str::FromStr;
5
6use jiff::Timestamp;
7use rootcause::option_ext::OptionExt;
8use rootcause::report;
9
10use crate::agent::Agent;
11
12const SEARCH_TEXT_MAX_BYTES: usize = 32 * 1024;
13
14#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct SessionKey {
16 agent: Agent,
17 id: String,
18}
19
20impl SessionKey {
21 pub fn new(agent: Agent, id: impl Into<String>) -> Self {
22 Self { agent, id: id.into() }
23 }
24
25 pub const fn agent(&self) -> Agent {
26 self.agent
27 }
28
29 pub fn id(&self) -> &str {
30 &self.id
31 }
32}
33
34impl Display for SessionKey {
35 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
36 write!(f, "{}:{}", self.agent.name(), self.id)
37 }
38}
39
40impl FromStr for SessionKey {
41 type Err = rootcause::Report;
42
43 fn from_str(value: &str) -> Result<Self, Self::Err> {
44 let Some((agent, id)) = value.split_once(':') else {
45 return Err(report!("invalid session key").attach(format!("value={value}")));
46 };
47 let agent =
48 Agent::from_name(agent).map_err(|err| report!("invalid session key agent").attach(err.to_string()))?;
49 if id.is_empty() {
50 return Err(report!("invalid session key").attach(format!("value={value}")));
51 }
52 Ok(Self::new(agent, id))
53 }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct Session {
58 pub id: String,
59 pub agent: Agent,
60 pub name: String,
61 pub search_text: String,
62 pub workspace: PathBuf,
63 pub path: PathBuf,
64 pub created_at: Timestamp,
65 pub updated_at: Timestamp,
66}
67
68impl Session {
69 pub fn new(
70 agent: Agent,
71 session_id: String,
72 workspace_dir: PathBuf,
73 path: PathBuf,
74 name: Option<String>,
75 created_at: Timestamp,
76 ) -> Self {
77 let name = name.filter(|name| !name.trim().is_empty()).unwrap_or_else(|| {
78 workspace_dir
79 .file_name()
80 .and_then(|name| name.to_str())
81 .filter(|name| !name.is_empty())
82 .map_or_else(|| session_id.clone(), str::to_owned)
83 });
84
85 Self {
86 id: session_id,
87 agent,
88 search_text: name.clone(),
89 name,
90 workspace: workspace_dir,
91 path,
92 created_at,
93 updated_at: created_at,
94 }
95 }
96
97 pub fn build_resume_command(&self) -> rootcause::Result<(&'static str, Vec<String>)> {
103 let workspace = self.workspace.to_str().context("non-utf8 workspace dir".to_owned())?;
104 match self.agent {
105 Agent::Claude => Ok(("claude", vec!["--resume".into(), self.id.clone()])),
106 Agent::Codex => Ok((
107 "codex",
108 self.build_codex_resume_args(workspace, std::env::var_os("ZELLIJ").is_some()),
109 )),
110 Agent::Cursor => Ok((
111 "cursor-agent",
112 vec![
113 "--resume".into(),
114 self.id.clone(),
115 "--workspace".into(),
116 workspace.into(),
117 ],
118 )),
119 Agent::Gemini | Agent::Opencode => {
120 Err(report!("resume is not supported for this agent").attach(format!("agent={}", self.agent)))
121 }
122 }
123 }
124
125 fn build_codex_resume_args(&self, workspace: &str, is_zellij: bool) -> Vec<String> {
126 let mut args = vec!["resume".into(), self.id.clone()];
127 if !is_zellij {
131 args.push("--no-alt-screen".into());
132 }
133 args.extend(["--cd".into(), workspace.into()]);
134 args
135 }
136}
137
138#[derive(Debug, Default)]
139pub struct SearchTextBuilder {
140 snippets_text: String,
141 first_snippet: Option<String>,
142 last_snippet: Option<String>,
143 reached_limit: bool,
144}
145
146impl SearchTextBuilder {
147 pub fn push(&mut self, raw: &str) {
148 if self.reached_limit {
149 return;
150 }
151
152 let snippet = raw.split_whitespace().collect::<Vec<_>>().join(" ");
153 let Some(snippet) = (!snippet.is_empty()).then_some(snippet) else {
154 return;
155 };
156 if self.last_snippet.as_ref().is_some_and(|last| last == &snippet) {
157 return;
158 }
159 if self.first_snippet.is_none() {
160 self.first_snippet = Some(snippet.clone());
161 }
162
163 self.reached_limit = !push_normalized_snippet(&mut self.snippets_text, &mut self.last_snippet, &snippet);
164 }
165
166 pub fn build(self, fallback: &str) -> String {
167 let fallback = fallback.split_whitespace().collect::<Vec<_>>().join(" ");
168 let Some(fallback) = (!fallback.is_empty()).then_some(fallback) else {
169 return self.snippets_text;
170 };
171
172 if self.first_snippet.as_ref().is_some_and(|first| first == &fallback) {
173 return self.snippets_text;
174 }
175
176 let mut search_text = String::new();
177 let mut last_snippet = None::<String>;
178 if !push_normalized_snippet(&mut search_text, &mut last_snippet, &fallback) {
179 return search_text;
180 }
181 if self.snippets_text.is_empty() {
182 return search_text;
183 }
184
185 let separator_len = usize::from(!search_text.is_empty());
186 if search_text.len().saturating_add(separator_len) >= SEARCH_TEXT_MAX_BYTES {
187 return search_text;
188 }
189 if !search_text.is_empty() {
190 search_text.push(' ');
191 }
192
193 let remaining = SEARCH_TEXT_MAX_BYTES.saturating_sub(search_text.len());
194 if let Some(truncated) = truncate_to_boundary(&self.snippets_text, remaining) {
195 search_text.push_str(truncated);
196 }
197
198 search_text
199 }
200}
201
202fn push_normalized_snippet(search_text: &mut String, last_snippet: &mut Option<String>, snippet: &str) -> bool {
203 let separator_len = usize::from(!search_text.is_empty());
204 if search_text.len().saturating_add(separator_len) >= SEARCH_TEXT_MAX_BYTES {
205 return false;
206 }
207 if !search_text.is_empty() {
208 search_text.push(' ');
209 }
210
211 let remaining = SEARCH_TEXT_MAX_BYTES.saturating_sub(search_text.len());
212 if remaining == 0 {
213 return false;
214 }
215
216 let snippet_len = snippet.len();
217 truncate_to_boundary(snippet, remaining).is_some_and(|truncated| {
218 let is_full_snippet = truncated.len() == snippet_len;
219 search_text.push_str(truncated);
220 *last_snippet = Some(snippet.to_owned());
221 is_full_snippet
222 })
223}
224
225fn truncate_to_boundary(text: &str, max_bytes: usize) -> Option<&str> {
226 if max_bytes == 0 {
227 return None;
228 }
229 if text.len() <= max_bytes {
230 return Some(text);
231 }
232
233 let mut end = 0;
234 for (idx, ch) in text.char_indices() {
235 let next = idx.saturating_add(ch.len_utf8());
236 if next > max_bytes {
237 break;
238 }
239 end = next;
240 }
241
242 (end > 0).then(|| text.get(..end)).flatten()
243}
244
245#[cfg(test)]
246mod tests {
247 use jiff::Timestamp;
248 use tempfile::tempdir;
249 use test_that::prelude::*;
250
251 use super::*;
252
253 #[test]
254 fn test_session_key_string_round_trip_uses_agent_session_format() {
255 let key_result = "codex:session-id".parse::<SessionKey>();
256 assert_that!(key_result, ok(anything()));
257 let key = key_result.expect("session key should parse");
258
259 assert_that!(key, eq(SessionKey::new(Agent::Codex, "session-id")));
260 assert_that!(key.to_string(), eq("codex:session-id"));
261 }
262
263 #[test]
264 fn test_build_resume_command_matches_agent() {
265 let tempdir = tempdir().expect("tempdir should be created");
266 let workspace = tempdir.path().join("workspace");
267 let path = tempdir.path().join("session.jsonl");
268 std::fs::create_dir_all(&workspace).expect("workspace should be created");
269 let created_at = Timestamp::from_millisecond(1).expect("test timestamp should be valid");
270
271 let claude = Session {
272 agent: Agent::Claude,
273 id: "session-id".into(),
274 workspace: workspace.clone(),
275 name: "session-name".into(),
276 search_text: "session-name".into(),
277 path,
278 created_at,
279 updated_at: created_at,
280 };
281 let codex = Session {
282 agent: Agent::Codex,
283 ..claude.clone()
284 };
285 let cursor = Session {
286 agent: Agent::Cursor,
287 ..claude.clone()
288 };
289
290 let claude_command_result = claude.build_resume_command();
291 assert_that!(claude_command_result, ok(anything()));
292 let (_, claude_args) = claude_command_result.expect("Claude session should build resume command");
293 assert_that!(claude_args, eq(vec!["--resume".to_owned(), "session-id".to_owned()]));
294 let workspace_str = workspace.to_str().expect("workspace test path should be utf8");
295 assert_that!(
296 codex.build_codex_resume_args(workspace_str, false),
297 eq(vec![
298 "resume".to_owned(),
299 "session-id".to_owned(),
300 "--no-alt-screen".to_owned(),
301 "--cd".to_owned(),
302 workspace_str.to_owned(),
303 ])
304 );
305 assert_that!(
306 codex.build_codex_resume_args(workspace_str, true),
307 eq(vec![
308 "resume".to_owned(),
309 "session-id".to_owned(),
310 "--cd".to_owned(),
311 workspace_str.to_owned(),
312 ])
313 );
314 let cursor_command_result = cursor.build_resume_command();
315 assert_that!(cursor_command_result, ok(anything()));
316 let (_, cursor_args) = cursor_command_result.expect("Cursor session should build resume command");
317 assert_that!(
318 cursor_args,
319 eq(vec![
320 "--resume".to_owned(),
321 "session-id".to_owned(),
322 "--workspace".to_owned(),
323 workspace_str.to_owned(),
324 ])
325 );
326 }
327
328 #[test]
329 fn test_session_new_sets_search_text_from_resolved_name() {
330 let tempdir = tempdir().expect("tempdir should be created");
331 let workspace = tempdir.path().join("workspace");
332 std::fs::create_dir_all(&workspace).expect("workspace should be created");
333 let created_at = Timestamp::from_millisecond(1).expect("test timestamp should be valid");
334
335 let session = Session::new(
336 Agent::Codex,
337 "session-id".into(),
338 workspace,
339 PathBuf::from("session.jsonl"),
340 Some("hello world".into()),
341 created_at,
342 );
343
344 assert_that!(session.name, eq("hello world"));
345 assert_that!(session.search_text, eq("hello world"));
346 }
347
348 #[test]
349 fn test_search_text_builder_normalizes_dedupes_and_falls_back() {
350 let mut builder = SearchTextBuilder::default();
351 for snippet in [" fallback ", "first\nline", "", "first line", "second\tline"] {
352 builder.push(snippet);
353 }
354 let search_text = builder.build("fallback");
355
356 assert_that!(search_text, eq("fallback first line second line"));
357 }
358}