Skip to main content

ai_session_search/
lib.rs

1#![recursion_limit = "256"]
2// SPDX-FileCopyrightText: 2026 Andrew Hundt
3// SPDX-License-Identifier: Apache-2.0
4
5pub mod analysis_pipeline;
6pub mod analysis_publication;
7pub mod analytics;
8mod background_refresh;
9mod cli;
10pub mod config;
11pub(crate) mod corrections;
12pub mod dates;
13pub mod db;
14pub mod diagnostics;
15mod durable_fs;
16pub(crate) mod durable_path;
17mod executable_alias;
18pub mod export;
19pub mod files;
20mod fts;
21pub mod hashing;
22pub mod indexer;
23pub mod inspect;
24pub(crate) mod integrations;
25pub mod keymap;
26pub mod mcp_schema_budget;
27pub mod mcp_server;
28pub(crate) mod message_classification;
29pub mod message_search;
30mod message_search_batches;
31pub mod messages;
32pub mod migration;
33pub mod models;
34pub mod terminal_style;
35mod text_file_transaction;
36// Safety guard (plan H8): the provider parse path must never `.unwrap()` on
37// non-test code — a single malformed session file would abort the whole reindex.
38// Errors there must flow through `minimal_record` (util.rs) instead. Scoped to
39// `not(test)` so the providers' test fixtures may still use `.unwrap()` freely.
40#[cfg_attr(not(test), warn(clippy::unwrap_used))]
41pub mod providers;
42pub mod refs;
43pub mod render;
44pub mod runtime;
45pub mod search_scope;
46pub mod service;
47pub mod skill_catalog;
48pub(crate) mod skill_manifest;
49pub mod skill_run;
50pub mod skills;
51pub mod source;
52mod sql_functions;
53pub mod sql_query;
54pub mod tail;
55pub mod trigram;
56pub mod trigram_index;
57mod tui;
58pub(crate) mod update;
59pub mod util;
60
61// Curated application API. Module paths remain stable for existing consumers, while new callers
62// can discover and import the supported service/query/publication surface from the crate root
63// without traversing storage, CLI, MCP, provider, or PyO3 implementation modules.
64pub use analysis_pipeline::{
65    AnalysisPolicySpec, AnalysisResult, ClassificationRuleSpec, ClassificationTarget,
66    PhraseTextMode, PhraseVocabularyPolicySpec,
67};
68pub use analysis_publication::{
69    AnalysisPublicationFormat, AnalysisPublicationPlan, AnalysisPublicationReceipt,
70};
71pub use cli::run_from as run_cli_from;
72pub use corrections::{CapabilityReceipt, MessageClassificationReport};
73pub use export::{ExportFormat, ExportPublicationPlan};
74pub use inspect::{EvidenceWindow, InspectionOptions};
75pub use message_search::{
76    AdditionalFieldText, ContextWindow, CoordinateUnit, DetailLevel, FieldViewBudget,
77    FieldViewExtent, FuzzyQuery, JsonPointer, LineWindow, LiteralQuery, MatchViewBudget,
78    MatchWindow, MessageFieldView, MessageMatchEvidence, MessageMatchViewMarkers,
79    MessagePredicates, MessageQuery, MessageResultRef, MessageSearchError, MessageSearchHit,
80    MessageSearchInclude, MessageSearchIncludedData, MessageSearchOmission, MessageSearchParameter,
81    MessageSearchParameterDomain, MessageSearchParameterRegistry, MessageSearchParameterSpec,
82    MessageSearchRequest, MessageSearchRequestBuilder, MessageSearchResponse, MessageSearchRule,
83    MessageSearchRuntimeDiagnostics, MessageSearchSpecification, MessageTarget, PageSide,
84    ProviderScope, PurposeSelection, ReceiptLevel, RequestedExtent, RequestedTimeRange,
85    ResolvedMessageSearchRequest, ResolvedQueryMode, ResolvedRequestExtent,
86    ResolvedRequestPresentation, ResultSetExtent, SequenceRange, ValidatedRegex, ValueOriginKind,
87    ViewCharRange,
88};
89pub use message_search_batches::{
90    MessageSearchBatch, MessageSearchBatches, MessageSearchCompletion,
91};
92pub use models::{AnalysisRequest, AnalysisSessionSelection};
93pub use models::{
94    FileQuery, MessageClassificationMatch, MessageFilters, MessageSearchMode, SearchFilters,
95    SessionKind,
96};
97pub use search_scope::{
98    AccessRoot, AccessRootOrigin, AccessRootSource, EffectiveAccessScope, TrustedAccessInputs,
99};
100pub use service::SessionSearch;
101pub use service::{AnalysisReceipt, ReceiptedAnalysis};
102pub use skill_catalog::{SkillName, SkillNameSelector, SkillPathSelector, SkillSelector};
103pub use skill_run::{
104    CapabilityExecutionSource, MessageClassificationDefinition, MessageClassificationQuery,
105    MessageClassificationResult, ResolvedSkillReceipt, SelectedSkillLocation, SkillCapabilityInput,
106    SkillCapabilityOutput, SkillRunQuery, SkillRunReport,
107};
108
109/// Execute the canonical CLI after recording that this process entered through the Python binding.
110///
111/// This is an embedding boundary rather than a second dispatcher: command parsing and execution
112/// remain in [`run_cli_from`], while detached children can re-enter through the installed Python
113/// console script without guessing from platform-specific interpreter paths.
114#[doc(hidden)]
115pub fn run_cli_from_python<I, T>(args: I) -> anyhow::Result<i32>
116where
117    I: IntoIterator<Item = T>,
118    T: Into<std::ffi::OsString> + Clone,
119{
120    update::mark_python_binding_process();
121    cli::run_from(args)
122}
123
124/// Return whether an application error means an ordinary downstream reader closed its pipe.
125///
126/// Inspecting the short error chain is `O(chain depth)` time and `O(1)` memory. Native and
127/// language-bound entrypoints share this classifier so wrappers cannot turn successful shell
128/// pipelines into failures merely by erasing the underlying I/O error type.
129#[doc(hidden)]
130pub fn is_broken_pipe_error(error: &anyhow::Error) -> bool {
131    error.chain().any(|cause| {
132        cause
133            .downcast_ref::<std::io::Error>()
134            .is_some_and(|error| error.kind() == std::io::ErrorKind::BrokenPipe)
135            || cause
136                .downcast_ref::<serde_json::Error>()
137                .is_some_and(|error| error.io_error_kind() == Some(std::io::ErrorKind::BrokenPipe))
138    })
139}
140
141/// Return whether an application error means a filesystem or the index database ran out of space.
142///
143/// Inspecting the short error chain is `O(chain depth)` time and `O(1)` memory. One classifier
144/// covers both ways a write reports exhaustion, and both are already platform-independent, so this
145/// needs no per-platform branch: `std::io::ErrorKind::StorageFull` is what the standard library
146/// maps `ENOSPC` to on Unix and `ERROR_DISK_FULL`/`ERROR_HANDLE_DISK_FULL` to on Windows, and
147/// SQLite reports `SQLITE_FULL` identically everywhere. Matching a raw platform error number here
148/// instead would need one arm per target and would silently classify nothing on the targets it
149/// missed.
150///
151/// Callers use this to separate a condition that clears by itself, sometimes after days, from one
152/// that needs a correction before the next attempt can succeed.
153#[doc(hidden)]
154pub fn is_storage_full_error(error: &anyhow::Error) -> bool {
155    error.chain().any(|cause| {
156        cause
157            .downcast_ref::<std::io::Error>()
158            .is_some_and(|error| error.kind() == std::io::ErrorKind::StorageFull)
159            || cause
160                .downcast_ref::<serde_json::Error>()
161                .is_some_and(|error| error.io_error_kind() == Some(std::io::ErrorKind::StorageFull))
162            || cause
163                .downcast_ref::<rusqlite::Error>()
164                .and_then(rusqlite::Error::sqlite_error_code)
165                .is_some_and(|code| code == rusqlite::ErrorCode::DiskFull)
166    })
167}
168
169/// The sentence a caller needs after an operation stopped because storage is full.
170///
171/// An operating system reports a full disk as one clause with no advice. The two facts a reader
172/// cannot derive from it are what survived and what to do next, so both are stated here once.
173///
174/// The claim is that the indexed history is intact, which is what atomic publication and
175/// transaction rollback actually guarantee. It deliberately stops short of promising that a new
176/// process can open the index right now: a WAL database whose `-shm` sidecar is absent needs to
177/// create one, and on a completely full disk that creation is itself a write that can fail.
178const STORAGE_FULL_GUIDANCE: &str =
179    "There is no free space left for this write. No partial file replaced a complete one: file \
180     writes publish by atomic rename and database writes roll back, so the history already indexed \
181     is intact. Free space, then rerun; `aise doctor` reports index readiness.";
182
183/// Render an application error for a person, adding recovery guidance the error itself lacks.
184///
185/// Every entry point that ends a run formats through this, so what a reader is told does not depend
186/// on whether they reached aise through the executable or through the Python binding. Cost is one
187/// formatting pass plus `O(chain depth)` classification.
188#[doc(hidden)]
189pub fn error_message_with_recovery(error: &anyhow::Error) -> String {
190    let rendered = format!("{error:#}");
191    if is_storage_full_error(error) {
192        return format!("{rendered}\n{STORAGE_FULL_GUIDANCE}");
193    }
194    rendered
195}
196
197#[cfg(test)]
198mod storage_pressure_tests {
199    /// Both sources of a full-disk failure classify the same way on every supported platform.
200    ///
201    /// A staged file write reports it as `std::io::ErrorKind::StorageFull`, and the index database
202    /// reports it as SQLite's `SQLITE_FULL`; a caller deciding whether to wait or to ask for a
203    /// correction has to treat them alike. Neither spelling is platform-specific, which is why one
204    /// code path serves Unix and Windows.
205    #[test]
206    fn storage_exhaustion_is_recognized_from_the_filesystem_and_from_sqlite() {
207        let staged = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::StorageFull))
208            .context("failed to write staging file /tmp/index.db.stage");
209        assert!(super::is_storage_full_error(&staged), "{staged:#}");
210
211        let database = anyhow::Error::new(rusqlite::Error::SqliteFailure(
212            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_FULL),
213            Some("database or disk is full".to_string()),
214        ))
215        .context("could not commit the index transaction");
216        assert!(super::is_storage_full_error(&database), "{database:#}");
217    }
218
219    /// What a reader is told is what the entry points print, because both format through here.
220    ///
221    /// A full disk arrives as one clause — `No space left on device (os error 28)`, or the Windows
222    /// equivalent — and a reader acting on it needs two things that clause never carries: whether
223    /// their indexed history survived, and what to do. Testing the formatter rather than each
224    /// entry point is what makes this checkable without a full disk on three platforms; the
225    /// executable and the Python binding both call it and add no wording of their own.
226    #[test]
227    fn a_storage_failure_is_rendered_with_what_survived_and_what_to_do() {
228        let rendered = super::error_message_with_recovery(
229            &anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::StorageFull))
230                .context("failed to write staging file"),
231        );
232        assert!(
233            rendered.contains("failed to write staging file"),
234            "{rendered}"
235        );
236        assert!(rendered.contains("no free space"), "{rendered}");
237        assert!(rendered.contains("is intact"), "{rendered}");
238        assert!(rendered.contains("aise doctor"), "{rendered}");
239        // Availability is not promised, only integrity: creating an absent WAL sidecar is itself a
240        // write, so a new process opening the index on a full disk can still fail.
241        assert!(!rendered.contains("searchable"), "{rendered}");
242    }
243
244    /// An unrelated failure is rendered exactly as before, with nothing appended.
245    #[test]
246    fn an_unrelated_failure_gains_no_storage_guidance() {
247        let error = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
248            .context("failed to write staging file");
249        assert_eq!(
250            super::error_message_with_recovery(&error),
251            format!("{error:#}")
252        );
253    }
254
255    /// Failures that need a correction stay outside the wait-and-retry classification.
256    #[test]
257    fn other_failures_are_not_reported_as_storage_exhaustion() {
258        for error in [
259            anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::NotFound)),
260            anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
261            anyhow::Error::new(rusqlite::Error::SqliteFailure(
262                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
263                Some("database is locked".to_string()),
264            )),
265        ] {
266            assert!(!super::is_storage_full_error(&error), "{error:#}");
267        }
268    }
269}