Skip to main content

sivtr_core/
ai.rs

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}