Skip to main content

ai_session_search/
source.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! Canonical provider discovery and public source inventory.
5
6use 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
23/// Providers supported by discovery, in stable presentation order.
24pub 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
36/// Stable fingerprint of every provider's parser contract.
37///
38/// The provider registry owns membership/order and `provider_parse_version` owns each version;
39/// hashing those two canonical definitions avoids a second manually bumped generation. The first
40/// call is `O(P + V)` for `P = 9` providers and `V` version bytes; `OnceLock` makes later calls
41/// `O(1)`. Retained memory is one 32-byte value.
42pub(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/// Effective discovery configuration and current file count for one provider.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct ProviderSourceStatus {
67    /// Provider represented by this status.
68    pub provider: Provider,
69    /// Whether discovery and indexing are enabled for this provider.
70    pub enabled: bool,
71    /// Effective normalized roots searched for session files.
72    pub roots: Vec<String>,
73    /// Number of files currently discoverable beneath `roots`.
74    pub discovered_files: usize,
75    /// Non-fatal discovery failures. Readable sources remain available when this is non-empty.
76    pub warnings: Vec<ProviderDiscoveryWarning>,
77}
78
79/// One non-fatal filesystem or provider-sidecar discovery failure.
80#[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    /// Parse one discovered file with the adapter that owns its provider.
131    ///
132    /// One dispatch shared by the indexer and by `diagnostics::explain_unindexed`, so a
133    /// diagnosis of why a file produced no session parses it exactly as indexing would. A
134    /// second copy of this match would let the two disagree, which is precisely the failure a
135    /// reconciliation diagnostic exists to rule out.
136    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
363/// Discover enabled providers and report every provider's effective configuration.
364pub 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            // Named the way pi names a transcript, after the session it holds. Discovery does not
729            // require that, but a fixture that matches the real layout is the one worth testing.
730            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            // Deliberately reverse lexical order: first encounter, not sorting, is canonical.
754            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}