1use std::collections::HashSet;
7use std::io::{BufReader, Read};
8use std::sync::OnceLock;
9
10use anyhow::{bail, Result};
11use serde::Serialize;
12
13use crate::config::Config;
14use crate::hashing::FramedSha256;
15use crate::models::{Provider, SourceFile};
16use crate::providers::{
17 aistudio::AiStudioAdapter, antigravity::AntigravityAdapter, claude::ClaudeAdapter,
18 codex::CodexAdapter, cursor::CursorAdapter, gemini_cli::GeminiCliAdapter, pi::PiAdapter,
19 ProviderDiscovery,
20};
21use crate::util::normalize_path;
22
23pub const PROVIDERS: [Provider; 9] = [
25 Provider::Claude,
26 Provider::ClaudeDesktop,
27 Provider::Codex,
28 Provider::Cursor,
29 Provider::Antigravity,
30 Provider::Pi,
31 Provider::PrimeAgent,
32 Provider::AiStudio,
33 Provider::GeminiCli,
34];
35
36pub(crate) fn provider_parse_contract_fingerprint() -> [i64; 4] {
43 static FINGERPRINT: OnceLock<[i64; 4]> = OnceLock::new();
44 *FINGERPRINT.get_or_init(|| {
45 provider_parse_contract_fingerprint_from(
46 PROVIDERS
47 .into_iter()
48 .map(|provider| (provider, crate::util::provider_parse_version(provider))),
49 )
50 })
51}
52
53fn provider_parse_contract_fingerprint_from<'a>(
54 contracts: impl IntoIterator<Item = (Provider, &'a str)>,
55) -> [i64; 4] {
56 let mut digest = FramedSha256::new(b"aise-provider-parse-contract-v1");
57 for (provider, version) in contracts {
58 digest.update_bytes(provider.as_str().as_bytes());
59 digest.update_bytes(version.as_bytes());
60 }
61 digest.finish_i64_words()
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct ProviderSourceStatus {
67 pub provider: Provider,
69 pub enabled: bool,
71 pub roots: Vec<String>,
73 pub discovered_files: usize,
75 pub warnings: Vec<ProviderDiscoveryWarning>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
81pub struct ProviderDiscoveryWarning {
82 pub provider: Provider,
83 pub path: String,
84 pub operation: String,
85 pub message: String,
86 pub readable_sources_preserved: bool,
87 pub verification_command: String,
88 pub guidance: String,
89}
90
91#[derive(Debug, Default)]
92pub(crate) struct DiscoveryResult {
93 pub(crate) sources: Vec<SourceFile>,
94 pub(crate) warnings: Vec<ProviderDiscoveryWarning>,
95}
96
97pub(crate) struct SourceInventory {
98 pub(crate) providers: Vec<ProviderSourceStatus>,
99 pub(crate) discovered: HashSet<(Provider, String)>,
100 pub(crate) warnings: Vec<ProviderDiscoveryWarning>,
101}
102
103pub(crate) struct ProviderSet {
104 pub(crate) claude: ClaudeAdapter,
105 claude_desktop: ClaudeAdapter,
106 pub(crate) codex: CodexAdapter,
107 pub(crate) cursor: CursorAdapter,
108 pub(crate) antigravity: AntigravityAdapter,
109 pub(crate) pi: PiAdapter,
110 pub(crate) prime_agent: PiAdapter,
111 pub(crate) aistudio: AiStudioAdapter,
112 pub(crate) gemini_cli: GeminiCliAdapter,
113}
114
115impl ProviderSet {
116 pub(crate) fn new(config: &Config) -> Self {
117 Self {
118 claude: ClaudeAdapter::new(provider_roots(config, Provider::Claude)),
119 claude_desktop: ClaudeAdapter::new(provider_roots(config, Provider::ClaudeDesktop)),
120 codex: CodexAdapter::new(provider_roots(config, Provider::Codex)),
121 cursor: CursorAdapter::new(provider_roots(config, Provider::Cursor)),
122 antigravity: AntigravityAdapter::new(provider_roots(config, Provider::Antigravity)),
123 pi: PiAdapter::new(provider_roots(config, Provider::Pi)),
124 prime_agent: PiAdapter::prime_agent(provider_roots(config, Provider::PrimeAgent)),
125 aistudio: AiStudioAdapter::new(provider_roots(config, Provider::AiStudio)),
126 gemini_cli: GeminiCliAdapter::new(provider_roots(config, Provider::GeminiCli)),
127 }
128 }
129
130 pub(crate) fn parse(&self, source: &SourceFile) -> Result<crate::models::ParsedSession> {
137 self.parse_until(source, &|| false)?
138 .ok_or_else(|| anyhow::anyhow!("provider parse cancelled with a non-cancelling token"))
139 }
140
141 pub(crate) fn parse_until(
142 &self,
143 source: &SourceFile,
144 should_cancel: &dyn Fn() -> bool,
145 ) -> Result<Option<crate::models::ParsedSession>> {
146 if should_cancel() {
147 return Ok(None);
148 }
149 let parsed = match source.provider {
150 Provider::Claude | Provider::ClaudeDesktop => {
151 parse_jsonl_until(source, should_cancel, |reader| {
152 self.claude.parse_reader(reader, &source.path)
153 })
154 }
155 Provider::Codex => parse_jsonl_until(source, should_cancel, |reader| {
156 self.codex.parse_reader(reader, &source.path)
157 }),
158 Provider::Cursor => parse_jsonl_until(source, should_cancel, |reader| {
159 self.cursor.parse_reader(reader, &source.path)
160 }),
161 Provider::Antigravity => parse_jsonl_until(source, should_cancel, |reader| {
162 self.antigravity.parse_reader(reader, &source.path)
163 }),
164 Provider::Pi => parse_jsonl_until(source, should_cancel, |reader| {
165 self.pi.parse_reader(reader, &source.path)
166 }),
167 Provider::PrimeAgent => parse_jsonl_until(source, should_cancel, |reader| {
168 self.prime_agent.parse_reader(reader, &source.path)
169 }),
170 Provider::AiStudio => read_snapshot_until(source, should_cancel, |raw| {
171 self.aistudio.parse_raw(&source.path, raw)
172 }),
173 Provider::GeminiCli => read_snapshot_until(source, should_cancel, |raw| {
174 self.gemini_cli.parse_raw(&source.path, raw)
175 }),
176 };
177 if should_cancel() {
178 return Ok(None);
179 }
180 let parsed = parsed?;
181 if let Some(reason) = total_parse_failure_reason(&parsed) {
182 bail!("{reason}");
183 }
184 Ok(Some(parsed))
185 }
186
187 pub(crate) fn discover_enabled(&self, config: &Config) -> DiscoveryResult {
188 let mut discovered = DiscoveryResult::default();
189 if config.providers.claude.enabled {
190 discovered.extend_provider(Provider::Claude, self.claude.discover_with_warnings());
191 }
192 if config.providers.claude_desktop.enabled {
193 discovered.extend_provider(
194 Provider::ClaudeDesktop,
195 self.claude_desktop.discover_with_warnings(),
196 );
197 }
198 if config.providers.codex.enabled {
199 discovered.extend_provider(Provider::Codex, self.codex.discover_with_warnings());
200 }
201 if config.providers.cursor.enabled {
202 discovered.extend_provider(Provider::Cursor, self.cursor.discover_with_warnings());
203 }
204 if config.providers.antigravity.enabled {
205 discovered.extend_provider(
206 Provider::Antigravity,
207 self.antigravity.discover_with_warnings(),
208 );
209 }
210 if config.providers.pi.enabled {
211 discovered.extend_provider(Provider::Pi, self.pi.discover_with_warnings());
212 }
213 if config.providers.prime_agent.enabled {
214 discovered.extend_provider(
215 Provider::PrimeAgent,
216 self.prime_agent.discover_with_warnings(),
217 );
218 }
219 if config.providers.aistudio.enabled {
220 discovered.extend_provider(Provider::AiStudio, self.aistudio.discover_with_warnings());
221 }
222 if config.providers.gemini_cli.enabled {
223 discovered.extend_provider(
224 Provider::GeminiCli,
225 self.gemini_cli.discover_with_warnings(),
226 );
227 }
228 deduplicate_warnings(&mut discovered.warnings);
229 discovered
230 }
231}
232
233struct CancellationReader<'a, R> {
234 inner: R,
235 should_cancel: &'a dyn Fn() -> bool,
236}
237
238impl<R: Read> Read for CancellationReader<'_, R> {
239 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
240 if (self.should_cancel)() {
241 return Err(std::io::Error::other("session parse cancelled"));
242 }
243 self.inner.read(buffer)
244 }
245}
246
247fn parse_jsonl_until(
248 source: &SourceFile,
249 should_cancel: &dyn Fn() -> bool,
250 parse: impl FnOnce(
251 BufReader<CancellationReader<'_, std::fs::File>>,
252 ) -> Result<crate::models::ParsedSession>,
253) -> Result<crate::models::ParsedSession> {
254 let file = std::fs::File::open(&source.path)?;
255 parse(BufReader::new(CancellationReader {
256 inner: file,
257 should_cancel,
258 }))
259}
260
261fn read_snapshot_until(
262 source: &SourceFile,
263 should_cancel: &dyn Fn() -> bool,
264 parse: impl FnOnce(String) -> Result<crate::models::ParsedSession>,
265) -> Result<crate::models::ParsedSession> {
266 let file = std::fs::File::open(&source.path)?;
267 let mut reader = CancellationReader {
268 inner: file,
269 should_cancel,
270 };
271 let mut raw = String::new();
272 reader.read_to_string(&mut raw)?;
273 parse(raw)
274}
275
276fn total_parse_failure_reason(parsed: &crate::models::ParsedSession) -> Option<String> {
277 if parsed.session.preview_text == "(parse failed)" {
278 return Some(
279 parsed
280 .session
281 .parse_warning
282 .clone()
283 .unwrap_or_else(|| "provider parser failed without a reason".to_string()),
284 );
285 }
286 let metadata = parsed.session.raw_metadata_json.as_deref()?;
287 let metadata: serde_json::Value = serde_json::from_str(metadata).ok()?;
288 let malformed = metadata
289 .get("malformed_line_count")
290 .and_then(serde_json::Value::as_u64)
291 .unwrap_or(0);
292 let valid_records = metadata
293 .get("valid_record_count")
294 .and_then(serde_json::Value::as_u64)
295 .unwrap_or(0);
296 (malformed > 0 && valid_records == 0).then(|| {
297 parsed
298 .session
299 .parse_warning
300 .clone()
301 .unwrap_or_else(|| "session contained no parseable JSONL records".to_string())
302 })
303}
304
305impl DiscoveryResult {
306 fn extend_provider(&mut self, provider: Provider, discovered: ProviderDiscovery) {
307 let readable_sources_preserved = !discovered.sources.is_empty();
308 self.warnings
309 .extend(discovered.warnings.into_iter().map(|warning| {
310 let verification_command = "aise doctor --format json".to_string();
311 ProviderDiscoveryWarning {
312 provider,
313 path: normalize_path(&warning.path),
314 operation: warning.operation.to_string(),
315 message: warning.message,
316 readable_sources_preserved,
317 guidance: discovery_warning_guidance(
318 readable_sources_preserved,
319 &verification_command,
320 ),
321 verification_command,
322 }
323 }));
324 let mut seen = self
325 .sources
326 .iter()
327 .map(source_identity)
328 .collect::<HashSet<_>>();
329 for source in discovered.sources {
330 if seen.insert(source_identity(&source)) {
331 self.sources.push(source);
332 }
333 }
334 }
335}
336
337fn discovery_warning_guidance(preserved: bool, verification_command: &str) -> String {
338 let preservation = if preserved {
339 "Readable sources from this provider were preserved for indexing."
340 } else {
341 "No readable sources from this provider were discovered."
342 };
343 format!("{preservation} Run `{verification_command}` to verify discovery status.")
344}
345
346fn source_identity(source: &SourceFile) -> (Provider, String) {
347 let path = std::fs::canonicalize(&source.path).unwrap_or_else(|_| source.path.clone());
348 (source.provider, normalize_path(&path))
349}
350
351fn deduplicate_warnings(warnings: &mut Vec<ProviderDiscoveryWarning>) {
352 let mut seen = HashSet::new();
353 warnings.retain(|warning| {
354 seen.insert((
355 warning.provider,
356 warning.path.clone(),
357 warning.operation.clone(),
358 warning.message.clone(),
359 ))
360 });
361}
362
363pub fn inventory(config: &Config) -> Vec<ProviderSourceStatus> {
365 inventory_snapshot(config).providers
366}
367
368pub(crate) fn inventory_snapshot(config: &Config) -> SourceInventory {
369 let DiscoveryResult { sources, warnings } = ProviderSet::new(config).discover_enabled(config);
370 let providers = PROVIDERS
371 .into_iter()
372 .map(|provider| ProviderSourceStatus {
373 provider,
374 enabled: provider_enabled(config, provider),
375 roots: provider_roots(config, provider)
376 .into_iter()
377 .map(|path| normalize_path(&path))
378 .collect(),
379 discovered_files: sources
380 .iter()
381 .filter(|source| source.provider == provider)
382 .count(),
383 warnings: warnings
384 .iter()
385 .filter(|warning| warning.provider == provider)
386 .cloned()
387 .collect(),
388 })
389 .collect();
390 let discovered = sources
391 .into_iter()
392 .map(|source| (source.provider, normalize_path(&source.path)))
393 .collect();
394 SourceInventory {
395 providers,
396 discovered,
397 warnings,
398 }
399}
400
401pub(crate) fn provider_enabled(config: &Config, provider: Provider) -> bool {
402 match provider {
403 Provider::Claude => config.providers.claude.enabled,
404 Provider::ClaudeDesktop => config.providers.claude_desktop.enabled,
405 Provider::Codex => config.providers.codex.enabled,
406 Provider::Cursor => config.providers.cursor.enabled,
407 Provider::Antigravity => config.providers.antigravity.enabled,
408 Provider::Pi => config.providers.pi.enabled,
409 Provider::PrimeAgent => config.providers.prime_agent.enabled,
410 Provider::AiStudio => config.providers.aistudio.enabled,
411 Provider::GeminiCli => config.providers.gemini_cli.enabled,
412 }
413}
414
415pub(crate) fn provider_roots(config: &Config, provider: Provider) -> Vec<std::path::PathBuf> {
416 let configured = match provider {
417 Provider::Claude => config.claude_paths(),
418 Provider::ClaudeDesktop => config.claude_desktop_paths(),
419 Provider::Codex => config.codex_paths(),
420 Provider::Cursor => config.cursor_paths(),
421 Provider::Antigravity => config.antigravity_paths(),
422 Provider::Pi => config.pi_paths(),
423 Provider::PrimeAgent => config.prime_agent_paths(),
424 Provider::AiStudio => config.aistudio_paths(),
425 Provider::GeminiCli => config.gemini_cli_paths(),
426 };
427 canonical_unique_roots(configured)
428}
429
430fn canonical_unique_roots(paths: Vec<std::path::PathBuf>) -> Vec<std::path::PathBuf> {
431 let mut seen = HashSet::new();
432 paths
433 .into_iter()
434 .filter_map(|path| {
435 let identity = std::fs::canonicalize(&path).unwrap_or(path);
436 seen.insert(identity.clone()).then_some(identity)
437 })
438 .collect()
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn provider_parse_stops_inside_a_large_source_when_cancelled() {
447 let dir = tempfile::tempdir().unwrap();
448 let path = dir.path().join("large.jsonl");
449 let content = format!(
450 "{{\"type\":\"user\",\"sessionId\":\"cancel\",\"message\":{{\"role\":\"user\",\"content\":{}}}}}\n",
451 serde_json::to_string(&"x".repeat(128 * 1024)).unwrap()
452 );
453 std::fs::write(&path, content).unwrap();
454 let source = SourceFile {
455 provider: Provider::Claude,
456 path: path.clone(),
457 mtime_ns: 0,
458 size_bytes: std::fs::metadata(&path).unwrap().len() as i64,
459 };
460 let config = Config::default();
461 let providers = ProviderSet::new(&config);
462 let checks = std::cell::Cell::new(0usize);
463
464 let parsed = providers
465 .parse_until(&source, &|| {
466 let next = checks.get() + 1;
467 checks.set(next);
468 next >= 4
469 })
470 .unwrap();
471
472 assert!(
473 parsed.is_none(),
474 "cancellation must not publish a partial parse"
475 );
476 assert!(checks.get() >= 4, "the token was checked during file reads");
477 }
478
479 #[test]
480 fn snapshot_parse_stops_inside_a_large_source_read_when_cancelled() {
481 let dir = tempfile::tempdir().unwrap();
482 let path = dir.path().join("large.md");
483 std::fs::write(&path, "x".repeat(128 * 1024)).unwrap();
484 let source = SourceFile {
485 provider: Provider::AiStudio,
486 path: path.clone(),
487 mtime_ns: 0,
488 size_bytes: std::fs::metadata(&path).unwrap().len() as i64,
489 };
490 let providers = ProviderSet::new(&Config::default());
491 let checks = std::cell::Cell::new(0usize);
492
493 let parsed = providers
494 .parse_until(&source, &|| {
495 let next = checks.get() + 1;
496 checks.set(next);
497 next >= 4
498 })
499 .unwrap();
500
501 assert!(parsed.is_none());
502 assert!(checks.get() >= 4);
503 }
504
505 #[test]
506 fn valid_metadata_record_is_not_a_total_failure_when_another_line_is_malformed() {
507 let dir = tempfile::tempdir().unwrap();
508 let path = dir.path().join("metadata-only.jsonl");
509 std::fs::write(&path, "{\"type\":\"metadata\"}\n{partial\n").unwrap();
510 let source = SourceFile {
511 provider: Provider::Claude,
512 path: path.clone(),
513 mtime_ns: 0,
514 size_bytes: std::fs::metadata(&path).unwrap().len() as i64,
515 };
516
517 let parsed = ProviderSet::new(&Config::default()).parse(&source).unwrap();
518
519 assert!(parsed.messages.is_empty());
520 assert_eq!(
521 parsed.session.parse_warning.as_deref(),
522 Some("skipped 1 malformed JSONL record")
523 );
524 }
525
526 fn disable_all(config: &mut Config) {
527 config.providers.claude.enabled = false;
528 config.providers.claude_desktop.enabled = false;
529 config.providers.codex.enabled = false;
530 config.providers.cursor.enabled = false;
531 config.providers.antigravity.enabled = false;
532 config.providers.pi.enabled = false;
533 config.providers.prime_agent.enabled = false;
534 config.providers.aistudio.enabled = false;
535 config.providers.gemini_cli.enabled = false;
536 }
537
538 #[test]
539 fn parser_contract_fingerprint_changes_with_membership_order_or_version() {
540 let baseline = provider_parse_contract_fingerprint_from([
541 (Provider::Claude, "claude-v4"),
542 (Provider::Codex, "codex-v5"),
543 ]);
544 assert_ne!(
545 baseline,
546 provider_parse_contract_fingerprint_from([
547 (Provider::Claude, "claude-v5"),
548 (Provider::Codex, "codex-v5"),
549 ])
550 );
551 assert_ne!(
552 baseline,
553 provider_parse_contract_fingerprint_from([
554 (Provider::Codex, "codex-v5"),
555 (Provider::Claude, "claude-v4"),
556 ])
557 );
558 }
559
560 #[test]
561 fn inventory_includes_all_disabled_providers_without_discovery() {
562 let mut config = Config::default();
563 disable_all(&mut config);
564
565 let statuses = inventory(&config);
566
567 assert_eq!(statuses.len(), PROVIDERS.len());
568 assert!(statuses.iter().all(|status| !status.enabled));
569 assert!(statuses.iter().all(|status| status.discovered_files == 0));
570 }
571
572 #[test]
573 fn inventory_discovers_enabled_snapshot_providers() {
574 let dir = tempfile::tempdir().unwrap();
575 let aistudio = dir.path().join("aistudio");
576 let gemini = dir.path().join("gemini");
577 std::fs::create_dir_all(&aistudio).unwrap();
578 std::fs::create_dir_all(gemini.join("project/chats")).unwrap();
579 std::fs::write(aistudio.join("chat.json"), "{}").unwrap();
580 std::fs::write(gemini.join("project/chats/session-one.json"), "{}").unwrap();
581 let mut config = Config::default();
582 disable_all(&mut config);
583 config.providers.aistudio.enabled = true;
584 config.providers.aistudio.paths = vec![aistudio.to_string_lossy().into_owned()];
585 config.providers.gemini_cli.enabled = true;
586 config.providers.gemini_cli.paths = vec![gemini.to_string_lossy().into_owned()];
587
588 let statuses = inventory(&config);
589
590 assert_eq!(
591 statuses
592 .iter()
593 .find(|status| status.provider == Provider::AiStudio)
594 .unwrap()
595 .discovered_files,
596 1
597 );
598 assert_eq!(
599 statuses
600 .iter()
601 .find(|status| status.provider == Provider::GeminiCli)
602 .unwrap()
603 .discovered_files,
604 1
605 );
606 }
607
608 #[test]
609 fn inventory_canonicalizes_alias_roots_before_discovery() {
610 let dir = tempfile::tempdir().unwrap();
611 let root = dir.path().join("claude");
612 std::fs::create_dir_all(&root).unwrap();
613 std::fs::write(root.join("chat.json"), "{}").unwrap();
614 let mut config = Config::default();
615 disable_all(&mut config);
616 config.providers.aistudio.enabled = true;
617 config.providers.aistudio.paths = vec![
618 root.to_string_lossy().into_owned(),
619 root.join(".").to_string_lossy().into_owned(),
620 ];
621
622 let status = inventory(&config)
623 .into_iter()
624 .find(|status| status.provider == Provider::AiStudio)
625 .unwrap();
626
627 assert_eq!(status.roots.len(), 1);
628 assert_eq!(status.discovered_files, 1);
629 }
630
631 #[test]
632 fn discovery_deduplicates_alias_sources_in_first_encounter_order() {
633 let dir = tempfile::tempdir().unwrap();
634 let root = dir.path().join("aistudio");
635 std::fs::create_dir_all(&root).unwrap();
636 let first = root.join("first.json");
637 let second = root.join("second.json");
638 std::fs::write(&first, "{}").unwrap();
639 std::fs::write(&second, "{}").unwrap();
640 let first = std::fs::canonicalize(first).unwrap();
641 let second = std::fs::canonicalize(second).unwrap();
642 let mut config = Config::default();
643 disable_all(&mut config);
644 config.providers.aistudio.enabled = true;
645 config.providers.aistudio.paths = vec![
646 root.to_string_lossy().into_owned(),
647 root.join(".").to_string_lossy().into_owned(),
648 ];
649
650 let discovered = ProviderSet::new(&config).discover_enabled(&config);
651
652 assert_eq!(discovered.sources.len(), 2);
653 assert_eq!(discovered.sources[0].path, first);
654 assert_eq!(discovered.sources[1].path, second);
655 }
656
657 #[cfg(unix)]
658 #[test]
659 fn discovery_keeps_readable_sources_and_reports_denied_subtrees() {
660 use std::os::unix::fs::PermissionsExt as _;
661
662 let dir = tempfile::tempdir().unwrap();
663 let root = dir.path().join("aistudio");
664 let denied = root.join("denied");
665 std::fs::create_dir_all(&denied).unwrap();
666 let readable = root.join("readable.jsonl");
667 std::fs::write(&readable, "{}").unwrap();
668 let readable = std::fs::canonicalize(readable).unwrap();
669 std::fs::write(denied.join("hidden.jsonl"), "{}").unwrap();
670 std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o000)).unwrap();
671 let mut config = Config::default();
672 disable_all(&mut config);
673 config.providers.claude.enabled = true;
674 config.providers.claude.paths = vec![root.to_string_lossy().into_owned()];
675
676 let discovered = ProviderSet::new(&config).discover_enabled(&config);
677
678 std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o700)).unwrap();
679 assert!(discovered
680 .sources
681 .iter()
682 .any(|source| source.path == readable));
683 assert!(discovered.warnings.iter().any(|warning| {
684 warning.provider == Provider::Claude
685 && warning.path.contains("denied")
686 && warning.operation == "traverse"
687 && warning.readable_sources_preserved
688 && warning.verification_command == "aise doctor --format json"
689 && warning.guidance.contains("preserved for indexing")
690 }));
691 }
692
693 #[test]
694 fn missing_discovery_roots_remain_quiet() {
695 let dir = tempfile::tempdir().unwrap();
696 let mut config = Config::default();
697 disable_all(&mut config);
698 config.providers.aistudio.enabled = true;
699 config.providers.aistudio.paths = vec![dir.path().join("absent").display().to_string()];
700
701 let discovered = ProviderSet::new(&config).discover_enabled(&config);
702
703 assert!(discovered.sources.is_empty());
704 assert!(discovered.warnings.is_empty());
705 }
706
707 fn provider_fixture_path(
708 provider: Provider,
709 root: &std::path::Path,
710 label: &str,
711 ) -> std::path::PathBuf {
712 let path = match provider {
713 Provider::Claude => root.join(format!("{label}.jsonl")),
714 Provider::ClaudeDesktop => root
715 .join("local-agent-mode-sessions")
716 .join(format!("local_{label}"))
717 .join("audit.jsonl"),
718 Provider::Codex => root.join(format!(
719 "rollout-2026-08-04T00-00-00-019efd97-d602-7922-89dd-46727210650{}.jsonl",
720 if label == "first" { "5" } else { "6" }
721 )),
722 Provider::Cursor => root
723 .join("agent-transcripts")
724 .join(format!("{label}.jsonl")),
725 Provider::Antigravity => root
726 .join(label)
727 .join(".system_generated/logs/transcript.jsonl"),
728 Provider::Pi => root.join(label).join(format!(
731 "2026-06-18T17-31-17-343Z_019edbc9-83df-72a0-a95b-64e6d810ad7{}.jsonl",
732 if label == "first" { "5" } else { "6" }
733 )),
734 Provider::PrimeAgent => root.join(format!(
735 "019fea39-38c2-710e-8100-3624dfc0ac0{}.jsonl",
736 if label == "first" { "7" } else { "8" }
737 )),
738 Provider::AiStudio => root.join(format!("{label}.json")),
739 Provider::GeminiCli => root
740 .join(label)
741 .join("chats")
742 .join(format!("session-{label}.json")),
743 };
744 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
745 std::fs::write(&path, "{}").unwrap();
746 path
747 }
748
749 #[test]
750 fn every_provider_discovers_two_root_ordered_union_once() {
751 for provider in PROVIDERS {
752 let dir = tempfile::tempdir().unwrap();
753 let first_root = dir.path().join("z-first");
755 let second_root = dir.path().join("a-second");
756 std::fs::create_dir_all(&first_root).unwrap();
757 std::fs::create_dir_all(&second_root).unwrap();
758 let first =
759 std::fs::canonicalize(provider_fixture_path(provider, &first_root, "first"))
760 .unwrap();
761 let second =
762 std::fs::canonicalize(provider_fixture_path(provider, &second_root, "second"))
763 .unwrap();
764 let paths = vec![
765 first_root.display().to_string(),
766 first_root.join(".").display().to_string(),
767 second_root.display().to_string(),
768 ];
769 let mut config = Config::default();
770 disable_all(&mut config);
771 let provider_config = match provider {
772 Provider::Claude => &mut config.providers.claude,
773 Provider::ClaudeDesktop => &mut config.providers.claude_desktop,
774 Provider::Codex => &mut config.providers.codex,
775 Provider::Cursor => &mut config.providers.cursor,
776 Provider::Antigravity => &mut config.providers.antigravity,
777 Provider::Pi => &mut config.providers.pi,
778 Provider::PrimeAgent => &mut config.providers.prime_agent,
779 Provider::AiStudio => &mut config.providers.aistudio,
780 Provider::GeminiCli => &mut config.providers.gemini_cli,
781 };
782 provider_config.enabled = true;
783 provider_config.paths = paths;
784
785 let discovered = ProviderSet::new(&config).discover_enabled(&config);
786
787 assert!(
788 discovered.warnings.is_empty(),
789 "{provider}: {:?}",
790 discovered.warnings
791 );
792 assert_eq!(
793 discovered
794 .sources
795 .iter()
796 .map(|source| (source.provider, source.path.clone()))
797 .collect::<Vec<_>>(),
798 vec![(provider, first), (provider, second)],
799 "{provider} must preserve configured-root encounter order and dedupe aliases"
800 );
801 }
802 }
803}