1use super::HarnessKind;
34use crate::base::paths::{CODE_DIR, LEGACY_CODE_DIR};
35use crate::engine::run::{EngineError, capture};
36use crate::engine::{ContainerState, Engine, EngineCommand, Exec};
37use std::collections::HashSet;
38
39pub const TITLE_CHARS: usize = 200;
42
43const ID_CHARS: usize = 128;
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Conversation {
50 pub id: String,
52 pub title: Option<String>,
55 pub used_ms: i64,
57}
58
59#[must_use]
67pub fn is_safe_id(id: &str) -> bool {
68 id.len() <= ID_CHARS
69 && id.chars().next().is_some_and(|first| first.is_ascii_alphanumeric())
70 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
71}
72
73#[must_use]
80pub fn parse(output: &str) -> Vec<Conversation> {
81 let mut found: Vec<Conversation> = output.lines().filter_map(conversation).collect();
82 found.sort_by(|a, b| b.used_ms.cmp(&a.used_ms).then_with(|| a.id.cmp(&b.id)));
83 let mut seen = HashSet::new();
84 found.retain(|conversation| seen.insert(conversation.id.clone()));
85 found
86}
87
88fn conversation(line: &str) -> Option<Conversation> {
90 let mut fields = line.splitn(3, '\t');
91 let id = fields.next()?.trim();
92 let used_ms = fields.next()?.trim().parse().ok()?;
93 let title = fields.next().unwrap_or_default();
94 is_safe_id(id).then(|| Conversation { id: id.to_owned(), title: tidy(title), used_ms })
95}
96
97fn tidy(title: &str) -> Option<String> {
99 let words: Vec<&str> =
100 title.split(|c: char| c.is_whitespace() || c.is_control()).filter(|w| !w.is_empty()).collect();
101 let joined = words.join(" ");
102 let cut: String = joined.chars().take(TITLE_CHARS).collect();
103 let cut = cut.trim_end();
104 (!cut.is_empty()).then(|| cut.to_owned())
105}
106
107macro_rules! prelude {
110 () => {
111 r#"const fs = require('fs'), path = require('path'), os = require('os');
112const workspace = process.argv[1] || '', was = process.argv[2] || '', home = os.homedir();
113const workspaces = [workspace, was].filter((d, i, all) => d && all.indexOf(d) === i);
114const here = (d) => typeof d === 'string' && workspaces.includes(d);
115const LINE_CAP = 1 << 20, FILE_CAP = 256 << 20, TITLE_CAP = 1000;
116const clean = (s) => typeof s === 'string' ? s.replace(/[\p{Cc}\p{Zl}\p{Zp}]+/gu, ' ').trim().slice(0, TITLE_CAP) : '';
117const emit = (id, ms, title) => {
118 if (typeof id !== 'string' || !id) return;
119 const at = Number.isFinite(ms) ? Math.floor(ms) : 0;
120 process.stdout.write(clean(id) + '\t' + at + '\t' + clean(title) + '\n');
121};
122const json = (s) => { try { return JSON.parse(s); } catch (e) { return undefined; } };
123const text = (c) => typeof c === 'string' ? c : Array.isArray(c) ? c.map((p) => p && typeof p.text === 'string' ? p.text : '').join('') : '';
124const stat = (f) => { try { return fs.statSync(f); } catch (e) { return undefined; } };
125const list = (d) => { try { return fs.readdirSync(d); } catch (e) { return []; } };
126const small = (f) => { const s = stat(f); if (!s || !s.isFile() || s.size > LINE_CAP) return ''; try { return fs.readFileSync(f, 'utf8'); } catch (e) { return ''; } };
127const lines = (file, each) => {
128 let fd;
129 try { fd = fs.openSync(file, 'r'); } catch (e) { return; }
130 const buf = Buffer.alloc(1 << 16);
131 let parts = [], size = 0, over = false, total = 0;
132 const take = (piece) => { if (over) return; if (size + piece.length > LINE_CAP) { over = true; parts = []; size = 0; return; } parts.push(Buffer.from(piece)); size += piece.length; };
133 const end = () => { const line = over ? null : Buffer.concat(parts).toString('utf8'); parts = []; size = 0; over = false; return line === null ? undefined : each(line); };
134 try {
135 while (total < FILE_CAP) {
136 const n = fs.readSync(fd, buf, 0, buf.length, null);
137 if (n <= 0) break;
138 total += n;
139 let start = 0;
140 for (let i = 0; i < n; i++) {
141 if (buf[i] !== 10) continue;
142 take(buf.subarray(start, i));
143 start = i + 1;
144 if (end() === false) return;
145 }
146 take(buf.subarray(start, n));
147 }
148 if (size > 0) end();
149 } catch (e) {
150 } finally {
151 try { fs.closeSync(fd); } catch (e) {}
152 }
153};
154"#
155 };
156}
157
158const CLAUDE_CODE: &str = concat!(
172 prelude!(),
173 r#"try {
174 const found = [];
175 for (const w of workspaces) {
176 const dir = path.join(home, '.claude', 'projects', w.replace(/[^A-Za-z0-9]/g, '-'));
177 for (const name of list(dir)) found.push([dir, name]);
178 }
179 for (const [dir, name] of found) {
180 const file = path.join(dir, name), s = stat(file);
181 if (!name.endsWith('.jsonl') || !s || !s.isFile()) continue;
182 let custom = '', summary = '', first = '', any = false;
183 lines(file, (l) => {
184 any = any || l.includes('"sessionId"');
185 if (!l.includes('"custom-title"') && !l.includes('"summary"') && (first || !l.includes('"user"'))) return;
186 const r = json(l);
187 if (!r || typeof r !== 'object') return;
188 if (r.type === 'custom-title' && typeof r.customTitle === 'string') custom = r.customTitle;
189 else if (r.type === 'summary' && typeof r.summary === 'string') summary = r.summary;
190 else if (r.type === 'user' && !first && !r.isMeta && !r.isSidechain && r.message) {
191 const t = text(r.message.content).trim();
192 if (t && !t.startsWith('<')) first = t;
193 }
194 });
195 if (any) emit(name.slice(0, -'.jsonl'.length), s.mtimeMs, clean(custom) || clean(summary) || first);
196 }
197} catch (e) {}
198"#
199);
200
201const OPENCODE: &str = concat!(
213 prelude!(),
214 r#"try {
215 let out = '';
216 try {
217 out = require('child_process').execFileSync('opencode', ['session', 'list', '--format', 'json', '--pure'], {
218 cwd: workspace, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20, timeout: 60000,
219 });
220 } catch (e) {}
221 const all = json(out.slice(Math.max(out.indexOf('['), 0)));
222 for (const s of Array.isArray(all) ? all : []) {
223 if (!s || !here(s.directory)) continue;
224 const placeholder = typeof s.title === 'string' && /^(New|Child) session - \d{4}-\d\d-\d\dT[\d:.]+Z$/.test(s.title);
225 emit(s.id, s.updated, placeholder ? '' : s.title);
226 }
227} catch (e) {}
228"#
229);
230
231const GEMINI_CLI: &str = concat!(
245 prelude!(),
246 r#"try {
247 const root = path.join(home, '.gemini');
248 const known = json(small(path.join(root, 'projects.json')));
249 const slugs = [];
250 for (const w of workspaces) {
251 let slug = known && known.projects && typeof known.projects[w] === 'string' ? known.projects[w] : '';
252 if (!slug) slug = list(path.join(root, 'tmp')).find((d) => small(path.join(root, 'tmp', d, '.project_root')).trim() === w) || '';
253 if (slug && !slug.includes('/') && slug !== '.' && slug !== '..' && !slugs.includes(slug)) slugs.push(slug);
254 }
255 const found = [];
256 for (const slug of slugs) {
257 const dir = path.join(root, 'tmp', slug, 'chats');
258 for (const name of list(dir)) found.push([dir, name]);
259 }
260 const message = (m) => {
261 const t = text(m.content).trim();
262 const real = m.type === 'user'
263 ? t.length > 0 && !/^(\/|\?|<session_context>|<hook_context>)/.test(t)
264 : m.type === 'gemini' && (t.length > 0 || (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) || (Array.isArray(m.thoughts) && m.thoughts.length > 0));
265 return { id: m.id, user: m.type === 'user', text: t, real };
266 };
267 const messages = (all) => Array.isArray(all) ? all.filter((m) => m && typeof m.id === 'string').map(message) : [];
268 for (const [dir, name] of found) {
269 const file = path.join(dir, name), s = stat(file);
270 if (!name.startsWith('session-') || !/\.jsonl?$/.test(name) || !s || !s.isFile()) continue;
271 let meta = {}, said = [];
272 const record = (r) => {
273 if (!r || typeof r !== 'object') return;
274 if (typeof r.$rewindTo === 'string') {
275 const at = said.findIndex((m) => m.id === r.$rewindTo);
276 said = at >= 0 ? said.slice(0, at) : [];
277 } else if (typeof r.id === 'string') {
278 said.push(message(r));
279 } else if (r.$set && typeof r.$set === 'object') {
280 if (Array.isArray(r.$set.messages)) said = messages(r.$set.messages);
281 meta = { ...meta, ...r.$set, messages: undefined };
282 } else if (typeof r.sessionId === 'string' && typeof r.projectHash === 'string') {
283 said = said.concat(messages(r.messages));
284 meta = { ...meta, ...r, messages: undefined };
285 }
286 };
287 lines(file, (l) => { if (l.trim()) record(json(l)); });
288 if ((!meta.sessionId || !meta.projectHash) && s.size <= FILE_CAP / 4) {
289 meta = {};
290 said = [];
291 try { record(json(fs.readFileSync(file, 'utf8'))); } catch (e) {}
292 }
293 if (typeof meta.sessionId !== 'string' || (meta.kind !== undefined && meta.kind !== 'main') || !said.some((m) => m.real)) continue;
294 const first = said.find((m) => m.user && m.real);
295 const used = Date.parse(meta.lastUpdated) || Date.parse(meta.startTime) || s.mtimeMs;
296 emit(meta.sessionId, used, clean(meta.summary) || (first ? first.text : ''));
297 }
298} catch (e) {}
299"#
300);
301
302const CODEX: &str = concat!(
316 prelude!(),
317 r#"try {
318 const root = process.env.CODEX_HOME || path.join(home, '.codex');
319 const names = new Map();
320 lines(path.join(root, 'session_index.jsonl'), (l) => {
321 const r = json(l);
322 if (r && typeof r.id === 'string' && typeof r.thread_name === 'string') names.set(r.id, r.thread_name);
323 });
324 const injected = /^(<[A-Za-z_][\w-]*>|# [A-Za-z_-]+\.md instructions)/;
325 const walk = (dir, depth) => {
326 for (const name of list(dir)) {
327 const file = path.join(dir, name), s = stat(file);
328 if (!s) continue;
329 if (s.isDirectory()) { if (depth < 4) walk(file, depth + 1); continue; }
330 if (!s.isFile() || !name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue;
331 let id = '', first = '', seen = 0;
332 lines(file, (l) => {
333 const r = json(l);
334 if (seen++ === 0) {
335 const p = r && r.type === 'session_meta' && r.payload;
336 if (!p || !here(p.cwd) || typeof p.id !== 'string') return false;
337 id = p.id;
338 return names.has(id) ? false : undefined;
339 }
340 const p = r && r.payload;
341 if (!p) return;
342 let t = '';
343 if (r.type === 'event_msg' && p.type === 'user_message') t = typeof p.message === 'string' ? p.message.trim() : '';
344 else if (r.type === 'response_item' && p.type === 'message' && p.role === 'user') t = text(p.content).trim();
345 if (t && !injected.test(t)) { first = t; return false; }
346 });
347 if (id) emit(id, s.mtimeMs, clean(names.get(id)) || first);
348 }
349 };
350 walk(path.join(root, 'sessions'), 0);
351} catch (e) {}
352"#
353);
354
355const KIMI_CODE: &str = concat!(
364 prelude!(),
365 r#"try {
366 for (const w of workspaces) {
367 let out = '';
368 try {
369 out = require('child_process').execFileSync('kimi', ['session', 'list', '--cwd', w, '--json'], {
370 encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20, timeout: 60000,
371 });
372 } catch (e) {}
373 const all = json(out.slice(Math.max(out.indexOf('['), 0)));
374 for (const s of Array.isArray(all) ? all : []) {
375 if (!s || !here(s.workDir) || s.archived === true) continue;
376 if (s.metadata && s.metadata.child_session_kind === 'child') continue;
377 emit(s.id, s.updatedAt, clean(s.title) || clean(s.lastPrompt));
378 }
379 }
380} catch (e) {}
381"#
382);
383
384const QWEN_CODE: &str = concat!(
391 prelude!(),
392 r#"try {
393 for (const w of workspaces) {
394 let out = '';
395 try {
396 out = require('child_process').execFileSync('qwen', ['sessions', 'list', '--json', '--limit', '1000'], {
397 cwd: w, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20, timeout: 60000,
398 });
399 } catch (e) {}
400 for (const l of out.split('\n')) {
401 const s = json(l);
402 if (!s || typeof s !== 'object' || !here(s.cwd)) continue;
403 emit(s.sessionId, s.mtime, clean(s.customTitle) || clean(s.prompt));
404 }
405 }
406} catch (e) {}
407"#
408);
409
410impl HarnessKind {
411 #[must_use]
418 pub fn history_script(self) -> Option<&'static str> {
419 match self {
420 Self::ClaudeCode => Some(CLAUDE_CODE),
421 Self::OpenCode => Some(OPENCODE),
422 Self::GeminiCli => Some(GEMINI_CLI),
423 Self::Codex => Some(CODEX),
424 Self::KimiCode => Some(KIMI_CODE),
425 Self::QwenCode => Some(QWEN_CODE),
426 Self::AntigravityIde => None,
427 }
428 }
429
430 #[must_use]
439 pub fn conversation_paths(self) -> &'static [&'static str] {
440 match self {
441 Self::ClaudeCode => &[".claude/projects"],
442 Self::OpenCode => &[
443 ".local/share/opencode/opencode.db",
444 ".local/share/opencode/opencode.db-wal",
445 ".local/share/opencode/opencode.db-shm",
446 ],
447 Self::GeminiCli => &[".gemini/tmp", ".gemini/projects.json"],
448 Self::Codex => &[".codex/sessions", ".codex/session_index.jsonl"],
449 Self::KimiCode => &[".kimi-code/sessions", ".kimi-code/session_index.jsonl"],
452 Self::QwenCode => &[".qwen/projects"],
455 Self::AntigravityIde => &[".gemini/antigravity-ide"],
460 }
461 }
462
463 #[must_use]
470 pub fn conversation_database(self) -> Option<&'static str> {
471 match self {
472 Self::OpenCode => Some(".local/share/opencode/opencode.db"),
473 Self::ClaudeCode
474 | Self::GeminiCli
475 | Self::Codex
476 | Self::KimiCode
477 | Self::QwenCode
478 | Self::AntigravityIde => None,
479 }
480 }
481}
482
483#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct Reading {
487 pub state: EngineCommand,
489 pub start: EngineCommand,
491 pub wake: EngineCommand,
493 pub list: EngineCommand,
495}
496
497#[must_use]
503pub fn reading(engine: &Engine, container: &str, harness: HarnessKind) -> Option<Reading> {
504 let command = ["node", "-e", harness.history_script()?, CODE_DIR, LEGACY_CODE_DIR];
505 Some(Reading {
506 state: engine.container_state(container),
507 start: engine.start_container(container),
508 wake: engine.unpause_container(container),
509 list: engine.exec_without_terminal(&Exec { container, command: &command }),
510 })
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
515enum Steps {
516 Nothing,
519 List,
521 StartThenList,
523 WakeThenList,
528}
529
530fn steps(state: Option<&ContainerState>) -> Steps {
532 match state {
533 None => Steps::Nothing,
534 Some(ContainerState::Running) => Steps::List,
535 Some(ContainerState::Paused) => Steps::WakeThenList,
536 Some(_) => Steps::StartThenList,
537 }
538}
539
540pub fn read(engine: &Engine, container: &str, harness: HarnessKind) -> Result<Vec<Conversation>, EngineError> {
552 read_starting(engine, container, harness, &mut || {})
553}
554
555pub fn read_starting(
562 engine: &Engine,
563 container: &str,
564 harness: HarnessKind,
565 started: &mut dyn FnMut(),
566) -> Result<Vec<Conversation>, EngineError> {
567 let Some(reading) = reading(engine, container, harness) else { return Ok(Vec::new()) };
570 let state = capture(&reading.state).ok().map(|word| ContainerState::parse(&word));
572 match steps(state.as_ref()) {
573 Steps::Nothing => return Ok(Vec::new()),
574 Steps::List => {}
575 Steps::StartThenList => {
576 capture(&reading.start)?;
577 started();
578 }
579 Steps::WakeThenList => {
583 capture(&reading.wake)?;
584 }
585 }
586 Ok(parse(&capture(&reading.list)?))
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use crate::engine::EngineKind;
593
594 fn args(command: &EngineCommand) -> Vec<String> {
595 command.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect()
596 }
597
598 fn ids(found: &[Conversation]) -> Vec<&str> {
599 found.iter().map(|conversation| conversation.id.as_str()).collect()
600 }
601
602 fn script(harness: HarnessKind) -> &'static str {
603 harness.history_script().expect("a command-line harness has a script")
604 }
605
606 #[test]
607 fn reads_every_line_newest_first() {
608 let output = "2afe99eb-008a-4542-b160-1aa5b29bb95f\t1789746000000\tNamed by qcode\n\
609 ses_f4acc7e75ffeEArYIV9UJooqnn\t1789746449823\tMy explicit title\n\
610 01a0b53a-7904-7862-b8f4-02774d17df35\t1789746926000\t\n";
611 assert_eq!(
612 parse(output),
613 [
614 Conversation {
615 id: "01a0b53a-7904-7862-b8f4-02774d17df35".to_owned(),
616 title: None,
617 used_ms: 1_789_746_926_000
618 },
619 Conversation {
620 id: "ses_f4acc7e75ffeEArYIV9UJooqnn".to_owned(),
621 title: Some("My explicit title".to_owned()),
622 used_ms: 1_789_746_449_823
623 },
624 Conversation {
625 id: "2afe99eb-008a-4542-b160-1aa5b29bb95f".to_owned(),
626 title: Some("Named by qcode".to_owned()),
627 used_ms: 1_789_746_000_000
628 },
629 ]
630 );
631 }
632
633 #[test]
634 fn nothing_printed_is_no_conversations() {
635 assert_eq!(parse(""), []);
636 assert_eq!(parse("\n\n"), []);
637 }
638
639 #[test]
640 fn a_line_that_is_not_a_conversation_is_left_out() {
641 let output = "just words\n\
642 abc\tnot-a-time\ttitle\n\
643 \t1789746000000\tno id\n\
644 abc\t1789746000000.5\tfractional time\n\
645 abc\n\
646 good\t5\n\
647 Error: something went wrong\n";
648 let found = parse(output);
649 assert_eq!(ids(&found), ["good"]);
650 assert_eq!(found[0].title, None, "a line without a title field has no title");
651 }
652
653 #[test]
654 fn an_id_that_could_be_read_as_anything_but_an_id_is_refused() {
655 let output = "--dangerously-skip-permissions\t9\tan option\n\
656 -r\t9\ta short option\n\
657 _hidden\t9\tstarts with a mark\n\
658 two words\t9\twhite space\n\
659 a;rm -rf /\t9\ta shell\n\
660 ../../etc\t9\ta path\n\
661 \u{0430}bc\t9\tnot ascii\n\
662 $(id)\t9\ta substitution\n\
663 ok-1_2\t1\tfine\n";
664 assert_eq!(ids(&parse(output)), ["ok-1_2"]);
665 assert!(!is_safe_id(&"a".repeat(ID_CHARS + 1)));
666 assert!(is_safe_id(&"a".repeat(ID_CHARS)));
667 assert!(!is_safe_id(""));
668 }
669
670 #[test]
671 fn a_title_is_one_tidy_line_of_bounded_length() {
672 let output = "a\t3\t line one\u{1b}[31m\u{7}\r\u{85}line two\u{2028}end \n\
673 b\t2\t\u{1}\u{2} \u{7f}\n\
674 c\t1\t";
675 let long = "x".repeat(TITLE_CHARS * 50);
676 let output = format!("{output}{long}\nd\t0\t{}\n", "é".repeat(TITLE_CHARS + 3));
677 let found = parse(&output);
678 assert_eq!(found[0].title.as_deref(), Some("line one [31m line two end"));
679 assert_eq!(found[1].title, None, "nothing but control characters is no title");
680 assert_eq!(found[2].title.as_deref().map(str::len), Some(TITLE_CHARS));
681 assert_eq!(found[3].title.as_deref().map(|title| title.chars().count()), Some(TITLE_CHARS));
682 }
683
684 #[test]
685 fn a_tab_inside_a_title_stays_part_of_the_title() {
686 assert_eq!(parse("a\t1\tone\ttwo")[0].title.as_deref(), Some("one two"));
687 }
688
689 #[test]
690 fn an_id_named_twice_is_kept_once_with_its_newest_use() {
691 let output = "same\t10\told name\nother\t20\t\nsame\t30\tnew name\nsame\t5\toldest\n";
692 let found = parse(output);
693 assert_eq!(ids(&found), ["same", "other"]);
694 assert_eq!(found[0].title.as_deref(), Some("new name"));
695 assert_eq!(found[0].used_ms, 30);
696 }
697
698 #[test]
699 fn equal_times_keep_one_order_every_time() {
700 assert_eq!(ids(&parse("b\t1\t\na\t1\t\nc\t1\t\n")), ["a", "b", "c"]);
701 }
702
703 #[test]
704 fn a_time_before_the_epoch_or_far_ahead_still_sorts() {
705 assert_eq!(ids(&parse("past\t-5\t\nfuture\t9223372036854775807\t\nnow\t0\t\n")), ["future", "now", "past"]);
706 }
707
708 #[test]
709 fn every_harness_has_a_script_that_reads_the_workspace_it_is_given() {
710 for harness in HarnessKind::TERMINAL {
711 let script = harness.history_script().expect("a command-line harness has a script");
712 assert!(script.starts_with(prelude!()), "{harness:?}");
713 assert!(script.len() > prelude!().len(), "{harness:?} has only the prelude");
714 assert!(script.contains("process.argv[1]"), "{harness:?}");
715 assert!(script.contains("process.argv[2]"), "{harness:?} knows only one name for the workspace");
716 assert!(script.contains("emit("), "{harness:?} never prints");
717 assert!(!script.contains("process.exit("), "{harness:?}: an early exit could cut the output short");
718 }
719 }
720
721 #[test]
722 fn each_script_looks_where_its_harness_writes() {
723 assert!(script(HarnessKind::ClaudeCode).contains("'.claude', 'projects'"));
724 assert!(script(HarnessKind::OpenCode).contains("'session', 'list', '--format', 'json'"));
725 assert!(script(HarnessKind::GeminiCli).contains("'projects.json'"));
726 assert!(script(HarnessKind::Codex).contains("'session_index.jsonl'"));
727 }
728
729 #[test]
730 fn a_window_harness_has_no_list_and_nothing_is_run_to_find_that_out() {
731 assert_eq!(HarnessKind::AntigravityIde.history_script(), None);
735 let engine = Engine::new(EngineKind::Podman, "/usr/bin/podman");
736 assert_eq!(reading(&engine, "qcode-p-anti", HarnessKind::AntigravityIde), None);
737 }
738
739 #[test]
740 fn a_conversation_backup_takes_where_the_scripts_read_and_never_a_login() {
741 assert!(script(HarnessKind::ClaudeCode).contains("'.claude', 'projects'"));
742 assert!(script(HarnessKind::GeminiCli).contains("'.gemini'") && GEMINI_CLI.contains("'tmp'"));
743 assert!(script(HarnessKind::Codex).contains("'.codex'") && CODEX.contains("'sessions'"));
744 for harness in HarnessKind::ALL {
745 let paths = harness.conversation_paths();
746 assert!(!paths.is_empty(), "{harness:?}");
747 for path in paths {
748 assert!(!path.starts_with('/') && !path.starts_with('~') && !path.starts_with('-'), "{path}");
749 assert!(!path.split('/').any(|part| part == ".." || part == "." || part.is_empty()), "{path}");
750 for login in harness.record().identity {
751 let inside = |outer: &str, inner: &str| inner == outer || inner.starts_with(&format!("{outer}/"));
752 assert!(!inside(path, login) && !inside(login, path), "{harness:?}: {path} and {login}");
753 }
754 }
755 if let Some(database) = harness.conversation_database() {
756 for journal in ["", "-wal", "-shm"] {
757 assert!(paths.contains(&format!("{database}{journal}").as_str()), "{harness:?}: {journal}");
758 }
759 }
760 }
761 assert_eq!(HarnessKind::OpenCode.conversation_database(), Some(".local/share/opencode/opencode.db"));
762 }
763
764 #[test]
765 fn the_script_runs_without_a_terminal_in_the_workspace_folder() {
766 let engine = Engine::new(EngineKind::Podman, "/usr/bin/podman");
767 let reading = reading(&engine, "qcode-my-app-claude-sub", HarnessKind::ClaudeCode).expect("a script");
768 assert_eq!(
769 args(&reading.state),
770 ["container", "inspect", "--format", "{{.State.Status}}", "qcode-my-app-claude-sub"]
771 );
772 assert_eq!(args(&reading.start), ["start", "qcode-my-app-claude-sub"]);
773 let list = args(&reading.list);
774 assert_eq!(list[..4], ["exec", "qcode-my-app-claude-sub", "node", "-e"]);
775 assert_eq!(list[4], script(HarnessKind::ClaudeCode));
776 assert_eq!(list[5], CODE_DIR);
777 assert_eq!(list[6], LEGACY_CODE_DIR, "the name of before is handed over too, or old conversations vanish");
778 assert_eq!(list.len(), 7);
779 assert!(!list.iter().any(|arg| arg == "--tty" || arg == "--interactive" || arg == "--user"), "{list:?}");
780 }
781
782 #[test]
783 fn docker_reads_the_same_way() {
784 let engine = Engine::new(EngineKind::Docker, "/usr/bin/docker");
785 let reading = reading(&engine, "qcode-p-codex", HarnessKind::Codex).expect("a script");
786 assert_eq!(reading.list.program, std::path::Path::new("/usr/bin/docker"));
787 assert_eq!(args(&reading.list)[..2], ["exec", "qcode-p-codex"]);
788 }
789
790 #[test]
791 fn a_container_that_is_not_there_is_not_made_or_started() {
792 assert_eq!(steps(None), Steps::Nothing);
793 }
794
795 #[test]
796 fn a_running_container_is_only_read() {
797 assert_eq!(steps(Some(&ContainerState::Running)), Steps::List);
798 }
799
800 #[test]
801 fn a_stopped_container_is_started_before_it_is_read() {
802 for state in [ContainerState::Exited, ContainerState::Created, ContainerState::Unknown("stopping".to_owned())] {
803 assert_eq!(steps(Some(&state)), Steps::StartThenList, "{state:?}");
804 }
805 }
806
807 #[test]
808 fn a_frozen_container_is_woken_before_it_is_read_and_never_started() {
809 assert_eq!(steps(Some(&ContainerState::Paused)), Steps::WakeThenList);
813 let engine = Engine::new(EngineKind::Podman, "/usr/bin/podman");
814 let reading = reading(&engine, "qcode-p-codex", HarnessKind::Codex).expect("a script");
815 assert_eq!(args(&reading.wake), ["unpause", "qcode-p-codex"], "the reading wakes the container");
816 }
817
818 #[test]
819 fn an_engine_that_is_not_there_has_no_container_to_read() {
820 let engine = Engine::new(EngineKind::Podman, "/qcode/no/such/engine");
823 assert_eq!(read(&engine, "qcode-p-codex", HarnessKind::Codex).expect("nothing to read"), []);
824 }
825}