1use anyhow::{Context, Result};
2use serde_json::Value;
3use std::cell::RefCell;
4use std::collections::{HashMap, HashSet};
5use std::fs;
6use std::io::{BufRead, BufReader};
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AgentProvider {
12 Claude,
13 Codex,
14 Hermes,
15 OpenCode,
16 Pi,
17}
18
19#[derive(Clone, Copy)]
20pub struct AgentProviderSpec {
21 pub provider: AgentProvider,
22 pub name: &'static str,
23 pub command_name: &'static str,
24 pub current_transcript_env: Option<&'static str>,
25 pub current_session_id_env: Option<&'static str>,
26 factory: fn() -> Box<dyn AgentSessionProvider>,
27}
28
29const AGENT_PROVIDER_SPECS: &[AgentProviderSpec] = &[
30 AgentProviderSpec {
31 provider: AgentProvider::Codex,
32 name: "Codex",
33 command_name: "codex",
34 current_transcript_env: None,
35 current_session_id_env: Some("CODEX_THREAD_ID"),
36 factory: codex_provider,
37 },
38 AgentProviderSpec {
39 provider: AgentProvider::Claude,
40 name: "Claude",
41 command_name: "claude",
42 current_transcript_env: Some("CLAUDE_TRANSCRIPT_PATH"),
43 current_session_id_env: Some("CLAUDE_SESSION_ID"),
44 factory: claude_provider,
45 },
46 AgentProviderSpec {
47 provider: AgentProvider::OpenCode,
48 name: "OpenCode",
49 command_name: "opencode",
50 current_transcript_env: None,
51 current_session_id_env: None,
52 factory: opencode_provider,
53 },
54 AgentProviderSpec {
55 provider: AgentProvider::Hermes,
56 name: "Hermes",
57 command_name: "hermes",
58 current_transcript_env: None,
59 current_session_id_env: None,
60 factory: hermes_provider,
61 },
62 AgentProviderSpec {
63 provider: AgentProvider::Pi,
64 name: "Pi",
65 command_name: "pi",
66 current_transcript_env: None,
67 current_session_id_env: None,
68 factory: pi_provider,
69 },
70];
71
72fn codex_provider() -> Box<dyn AgentSessionProvider> {
73 Box::new(crate::codex::CodexProvider)
74}
75
76fn claude_provider() -> Box<dyn AgentSessionProvider> {
77 Box::new(crate::claude::ClaudeProvider)
78}
79
80fn opencode_provider() -> Box<dyn AgentSessionProvider> {
81 Box::new(crate::opencode::OpenCodeProvider::default())
82}
83
84fn hermes_provider() -> Box<dyn AgentSessionProvider> {
85 Box::new(crate::hermes::HermesProvider)
86}
87
88fn pi_provider() -> Box<dyn AgentSessionProvider> {
89 Box::new(crate::pi::PiProvider)
90}
91
92impl AgentProvider {
93 pub fn all() -> &'static [AgentProviderSpec] {
94 AGENT_PROVIDER_SPECS
95 }
96
97 pub fn from_command_name(value: &str) -> Option<Self> {
98 Self::all()
99 .iter()
100 .find(|spec| spec.command_name.eq_ignore_ascii_case(value))
101 .map(|spec| spec.provider)
102 }
103
104 pub fn spec(self) -> &'static AgentProviderSpec {
105 Self::all()
106 .iter()
107 .find(|spec| spec.provider == self)
108 .expect("agent provider registry must contain every AgentProvider variant")
109 }
110
111 pub fn name(self) -> &'static str {
112 self.spec().name
113 }
114
115 pub fn command_name(self) -> &'static str {
116 self.spec().command_name
117 }
118
119 pub fn current_transcript_env(self) -> Option<&'static str> {
120 self.spec().current_transcript_env
121 }
122
123 pub fn current_session_id_env(self) -> Option<&'static str> {
124 self.spec().current_session_id_env
125 }
126
127 pub fn session_provider(self) -> Box<dyn AgentSessionProvider> {
128 (self.spec().factory)()
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum AgentBlockKind {
134 User,
135 Assistant,
136 ToolCall,
137 ToolOutput,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct AgentBlock {
142 pub kind: AgentBlockKind,
143 pub timestamp: Option<String>,
144 pub label: Option<String>,
145 pub text: String,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct AgentSession {
150 pub path: PathBuf,
151 pub id: Option<String>,
152 pub cwd: Option<String>,
153 pub title: Option<String>,
154 pub blocks: Vec<AgentBlock>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct AgentSessionInfo {
159 pub path: PathBuf,
160 pub id: Option<String>,
161 pub cwd: Option<String>,
162 pub title: Option<String>,
163 pub modified: SystemTime,
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Eq)]
167pub struct AgentSessionMeta {
168 pub id: Option<String>,
169 pub cwd: Option<String>,
170 pub cwd_history: Vec<String>,
171 pub title: Option<String>,
172}
173
174impl AgentSessionMeta {
175 pub fn add_cwd(&mut self, cwd: impl Into<String>) {
176 let cwd = cwd.into();
177 if cwd.trim().is_empty() {
178 return;
179 }
180 if self.cwd.is_none() {
181 self.cwd = Some(cwd.clone());
182 }
183 if !self.cwd_history.iter().any(|existing| existing == &cwd) {
184 self.cwd_history.push(cwd);
185 }
186 }
187
188 fn cwd_candidates(&self) -> impl Iterator<Item = &str> {
189 self.cwd_history.iter().map(String::as_str).chain(
190 self.cwd
191 .as_deref()
192 .into_iter()
193 .filter(|cwd| !self.cwd_history.iter().any(|existing| existing == cwd)),
194 )
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum AgentSelection {
200 LastTurn,
201 LastAssistant,
202 LastUser,
203 LastTool,
204 LastBlocks(usize),
205 All,
206}
207
208impl AgentSelection {
209 pub fn label(self) -> &'static str {
210 match self {
211 Self::LastTurn => "turn",
212 Self::LastAssistant => "assistant",
213 Self::LastUser => "user",
214 Self::LastTool => "tool",
215 Self::LastBlocks(_) => "blocks",
216 Self::All => "all",
217 }
218 }
219}
220
221pub trait AgentSessionProvider {
222 fn provider(&self) -> AgentProvider;
223
224 fn list_recent_sessions(&self, cwd: Option<&Path>) -> Result<Vec<AgentSessionInfo>>;
225
226 fn parse_session_file(&self, path: &Path) -> Result<AgentSession>;
227
228 fn find_session_by_id(&self, id: &str) -> Result<Option<PathBuf>> {
229 for session in self.list_recent_sessions(None)? {
230 if session
231 .path
232 .file_name()
233 .and_then(|name| name.to_str())
234 .is_some_and(|name| name.contains(id))
235 || session.id.as_deref() == Some(id)
236 {
237 return Ok(Some(session.path));
238 }
239 }
240
241 Ok(None)
242 }
243
244 fn find_current_session(&self, cwd: &Path) -> Result<Option<PathBuf>> {
245 if let Some(session) = self.list_recent_sessions(Some(cwd))?.into_iter().next() {
246 return Ok(Some(session.path));
247 }
248
249 Ok(self
250 .list_recent_sessions(None)?
251 .into_iter()
252 .next()
253 .map(|session| session.path))
254 }
255}
256
257pub fn list_recent_jsonl_sessions(
258 root: &Path,
259 cwd: Option<&Path>,
260 parse_meta: impl Fn(&Path) -> Result<AgentSessionMeta>,
261) -> Result<Vec<AgentSessionInfo>> {
262 let wanted = cwd.map(WorkspaceMatchTarget::new);
263 let mut sessions = Vec::new();
264
265 for path in jsonl_files(root)? {
266 let meta = match parse_meta(&path) {
267 Ok(meta) => meta,
268 Err(error) => {
269 eprintln!(
270 "warning: failed to parse agent session metadata {}: {error:#}",
271 path.display()
272 );
273 continue;
274 }
275 };
276 if let Some(wanted) = wanted.as_ref() {
277 if !meta
278 .cwd_candidates()
279 .any(|candidate| wanted.matches(Path::new(candidate)))
280 {
281 continue;
282 }
283 }
284
285 sessions.push(AgentSessionInfo {
286 modified: modified_time(&path).unwrap_or(SystemTime::UNIX_EPOCH),
287 path,
288 id: meta.id,
289 cwd: meta.cwd,
290 title: meta.title,
291 });
292 }
293
294 sessions.sort_by_key(|session| session.modified);
295 sessions.reverse();
296 Ok(sessions)
297}
298
299pub fn parse_jsonl_session(
300 path: &Path,
301 provider_name: &str,
302 mut apply_event: impl FnMut(&mut AgentSession, &Value),
303) -> Result<AgentSession> {
304 let file = fs::File::open(path)
305 .with_context(|| format!("Failed to read {provider_name} session: {}", path.display()))?;
306 let reader = BufReader::new(file);
307 let mut session = AgentSession {
308 path: path.to_path_buf(),
309 id: None,
310 cwd: None,
311 title: None,
312 blocks: Vec::new(),
313 };
314
315 for (idx, line) in reader.lines().enumerate() {
316 let line = line.with_context(|| {
317 format!(
318 "Failed to read {provider_name} session line {}: {}",
319 idx + 1,
320 path.display()
321 )
322 })?;
323 if line.trim().is_empty() {
324 continue;
325 }
326
327 let value: Value = match serde_json::from_str(&line) {
328 Ok(value) => value,
329 Err(error) if idx > 0 && is_trailing_partial_json_line(&error) => break,
330 Err(error) => {
331 return Err(error).with_context(|| {
332 format!(
333 "Failed to parse {provider_name} session line {} as JSON: {}",
334 idx + 1,
335 path.display()
336 )
337 });
338 }
339 };
340 apply_event(&mut session, &value);
341 }
342
343 Ok(session)
344}
345
346pub fn parse_jsonl_meta(
347 path: &Path,
348 provider_name: &str,
349 max_lines: usize,
350 mut update_meta: impl FnMut(&mut AgentSessionMeta, &Value),
351) -> Result<AgentSessionMeta> {
352 let file = fs::File::open(path)
353 .with_context(|| format!("Failed to read {provider_name} session: {}", path.display()))?;
354 let reader = BufReader::new(file);
355 let mut meta = AgentSessionMeta::default();
356
357 for (idx, line) in reader.lines().take(max_lines).enumerate() {
358 let line = line.with_context(|| {
359 format!(
360 "Failed to read {provider_name} session metadata line {}: {}",
361 idx + 1,
362 path.display()
363 )
364 })?;
365 if line.trim().is_empty() {
366 continue;
367 }
368
369 let value: Value = serde_json::from_str(&line).with_context(|| {
370 format!(
371 "Failed to parse {provider_name} session metadata as JSON: {}",
372 path.display()
373 )
374 })?;
375 update_meta(&mut meta, &value);
376 }
377
378 Ok(meta)
379}
380
381pub fn push_block(
382 session: &mut AgentSession,
383 kind: AgentBlockKind,
384 timestamp: Option<String>,
385 label: Option<String>,
386 text: impl Into<String>,
387) {
388 let text = text.into().trim().to_string();
389 if !text.is_empty() {
390 session.blocks.push(AgentBlock {
391 kind,
392 timestamp,
393 label,
394 text,
395 });
396 }
397}
398
399pub fn extract_content_text(content: &Value) -> String {
400 match content {
401 Value::String(text) => text.clone(),
402 Value::Object(object) => object
403 .get("text")
404 .and_then(Value::as_str)
405 .or_else(|| object.get("input_text").and_then(Value::as_str))
406 .or_else(|| object.get("output_text").and_then(Value::as_str))
407 .or_else(|| object.get("content").and_then(Value::as_str))
408 .unwrap_or_default()
409 .to_string(),
410 Value::Array(items) => items
411 .iter()
412 .filter_map(|item| {
413 item.get("text")
414 .and_then(Value::as_str)
415 .or_else(|| item.get("input_text").and_then(Value::as_str))
416 .or_else(|| item.get("output_text").and_then(Value::as_str))
417 .or_else(|| item.as_str())
418 })
419 .collect::<Vec<_>>()
420 .join("\n\n"),
421 _ => String::new(),
422 }
423}
424
425pub fn pretty_json_value(value: &Value) -> String {
426 serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
427}
428
429pub fn pretty_json_string(text: &str) -> String {
430 serde_json::from_str::<Value>(text)
431 .ok()
432 .and_then(|value| serde_json::to_string_pretty(&value).ok())
433 .unwrap_or_else(|| text.to_string())
434}
435
436pub fn jsonl_files(root: &Path) -> Result<Vec<PathBuf>> {
437 if !root.exists() {
438 return Ok(Vec::new());
439 }
440
441 let mut files = Vec::new();
442 collect_jsonl_files(root, &mut files)?;
443 Ok(files)
444}
445
446fn collect_jsonl_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
447 for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
448 let entry = entry?;
449 let path = entry.path();
450 if path.is_dir() {
451 collect_jsonl_files(&path, files)?;
452 } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
453 files.push(path);
454 }
455 }
456 Ok(())
457}
458
459fn modified_time(path: &Path) -> Result<SystemTime> {
460 Ok(fs::metadata(path)?.modified()?)
461}
462
463pub fn normalize_path_for_match(path: &Path) -> String {
464 path.canonicalize()
465 .unwrap_or_else(|_| path.to_path_buf())
466 .to_string_lossy()
467 .replace('/', "\\")
468 .to_lowercase()
469}
470
471struct WorkspaceMatchTarget {
472 normalized_path: String,
473 remote_keys: HashSet<String>,
474 candidate_remote_keys: RefCell<HashMap<String, HashSet<String>>>,
475}
476
477impl WorkspaceMatchTarget {
478 fn new(path: &Path) -> Self {
479 Self {
480 normalized_path: normalize_path_for_match(path),
481 remote_keys: git_remote_keys(path),
482 candidate_remote_keys: RefCell::new(HashMap::new()),
483 }
484 }
485
486 fn matches(&self, candidate: &Path) -> bool {
487 let normalized_candidate = normalize_path_for_match(candidate);
488 if normalized_candidate == self.normalized_path {
489 return true;
490 }
491
492 if self.remote_keys.is_empty() {
493 return false;
494 }
495
496 {
497 let cache = self.candidate_remote_keys.borrow();
498 if let Some(candidate_keys) = cache.get(&normalized_candidate) {
499 return candidate_keys
500 .iter()
501 .any(|candidate_key| self.remote_keys.contains(candidate_key));
502 }
503 }
504
505 let candidate_keys = git_remote_keys(candidate);
506 let matches = candidate_keys
507 .iter()
508 .any(|candidate_key| self.remote_keys.contains(candidate_key));
509 self.candidate_remote_keys
510 .borrow_mut()
511 .insert(normalized_candidate, candidate_keys);
512 matches
513 }
514}
515
516fn git_remote_keys(path: &Path) -> HashSet<String> {
517 let Some(root) = git_root(path) else {
518 return HashSet::new();
519 };
520 let Some(config_path) = git_config_path(&root) else {
521 return HashSet::new();
522 };
523 parse_git_remote_keys(&config_path)
524}
525
526fn git_root(path: &Path) -> Option<PathBuf> {
527 let mut dir = if path.is_dir() {
528 path.to_path_buf()
529 } else {
530 path.parent()
531 .map(Path::to_path_buf)
532 .unwrap_or_else(|| path.to_path_buf())
533 };
534
535 loop {
536 if dir.join(".git").exists() {
537 return Some(dir);
538 }
539 if !dir.pop() {
540 return None;
541 }
542 }
543}
544
545fn git_config_path(root: &Path) -> Option<PathBuf> {
546 let dot_git = root.join(".git");
547 if dot_git.is_dir() {
548 return Some(dot_git.join("config"));
549 }
550
551 let gitdir = fs::read_to_string(&dot_git).ok()?;
552 let relative = gitdir.trim().strip_prefix("gitdir:")?.trim();
553 let git_dir = resolve_gitdir(root, relative);
554 Some(git_dir.join("config"))
555}
556
557fn resolve_gitdir(root: &Path, gitdir: &str) -> PathBuf {
558 let path = PathBuf::from(gitdir);
559 if path.is_absolute() {
560 path
561 } else {
562 root.join(path)
563 }
564}
565
566fn parse_git_remote_keys(config_path: &Path) -> HashSet<String> {
567 fs::read_to_string(config_path)
568 .ok()
569 .map(|config| {
570 config
571 .lines()
572 .filter_map(remote_key_from_config_line)
573 .collect()
574 })
575 .unwrap_or_default()
576}
577
578fn remote_key_from_config_line(line: &str) -> Option<String> {
579 let trimmed = line.trim();
580 let url = trimmed.strip_prefix("url")?.trim_start();
581 let url = url.strip_prefix('=')?.trim();
582 normalize_remote_url(url)
583}
584
585fn normalize_remote_url(url: &str) -> Option<String> {
586 let trimmed = url.trim().trim_end_matches('/');
587 if trimmed.is_empty() {
588 return None;
589 }
590
591 let without_suffix = trimmed.strip_suffix(".git").unwrap_or(trimmed);
592 let normalized = if let Some((_, rest)) = without_suffix.split_once("://") {
593 normalize_remote_authority_path(rest).unwrap_or_else(|| without_suffix.to_string())
594 } else if let Some((authority, path)) = split_scp_like_remote(without_suffix) {
595 format!(
596 "{}/{}",
597 authority.rsplit('@').next().unwrap_or(authority),
598 path.trim_start_matches('/')
599 )
600 } else {
601 without_suffix.to_string()
602 };
603
604 Some(normalized.replace('\\', "/").to_lowercase())
605}
606
607fn normalize_remote_authority_path(rest: &str) -> Option<String> {
608 let (authority, path) = rest.split_once('/')?;
609 let host = authority.rsplit('@').next()?.trim();
610 let path = path.trim_start_matches('/').trim();
611 if host.is_empty() || path.is_empty() {
612 return None;
613 }
614 Some(format!("{host}/{path}"))
615}
616
617fn split_scp_like_remote(remote: &str) -> Option<(&str, &str)> {
618 let (authority, path) = remote.split_once(':')?;
619 if !authority.contains('@') || path.trim().is_empty() {
620 return None;
621 }
622 Some((authority, path))
623}
624
625fn is_trailing_partial_json_line(error: &serde_json::Error) -> bool {
626 matches!(error.classify(), serde_json::error::Category::Eof)
627}
628
629pub fn select_blocks(session: &AgentSession, selection: AgentSelection) -> Vec<AgentBlock> {
630 match selection {
631 AgentSelection::LastTurn => select_last_turn(&session.blocks),
632 AgentSelection::LastAssistant => {
633 select_last_kind(&session.blocks, AgentBlockKind::Assistant)
634 }
635 AgentSelection::LastUser => select_last_kind(&session.blocks, AgentBlockKind::User),
636 AgentSelection::LastTool => select_last_kind(&session.blocks, AgentBlockKind::ToolOutput),
637 AgentSelection::LastBlocks(count) => {
638 let start = session.blocks.len().saturating_sub(count);
639 session.blocks[start..].to_vec()
640 }
641 AgentSelection::All => session.blocks.clone(),
642 }
643}
644
645pub fn format_blocks(blocks: &[AgentBlock]) -> String {
646 format_blocks_with_text(blocks, |block| block.text.trim().to_string())
647}
648
649pub fn format_blocks_with_text(
650 blocks: &[AgentBlock],
651 text_for_block: impl Fn(&AgentBlock) -> String,
652) -> String {
653 if blocks.len() == 1 {
654 return text_for_block(&blocks[0]).trim().to_string();
655 }
656
657 blocks
658 .iter()
659 .filter_map(|block| format_block_with_heading(block, &text_for_block(block)))
660 .collect::<Vec<_>>()
661 .join("\n\n")
662 .trim()
663 .to_string()
664}
665
666fn select_last_kind(blocks: &[AgentBlock], kind: AgentBlockKind) -> Vec<AgentBlock> {
667 blocks
668 .iter()
669 .rev()
670 .find(|block| block.kind == kind)
671 .cloned()
672 .into_iter()
673 .collect()
674}
675
676fn select_last_turn(blocks: &[AgentBlock]) -> Vec<AgentBlock> {
677 let Some(assistant_idx) = blocks
678 .iter()
679 .rposition(|block| block.kind == AgentBlockKind::Assistant)
680 else {
681 return Vec::new();
682 };
683 let user_idx = blocks[..assistant_idx]
684 .iter()
685 .rposition(|block| block.kind == AgentBlockKind::User)
686 .unwrap_or(assistant_idx);
687
688 blocks[user_idx..=assistant_idx]
689 .iter()
690 .filter(|block| matches!(block.kind, AgentBlockKind::User | AgentBlockKind::Assistant))
691 .cloned()
692 .collect()
693}
694
695fn format_block_with_heading(block: &AgentBlock, text: &str) -> Option<String> {
696 let text = text.trim();
697 if text.is_empty() {
698 return None;
699 }
700
701 let heading = match block.kind {
702 AgentBlockKind::User => "User".to_string(),
703 AgentBlockKind::Assistant => "Assistant".to_string(),
704 AgentBlockKind::ToolCall => block
705 .label
706 .as_deref()
707 .map(|label| format!("Tool Call: {label}"))
708 .unwrap_or_else(|| "Tool Call".to_string()),
709 AgentBlockKind::ToolOutput => "Tool Output".to_string(),
710 };
711
712 Some(format!("## {heading}\n{text}"))
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use std::fs;
719
720 fn write_git_remote(repo: &Path, name: &str, url: &str) {
721 fs::create_dir_all(repo.join(".git")).unwrap();
722 fs::write(
723 repo.join(".git").join("config"),
724 format!("[remote \"{name}\"]\n\turl = {url}\n"),
725 )
726 .unwrap();
727 }
728
729 #[test]
730 fn normalizes_common_github_remote_url_forms() {
731 assert_eq!(
732 normalize_remote_url("https://github.com/Ariestar/sivtr.git").as_deref(),
733 Some("github.com/ariestar/sivtr")
734 );
735 assert_eq!(
736 normalize_remote_url("git@github.com:Ariestar/sivtr.git").as_deref(),
737 Some("github.com/ariestar/sivtr")
738 );
739 assert_eq!(
740 normalize_remote_url("ssh://git@github.com/Ariestar/sivtr.git/").as_deref(),
741 Some("github.com/ariestar/sivtr")
742 );
743 }
744
745 #[test]
746 fn normalizes_generic_git_remote_url_forms() {
747 assert_eq!(
748 normalize_remote_url("https://gitlab.example.com/team/sivtr.git").as_deref(),
749 Some("gitlab.example.com/team/sivtr")
750 );
751 assert_eq!(
752 normalize_remote_url("git@gitlab.example.com:team/sivtr.git").as_deref(),
753 Some("gitlab.example.com/team/sivtr")
754 );
755 assert_eq!(
756 normalize_remote_url("ssh://git@gitlab.example.com:2222/team/sivtr.git").as_deref(),
757 Some("gitlab.example.com:2222/team/sivtr")
758 );
759 }
760
761 #[test]
762 fn cwd_candidates_do_not_duplicate_the_primary_cwd() {
763 let mut tracked = AgentSessionMeta::default();
764 tracked.add_cwd("/repo");
765 tracked.add_cwd("/repo/subdir");
766 assert_eq!(
767 tracked.cwd_candidates().collect::<Vec<_>>(),
768 vec!["/repo", "/repo/subdir"]
769 );
770
771 let fallback = AgentSessionMeta {
772 cwd: Some("/repo".to_string()),
773 ..AgentSessionMeta::default()
774 };
775 assert_eq!(fallback.cwd_candidates().collect::<Vec<_>>(), vec!["/repo"]);
776 }
777
778 #[test]
779 fn matches_repositories_with_shared_remote() {
780 let dir = tempfile::tempdir().unwrap();
781 let target = dir.path().join("oh-my-ppt-fork");
782 let candidate = dir.path().join("oh-my-ppt");
783 fs::create_dir_all(&target).unwrap();
784 fs::create_dir_all(&candidate).unwrap();
785 write_git_remote(
786 &target,
787 "upstream",
788 "https://github.com/arcsin1/oh-my-ppt.git",
789 );
790 write_git_remote(&candidate, "origin", "git@github.com:arcsin1/oh-my-ppt.git");
791
792 assert!(WorkspaceMatchTarget::new(&target).matches(&candidate));
793 }
794
795 #[test]
796 fn does_not_match_unrelated_repositories() {
797 let dir = tempfile::tempdir().unwrap();
798 let target = dir.path().join("oh-my-ppt-fork");
799 let candidate = dir.path().join("sivtr");
800 fs::create_dir_all(&target).unwrap();
801 fs::create_dir_all(&candidate).unwrap();
802 write_git_remote(
803 &target,
804 "upstream",
805 "https://github.com/arcsin1/oh-my-ppt.git",
806 );
807 write_git_remote(
808 &candidate,
809 "origin",
810 "https://github.com/Ariestar/sivtr.git",
811 );
812
813 assert!(!WorkspaceMatchTarget::new(&target).matches(&candidate));
814 }
815
816 #[test]
817 fn includes_sessions_with_later_matching_cwd_metadata() {
818 let dir = tempfile::tempdir().unwrap();
819 let sessions = dir.path().join("sessions");
820 let target = dir.path().join("oh-my-ppt-fork");
821 let candidate = dir.path().join("oh-my-ppt");
822 fs::create_dir_all(&sessions).unwrap();
823 fs::create_dir_all(&target).unwrap();
824 fs::create_dir_all(&candidate).unwrap();
825 write_git_remote(
826 &target,
827 "upstream",
828 "https://github.com/arcsin1/oh-my-ppt.git",
829 );
830 write_git_remote(
831 &candidate,
832 "origin",
833 "https://github.com/arcsin1/oh-my-ppt.git",
834 );
835 let transcript = sessions.join("session.jsonl");
836 let first_event = serde_json::json!({
837 "sessionId": "abc",
838 "cwd": dir.path(),
839 "customTitle": "Initial",
840 });
841 let second_event = serde_json::json!({
842 "sessionId": "abc",
843 "cwd": candidate,
844 });
845 fs::write(&transcript, format!("{first_event}\n{second_event}\n")).unwrap();
846
847 let sessions = list_recent_jsonl_sessions(&sessions, Some(&target), |path| {
848 parse_jsonl_meta(path, "Claude", 50, |meta, value| {
849 if meta.id.is_none() {
850 meta.id = value
851 .get("sessionId")
852 .and_then(Value::as_str)
853 .map(str::to_string);
854 }
855 if let Some(cwd) = value.get("cwd").and_then(Value::as_str) {
856 meta.add_cwd(cwd);
857 }
858 if meta.title.is_none() {
859 meta.title = value
860 .get("customTitle")
861 .and_then(Value::as_str)
862 .map(str::to_string);
863 }
864 })
865 })
866 .unwrap();
867
868 assert_eq!(sessions.len(), 1);
869 assert_eq!(sessions[0].id.as_deref(), Some("abc"));
870 assert_eq!(
871 sessions[0].cwd.as_deref(),
872 Some(dir.path().to_str().unwrap())
873 );
874 }
875}