use std::collections::HashMap;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use crate::analysis_pipeline::AnalysisPolicySpec;
use crate::analysis_publication::{AnalysisPublicationFormat, AnalysisPublicationPlan};
use crate::config::{Config, ConfigOverrides, IndexRefresh, ResolvedConfig};
use crate::dates::DateRange;
use crate::db::Db;
use crate::durable_fs::{atomic_write_file, AtomicWriteMode};
use crate::indexer;
use crate::inspect::{inspection_rows, InspectionOptions};
use crate::migration::{
import_legacy_config, load_receipt, migrate_database, publish_imported_config,
recover_database_migration, verify_migration, ConfigPublishOptions, DatabaseMigrationOptions,
};
use crate::models::{
AnalysisRequest, AnalysisSessionSelection, Provider, SearchFilters, SessionKind, SessionRecord,
};
use crate::render::{render, OutputFormat, Row};
use crate::service::SessionSearch;
use crate::tui;
use crate::util::{
current_repo, highlight_matches, prompt_confirm, relative_age, render_posix_shell_command,
resume_plan, select_transcript_lines, truncate_for_display,
};
use anyhow::{anyhow, bail, Context, Result};
use clap::{Args, CommandFactory, Parser, Subcommand};
use serde::Serialize;
const GLOBAL_OPTIONS_HEADING: &str =
"Shared options (parsed globally; applicability depends on the selected command)";
const ROOT_COMMAND_GROUPS: &str = "\
Start here (everyday): search, messages, show, list, resume, files
Export and maintenance: export, reindex, doctor, integrations, package, config, dates
Analytics: stats, repeats, vocab, planning, analyze, skills
Expert: db, migrate, compact, mcp, tui
Typical path: `aise search \"<topic>\" --when 30d` or `aise list --path <dir> --when 7d` to find a
session, `aise messages search \"<phrase>\" --context 2` to find the exact turn, then
`aise show <id>` or `aise messages get <id> --seq <N> --context 3` to read it.";
#[derive(Debug, Parser)]
#[command(
name = "aise",
version,
about = "AI Session Search (aise): search local sessions from Claude Code, Claude Desktop local agent, Codex, Cursor, Antigravity, Pi coding agent, Prime Agent, Google AI Studio, and Gemini CLI",
after_help = ROOT_COMMAND_GROUPS
)]
struct Cli {
#[arg(long, global = true, help_heading = GLOBAL_OPTIONS_HEADING)]
config: Option<PathBuf>,
#[arg(long, global = true, help_heading = GLOBAL_OPTIONS_HEADING)]
database: Option<PathBuf>,
#[arg(long, global = true, help_heading = GLOBAL_OPTIONS_HEADING)]
cache_dir: Option<PathBuf>,
#[arg(long, global = true, help_heading = GLOBAL_OPTIONS_HEADING, value_parser = parse_positive_usize)]
threads: Option<usize>,
#[arg(long, global = true, help_heading = GLOBAL_OPTIONS_HEADING, value_enum)]
index_refresh: Option<IndexRefresh>,
#[arg(long, global = true, help_heading = GLOBAL_OPTIONS_HEADING)]
skip_release_notification: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
#[command(name = "__refresh-index", hide = true)]
RefreshIndex,
#[command(display_order = 21)]
Reindex(ReindexArgs),
#[command(display_order = 42)]
Compact,
#[command(display_order = 13)]
List(QueryArgs),
#[command(
display_order = 10,
after_help = "For turn-level literal, regex, or fuzzy content search, use `aise messages search QUERY` or select `--query-mode regex|fuzzy`."
)]
Search(SearchArgs),
#[command(display_order = 12)]
Show(ShowArgs),
#[command(display_order = 14)]
Resume(ResumeArgs),
#[command(display_order = 20)]
Export(ExportArgs),
#[command(display_order = 11, subcommand)]
Messages(crate::messages::MessagesCmd),
#[command(display_order = 33)]
Planning(crate::analytics::PlanningArgs),
#[command(display_order = 34)]
Analyze(AnalyzeArgs),
#[command(display_order = 30)]
Stats(crate::analytics::StatsArgs),
#[command(display_order = 32)]
Vocab(crate::analytics::VocabArgs),
#[command(display_order = 31)]
Repeats(crate::analytics::RepeatsArgs),
#[command(display_order = 15, subcommand)]
Files(crate::files::FilesCmd),
#[command(display_order = 23, subcommand)]
Integrations(IntegrationsCmd),
#[command(display_order = 35, subcommand)]
Skills(crate::skills::SkillsCmd),
#[command(display_order = 24, subcommand)]
Package(PackageCmd),
#[command(display_order = 43, subcommand)]
Mcp(crate::integrations::McpCmd),
#[command(display_order = 40, subcommand)]
Db(crate::sql_query::DbCmd),
#[command(display_order = 41, subcommand)]
Migrate(MigrationCmd),
#[command(display_order = 25, subcommand)]
Config(ConfigCmd),
#[command(display_order = 26)]
Dates,
#[command(display_order = 22)]
Doctor(DoctorArgs),
#[command(display_order = 44)]
Tui,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RootOption {
ConfigFile,
Database,
CacheDirectory,
WorkerThreads,
IndexRefresh,
ReleaseNotification,
}
impl RootOption {
fn flag(self) -> &'static str {
match self {
Self::ConfigFile => "--config",
Self::Database => "--database",
Self::CacheDirectory => "--cache-dir",
Self::WorkerThreads => "--threads",
Self::IndexRefresh => "--index-refresh",
Self::ReleaseNotification => "--skip-release-notification",
}
}
fn is_present(self, cli: &Cli) -> bool {
match self {
Self::ConfigFile => cli.config.is_some(),
Self::Database => cli.database.is_some(),
Self::CacheDirectory => cli.cache_dir.is_some(),
Self::WorkerThreads => cli.threads.is_some(),
Self::IndexRefresh => cli.index_refresh.is_some(),
Self::ReleaseNotification => cli.skip_release_notification,
}
}
}
#[derive(Debug, Subcommand)]
enum MigrationCmd {
Database(MigrationDatabaseArgs),
Config(MigrationConfigArgs),
Verify(MigrationVerifyArgs),
Recover(MigrationVerifyArgs),
}
#[derive(Debug, Args)]
struct MigrationConfigArgs {
#[arg(long)]
source_json: PathBuf,
#[arg(long)]
destination: PathBuf,
#[arg(long)]
database_path: PathBuf,
#[arg(long)]
cache_dir: PathBuf,
#[arg(long)]
apply: bool,
#[arg(long, requires = "apply")]
replace: bool,
#[arg(long, requires = "replace")]
rollback_copy: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct MigrationDatabaseArgs {
#[arg(long)]
source: PathBuf,
#[arg(long)]
destination: PathBuf,
#[arg(long)]
receipt: PathBuf,
#[arg(long, default_value_t = 256)]
pages_per_step: i32,
#[arg(long, default_value_t = 10)]
pause_ms: u64,
}
#[derive(Debug, Args)]
struct MigrationVerifyArgs {
#[arg(long)]
receipt: PathBuf,
}
#[derive(Debug, Args)]
struct ReindexArgs {
#[arg(long)]
full: bool,
}
#[derive(Debug, Args)]
struct PackageUpdateArgs {
#[arg(long)]
yes: bool,
}
#[derive(Debug, Args)]
struct ReportArgs {
#[arg(long, value_enum, default_value_t = ReportOutputFormat::Table)]
format: ReportOutputFormat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[clap(rename_all = "lowercase")]
pub(crate) enum ReportOutputFormat {
Table,
Json,
}
#[derive(Debug, Subcommand)]
enum PackageCmd {
Status(ReportArgs),
Check(ReportArgs),
Update(PackageUpdateArgs),
}
#[derive(Debug, Subcommand)]
enum IntegrationsCmd {
Install(crate::integrations::IntegrationInstallArgs),
Status(crate::integrations::IntegrationStatusArgs),
Uninstall(crate::integrations::IntegrationUninstallArgs),
Recover(crate::integrations::IntegrationRecoverArgs),
}
#[derive(Debug, Args)]
struct DoctorArgs {
#[arg(long, value_enum, default_value_t = DoctorFormat::Table)]
format: DoctorFormat,
#[arg(long)]
explain_unindexed: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)]
enum DoctorFormat {
Table,
Json,
}
const SESSION_FILTER_HEADING: &str = "Filters (which sessions)";
const SESSION_OUTPUT_HEADING: &str = "Result window and output";
#[derive(Debug, Args, Clone)]
struct QueryArgs {
#[command(flatten)]
filters: SessionFilterArgs,
#[arg(help_heading = SESSION_OUTPUT_HEADING, long)]
limit: Option<usize>,
#[arg(help_heading = SESSION_OUTPUT_HEADING, long, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
#[arg(help_heading = SESSION_OUTPUT_HEADING, long, value_enum, value_delimiter = ',')]
include: Vec<SessionInclude>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)]
#[clap(rename_all = "kebab-case")]
enum SessionInclude {
RawMetadata,
}
#[derive(Debug, Args, Clone)]
struct SessionFilterArgs {
#[arg(help_heading = SESSION_FILTER_HEADING, long)]
provider: Option<Provider>,
#[arg(help_heading = SESSION_FILTER_HEADING, long)]
path: Option<String>,
#[arg(help_heading = SESSION_FILTER_HEADING, long = "exclude-path")]
exclude_paths: Vec<String>,
#[arg(help_heading = SESSION_FILTER_HEADING, long = "exclude-session")]
exclude_sessions: Vec<String>,
#[arg(help_heading = SESSION_FILTER_HEADING, long = "session-kind", value_enum)]
session_kind: Option<SessionKind>,
#[arg(
help_heading = SESSION_FILTER_HEADING,
long = "session-kinds",
value_enum,
num_args = 1..,
value_delimiter = ',',
conflicts_with = "session_kind"
)]
session_kinds: Vec<SessionKind>,
#[arg(help_heading = SESSION_FILTER_HEADING, long = "parent-session")]
parent_session: Option<String>,
#[command(flatten)]
dates: DateRange,
#[arg(help_heading = SESSION_FILTER_HEADING, long)]
warnings_only: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)]
enum AnalysisFormatArg {
Json,
Markdown,
}
impl From<AnalysisFormatArg> for AnalysisPublicationFormat {
fn from(value: AnalysisFormatArg) -> Self {
match value {
AnalysisFormatArg::Json => Self::Json,
AnalysisFormatArg::Markdown => Self::Markdown,
}
}
}
#[derive(Debug, Args)]
struct AnalyzeArgs {
#[command(flatten)]
filters: SessionFilterArgs,
#[arg(long, value_parser = parse_first_canonical_sessions)]
first_canonical_sessions: Option<NonZeroUsize>,
#[arg(long)]
output: PathBuf,
#[arg(long)]
policy: Option<PathBuf>,
#[arg(long = "publication-format", value_enum, default_values_t = [AnalysisFormatArg::Json, AnalysisFormatArg::Markdown])]
publication_formats: Vec<AnalysisFormatArg>,
}
#[derive(Debug, Args)]
struct SearchArgs {
query: Option<String>,
#[command(flatten)]
filters: QueryArgs,
}
impl SearchArgs {
fn ranking_query(&self) -> Result<&str> {
match self.query.as_deref().map(str::trim) {
Some(query) if !query.is_empty() => Ok(query),
_ => bail!(
"aise search ranks sessions by a query and none was given; to list sessions by \
their metadata alone (newest first) run `{}`",
self.equivalent_list_command()
),
}
}
fn equivalent_list_command(&self) -> String {
self.filters.as_list_command()
}
}
fn value_enum_token<T: clap::ValueEnum>(value: &T) -> String {
value
.to_possible_value()
.expect("a parsed filter value is always a selectable variant")
.get_name()
.to_string()
}
impl QueryArgs {
fn as_list_command(&self) -> String {
let Self {
filters,
limit,
format,
include,
} = self;
let SessionFilterArgs {
provider,
path,
exclude_paths,
exclude_sessions,
session_kind,
session_kinds,
parent_session,
dates,
warnings_only,
} = filters;
let DateRange { since, until, when } = dates;
fn push(command: &mut Vec<String>, flag: &str, value: &str) {
command.push(flag.to_string());
command.push(
render_posix_shell_command(&[value.to_string()])
.unwrap_or_else(|_| format!("{value:?}")),
);
}
let mut command = vec!["aise".to_string(), "list".to_string()];
if let Some(provider) = provider {
push(&mut command, "--provider", provider.as_str());
}
if let Some(path) = path {
push(&mut command, "--path", path);
}
for path in exclude_paths {
push(&mut command, "--exclude-path", path);
}
for session in exclude_sessions {
push(&mut command, "--exclude-session", session);
}
if let Some(kind) = session_kind {
push(&mut command, "--session-kind", &value_enum_token(kind));
}
if !session_kinds.is_empty() {
let kinds = session_kinds
.iter()
.map(value_enum_token)
.collect::<Vec<_>>()
.join(",");
push(&mut command, "--session-kinds", &kinds);
}
if let Some(parent) = parent_session {
push(&mut command, "--parent-session", parent);
}
if let Some(when) = when {
push(&mut command, "--when", when);
}
if let Some(since) = since {
push(&mut command, "--since", since);
}
if let Some(until) = until {
push(&mut command, "--until", until);
}
if *warnings_only {
command.push("--warnings-only".to_string());
}
if let Some(limit) = limit {
push(&mut command, "--limit", &limit.to_string());
}
if *format != OutputFormat::Table {
push(&mut command, "--format", format.as_str());
}
if !include.is_empty() {
let fields = include
.iter()
.map(value_enum_token)
.collect::<Vec<_>>()
.join(",");
push(&mut command, "--include", &fields);
}
command.join(" ")
}
}
#[derive(Debug, Args)]
struct ShowArgs {
id: String,
#[arg(long, allow_hyphen_values = true)]
transcript_lines: Option<i64>,
#[arg(long, conflicts_with_all = ["transcript_lines", "raw"])]
summary: bool,
#[arg(long, allow_hyphen_values = true, requires = "summary")]
summary_items: Option<i64>,
#[arg(long, requires = "summary", value_parser = parse_positive_usize)]
preview_chars: Option<usize>,
#[arg(long)]
raw: bool,
}
#[derive(Debug, Args)]
struct ResumeArgs {
id: String,
#[arg(long)]
yes: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct ExportArgs {
id: Option<String>,
#[command(flatten)]
filters: SessionFilterArgs,
#[arg(long)]
limit: Option<usize>,
#[arg(long, default_value = "markdown")]
format: String,
#[arg(short, long, conflicts_with = "output_dir")]
output: Option<PathBuf>,
#[arg(long, conflicts_with = "output")]
output_dir: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
enum ConfigCmd {
File,
Example,
Init(ConfigInitArgs),
Show(ConfigShowArgs),
Origins,
Paths(ReportArgs),
}
#[derive(Debug, Args)]
struct ConfigInitArgs {
#[arg(long)]
force: bool,
}
#[derive(Debug, Args)]
struct ConfigShowArgs {
#[arg(long, value_enum, default_value_t = ConfigOutputFormat::Toml)]
format: ConfigOutputFormat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[clap(rename_all = "lowercase")]
enum ConfigOutputFormat {
Toml,
Json,
}
pub(crate) fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
match value.parse::<usize>() {
Ok(parsed) if parsed > 0 => Ok(parsed),
_ => Err(format!("expected a positive integer, got {value:?}")),
}
}
pub fn run_from<I, T>(args: I) -> Result<i32>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let cli = match parse_cli_from(args) {
Ok(cli) => cli,
Err(error) => {
let exit_code = error.exit_code();
error.print()?;
return Ok(exit_code);
}
};
if let Commands::Skills(command) = &cli.command {
if command.print_execution_help_if_requested()? {
return Ok(0);
}
}
execute(cli)?;
Ok(0)
}
fn parse_cli_from<I, T>(args: I) -> std::result::Result<Cli, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let mut args = args.into_iter().map(Into::into);
let Some(program) = args.next() else {
return Cli::try_parse_from(std::iter::empty::<OsString>());
};
let globals = Cli::command()
.get_arguments()
.filter(|argument| argument.is_global_set())
.filter_map(|argument| {
argument
.get_long()
.map(|name| (name.to_string(), argument.get_action().takes_values()))
})
.collect::<HashMap<_, _>>();
let remaining = args.collect::<Vec<_>>();
let mut global_tokens = Vec::new();
let mut command_tokens = Vec::with_capacity(remaining.len());
let mut index = 0;
while index < remaining.len() {
let token = &remaining[index];
let global = token.to_str().and_then(|token| {
token
.strip_prefix("--")
.map(|long| long.split_once('=').map_or(long, |(name, _)| name))
.and_then(|name| globals.get(name).map(|takes_value| (name, *takes_value)))
});
match global {
Some((_name, takes_value)) => {
let has_inline_value = token
.to_str()
.is_some_and(|token| token.starts_with("--") && token.contains('='));
global_tokens.push(token.clone());
if takes_value && !has_inline_value && index + 1 < remaining.len() {
index += 1;
global_tokens.push(remaining[index].clone());
}
}
None => command_tokens.push(token.clone()),
}
index += 1;
}
let normalized = std::iter::once(program)
.chain(global_tokens)
.chain(command_tokens.iter().cloned())
.collect::<Vec<_>>();
Cli::try_parse_from(&normalized)
.map_err(|error| clarify_stale_message_argument(error, &command_tokens))
}
fn clarify_stale_message_argument(
mut error: clap::Error,
command_tokens: &[OsString],
) -> clap::Error {
use clap::error::{ContextKind, ContextValue, ErrorKind};
if error.kind() != ErrorKind::UnknownArgument
|| command_tokens.first().and_then(|value| value.to_str()) != Some("messages")
|| command_tokens.get(1).and_then(|value| value.to_str()) != Some("search")
{
return error;
}
let invalid = match error.get(ContextKind::InvalidArg) {
Some(ContextValue::String(value)) => value.clone(),
_ => return error,
};
let stale = invalid
.split_once('=')
.map_or(invalid.as_str(), |(name, _)| name);
let replacement = match stale {
"--project" => "--workspace-path",
"--type" => "--role",
"--regex" => "--query-mode regex",
"--fuzzy" => "--query-mode fuzzy",
"--explain" => "--receipt-level summary",
_ => return error,
};
error.insert(
ContextKind::SuggestedArg,
ContextValue::String(replacement.to_string()),
);
error.insert(ContextKind::Suggested, ContextValue::None);
let mut root = Cli::command();
root.build();
if let Some(usage) = root
.find_subcommand_mut("messages")
.and_then(|messages| messages.find_subcommand_mut("search"))
.map(clap::Command::render_usage)
{
error.insert(ContextKind::Usage, ContextValue::StyledStr(usage));
}
error
}
fn refreshes_integrations_after_upgrade(command: &Commands) -> bool {
match command {
Commands::Integrations(
IntegrationsCmd::Install(_)
| IntegrationsCmd::Uninstall(_)
| IntegrationsCmd::Recover(_),
)
| Commands::Package(PackageCmd::Update(_))
| Commands::Migrate(_) => false,
Commands::Skills(crate::skills::SkillsCmd::Update(args)) => !args.dry_run,
Commands::Skills(cmd) => !cmd.is_management(),
_ => true,
}
}
fn execute(cli: Cli) -> Result<()> {
validate_root_options(&cli)?;
if matches!(&cli.command, Commands::RefreshIndex) {
return run_background_refresh_from_stdin();
}
let skip_release_notification = cli.skip_release_notification;
let overrides = ConfigOverrides {
config_path: cli.config,
database_path: cli.database,
cache_dir: cli.cache_dir,
threads: cli.threads,
index_refresh: cli.index_refresh,
};
let mut resolved_early = None;
if refreshes_integrations_after_upgrade(&cli.command) {
if let Ok(resolved) = Config::resolve(overrides.clone()) {
crate::integrations::refresh_integrations_after_upgrade_and_report(
&resolved.config,
&resolved.config_path,
);
resolved_early = Some(resolved);
}
}
let mut resolve = || match resolved_early.take() {
Some(resolved) => Ok(resolved),
None => Config::resolve(overrides.clone()),
};
let command = match cli.command {
Commands::Integrations(IntegrationsCmd::Install(args)) => {
let config_path = Config::selected_config_path(overrides.config_path.clone());
let receipt = crate::integrations::default_transaction_receipt(&config_path);
let outcome = crate::integrations::install_with_receipt(args, &receipt)?;
start_initial_indexing_after_integration_install(outcome, overrides, &config_path);
return Ok(());
}
Commands::Integrations(IntegrationsCmd::Status(args)) => {
let config_path = Config::selected_config_path(overrides.config_path.clone());
let receipt = crate::integrations::default_transaction_receipt(&config_path);
return crate::integrations::status_with_receipt(args, &receipt);
}
Commands::Integrations(IntegrationsCmd::Uninstall(args)) => {
let config_path = Config::selected_config_path(overrides.config_path.clone());
let receipt = crate::integrations::default_transaction_receipt(&config_path);
return crate::integrations::uninstall_with_receipt(args, &receipt);
}
Commands::Integrations(IntegrationsCmd::Recover(args)) => {
let config_path = Config::selected_config_path(overrides.config_path.clone());
let receipt = crate::integrations::default_transaction_receipt(&config_path);
return crate::integrations::recover_with_receipt(args, &receipt);
}
Commands::Package(PackageCmd::Status(args)) => {
return crate::update::print_package_status(args.format);
}
command => command,
};
let command = match command {
Commands::Mcp(crate::integrations::McpCmd::Serve) => {
let resolved = resolve()?;
return crate::mcp_server::serve_with_config(resolved.config);
}
Commands::Mcp(crate::integrations::McpCmd::SchemaBudget(args)) => {
let resolved = resolve()?;
return crate::mcp_schema_budget::run(&args, &resolved.config);
}
command => command,
};
if let Commands::Config(cmd) = &command {
match cmd {
ConfigCmd::File => {
println!(
"{}",
Config::selected_config_path(overrides.config_path.clone()).display()
);
return Ok(());
}
ConfigCmd::Example => {
print!("{}", crate::config::CONFIG_EXAMPLE_TOML);
return Ok(());
}
ConfigCmd::Init(args) => {
write_config_example(
&Config::selected_config_path(overrides.config_path.clone()),
args.force,
)?;
return Ok(());
}
ConfigCmd::Show(_) | ConfigCmd::Origins | ConfigCmd::Paths(_) => {}
}
}
if let Commands::Migrate(cmd) = command {
return run_migration(cmd);
}
let resolved = resolve()?;
let config = resolved.config.clone();
if let Commands::Package(command) = &command {
return match command {
PackageCmd::Status(_) => {
unreachable!("package status returned above, before the command dispatch")
}
PackageCmd::Check(args) => crate::update::run_package_check(&config, args.format),
PackageCmd::Update(args) => crate::update::run_package_update(&config, args.yes),
};
}
if let Commands::Db(cmd) = command {
if matches!(&cmd, crate::sql_query::DbCmd::Query(_)) {
crate::search_scope::ensure_raw_sql_allowed(&config.search.scope, "aise db query")?;
}
return crate::sql_query::run(
&config.db_path(),
config.index.busy_timeout_ms,
&config.db,
cmd,
);
}
if let Commands::Config(cmd) = command {
return run_config_cmd(&resolved, cmd);
}
if matches!(&command, Commands::Skills(cmd) if cmd.is_management()) {
let receipt = crate::integrations::default_transaction_receipt(&resolved.config_path);
let Commands::Skills(cmd) = command else {
unreachable!("the match guard above established a skill management command")
};
return crate::skills::run(&config, cmd, &receipt);
}
if matches!(command, Commands::Dates) {
println!("{}", crate::dates::format_reference());
return Ok(());
}
if let Commands::Messages(cmd) = &command {
if crate::messages::run_index_independent(cmd, &config)? {
return Ok(());
}
}
let explicit_maintenance = matches!(&command, Commands::Reindex(_) | Commands::Compact);
let mut app = if explicit_maintenance {
SessionSearch::open_for_maintenance(config.clone())?
} else {
SessionSearch::open(config.clone())?
};
app.set_progress_reporter(|message| eprintln!("aise: {message}"));
let db = app.database();
let implicit_read = !matches!(
command,
Commands::Reindex(_) | Commands::Compact | Commands::Doctor(_)
);
if implicit_read {
prepare_index_for_immediate_read(&config, db)?;
}
let mut refresh_scheduled = false;
match command {
Commands::Reindex(args) => {
let outcome = reindex(&config, db, args.full)?;
println!(
"reindex complete: scanned {} files, updated {} sessions",
outcome.files_seen, outcome.sessions_updated
);
for warning in &outcome.discovery_warnings {
eprintln!(
"aise: discovery warning: {} {} {}: {} {}",
warning.provider,
warning.operation,
warning.path,
warning.message,
warning.guidance
);
}
if outcome.effective_full {
let allocation = db.storage_allocation()?;
if let Some(guidance) = storage_compaction_guidance(allocation) {
eprintln!("aise: {guidance}");
}
}
}
Commands::List(args) => {
let format = args.format;
let include = args.include;
let filters =
build_filters(&args.filters, configured_search_limit(args.limit, &config))?;
let sessions = app.catalog().list_sessions(&filters)?;
match format {
OutputFormat::Table => print_sessions(&sessions),
other => render_session_rows(&sessions, other, &include)?,
}
}
Commands::Search(args) => {
let query = args.ranking_query()?;
let format = args.filters.format;
let include = args.filters.include.clone();
let filters = build_filters(
&args.filters.filters,
configured_search_limit(args.filters.limit, &config),
)?;
let current_repo = current_repo(&config);
let hits = app.catalog().search_sessions(
query,
&filters,
current_repo.as_deref(),
&config.search.scoring,
)?;
match format {
OutputFormat::Table => {
if hits.is_empty() {
println!("no sessions matched");
} else {
for hit in hits {
print_search_hit(&hit, query);
}
}
}
other => render_session_rows(&hits, other, &include)?,
}
}
Commands::Show(args) => {
if args.summary {
let options = InspectionOptions {
preview_chars: args
.preview_chars
.unwrap_or(config.cli.evidence_preview_chars)
.max(1),
evidence_window: crate::inspect::EvidenceWindow::from_signed_items(
args.summary_items.unwrap_or(config.cli.summary_items),
)?,
include_time_profile: false,
};
let inspection = app.catalog().inspect(&args.id, options)?;
render_rows(&inspection_rows(&inspection, options), OutputFormat::Table)?;
schedule_auto_refresh_after_output(
&config,
db,
implicit_read,
&mut refresh_scheduled,
);
return Ok(());
}
let session = db.resolve_session(&args.id)?;
print_session_detail(&session.session);
let transcript_lines = args
.transcript_lines
.unwrap_or(config.cli.show_transcript_lines);
let (transcript, returned_lines) =
select_transcript_lines(&session.transcript_text, transcript_lines);
if args.raw {
println!("\n{transcript}");
} else {
println!("\nTranscript lines returned: {returned_lines}");
println!("\nTranscript\n{transcript}\n");
}
}
Commands::Resume(args) => {
let session = db.resolve_session_record(&args.id)?;
let (cmd, cwd) = resume_plan(&session)?;
let rendered = render_posix_shell_command(&cmd)?;
println!("POSIX shell resume command: {rendered}");
println!("{}", crate::util::RESUME_COMMAND_POLICY_NOTE);
if let Some(cwd) = &cwd {
println!("cwd: {cwd}");
}
schedule_auto_refresh_after_output(&config, db, implicit_read, &mut refresh_scheduled);
if args.dry_run {
return Ok(());
}
if !args.yes && !prompt_confirm("Execute resume command?")? {
println!("resume cancelled");
return Ok(());
}
let mut command = Command::new(&cmd[0]);
command.args(&cmd[1..]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let status = command.status()?;
if !status.success() {
let dry_run = render_posix_shell_command(&[
"aise".to_string(),
"resume".to_string(),
args.id.clone(),
"--dry-run".to_string(),
])?;
return Err(anyhow!(
"resume command `{rendered}` exited with status {status}; rerun \
`{dry_run}` to print the command without executing it, or check \
the tool's own error output above",
));
}
}
Commands::Export(args) => {
let format = args.format.parse()?;
let filters = build_filters(
&args.filters,
args.limit.unwrap_or(config.search.default_limit),
)?;
if let Some(id) = args.id {
if args.output_dir.is_some()
|| args.limit.is_some()
|| !export_filters_are_empty(&filters)
{
return Err(anyhow!(
"aise export {id} does not accept corpus filters, --limit, or \
--output-dir (those apply to multi-session export); drop --id to \
export a filtered corpus, or drop --limit/--output-dir/filters to \
export just {id}"
));
}
let output = crate::service::ExportService::new(db)
.render_full(&id, format)?
.into_content();
if let Some(path) = args.output {
fs::write(&path, output)?;
println!("wrote {}", path.display());
} else {
print!("{output}");
}
} else {
if args.output.is_some() {
return Err(anyhow!(
"multi-session export requires --output-dir, not --output"
));
}
let destination = args
.output_dir
.context("export requires a session ID or --output-dir")?;
let destination = if destination.is_absolute() {
destination
} else {
std::env::current_dir()?.join(destination)
};
let plan = crate::export::ExportPublicationPlan::new(destination, format)?;
println!(
"{}",
serde_json::to_string_pretty(&app.exports().publish_bundle(&filters, &plan)?)?
);
}
}
Commands::Messages(cmd) => crate::messages::run(db, &cmd, &config)?,
Commands::Planning(args) => crate::analytics::run_planning(db, &config, &args)?,
Commands::Analyze(args) => {
let filters = build_filters(&args.filters, 0)?;
let selection = args
.first_canonical_sessions
.map_or(AnalysisSessionSelection::AllEligible, |max_sessions| {
AnalysisSessionSelection::FirstCanonicalSessions { max_sessions }
});
let request = AnalysisRequest::new(filters, selection)?;
let output = if args.output.is_absolute() {
args.output
} else {
std::env::current_dir()?.join(args.output)
};
let plan = AnalysisPublicationPlan::new(
output,
args.publication_formats
.into_iter()
.map(AnalysisPublicationFormat::from),
)?;
plan.preflight()?;
let policy_spec = match args.policy {
Some(path) => {
let bytes = fs::read(&path).with_context(|| {
format!("failed to read analysis policy {}", path.display())
})?;
serde_json::from_slice(&bytes).with_context(|| {
format!("failed to parse analysis policy {}", path.display())
})?
}
None => AnalysisPolicySpec::default(),
};
let policy = policy_spec.compile()?;
let analysis = app.analysis().run(&request, &policy)?;
println!(
"{}",
serde_json::to_string_pretty(&plan.publish(&analysis)?)?
);
}
Commands::Stats(args) => crate::analytics::run_stats(db, &config, &args)?,
Commands::Vocab(args) => crate::analytics::run_vocab(db, &config.analytics, &args)?,
Commands::Repeats(args) => crate::analytics::run_repeats(db, &config.analytics, &args)?,
Commands::Files(cmd) => crate::files::run(db, &cmd)?,
Commands::Compact => {
let before = db.storage_allocation()?.total_bytes;
eprintln!(
"aise: compacting index ({}) — optimize + vacuum + wal checkpoint…",
mib(before)
);
let outcome = app.maintenance().compact()?;
println!(
"compact complete: {} → {} (reclaimed {})",
mib(outcome.before_bytes),
mib(outcome.after_bytes),
mib(outcome.reclaimed_bytes())
);
}
Commands::Dates => unreachable!("date reference returns before opening the DB"),
Commands::Doctor(args) => print_doctor(&config, db, args.format, args.explain_unindexed)?,
Commands::Tui => {
schedule_auto_refresh_after_output(&config, db, implicit_read, &mut refresh_scheduled);
let selected = tui::select_session(&config, db)?;
drop(app);
if let Some(session) = selected {
tui::execute_resume(&session)?;
}
crate::update::notify_if_new_stable_release_available_after_cli_output(
&config,
skip_release_notification,
);
return Ok(());
}
Commands::Mcp(_) => unreachable!("MCP serving returns before opening the DB"),
Commands::Integrations(_) => {
unreachable!("integration lifecycle commands return before configuration")
}
Commands::Package(_) => unreachable!("package commands return before opening the DB"),
Commands::Db(_) => unreachable!("DB query commands return before opening the write DB"),
Commands::Migrate(_) => unreachable!("migration commands return before opening the DB"),
Commands::Config(_) => unreachable!("Config commands return before opening the DB"),
Commands::Skills(cmd) => {
let execution = cmd.into_execution()?;
let args = execution.args;
let definition = args
.definition_json
.as_deref()
.map(serde_json::from_str::<crate::skill_run::MessageClassificationDefinition>)
.transpose()
.context(
"--definition-json must be a JSON object with a nonempty categories array; \
each category requires name and patterns",
)?;
let additional_skills = args
.additional_skills
.iter()
.cloned()
.map(crate::skills::parse_skill_selector)
.collect::<Result<Vec<_>>>()?;
let filters = crate::analytics::message_classification_filters(db, &config, &args)?;
let report = app.analysis().run_skill(&crate::skill_run::SkillRunQuery {
skill: execution.selector,
definition,
input: crate::skill_run::SkillCapabilityInput::MessageClassification(
crate::skill_run::MessageClassificationQuery {
filters,
additional_skills,
},
),
})?;
crate::analytics::render_skill_run_report(&report, &config, &args)?;
}
Commands::RefreshIndex => unreachable!("background refresh returns before configuration"),
}
schedule_auto_refresh_after_output(&config, db, implicit_read, &mut refresh_scheduled);
drop(app);
crate::update::notify_if_new_stable_release_available_after_cli_output(
&config,
skip_release_notification,
);
Ok(())
}
fn validate_root_options(cli: &Cli) -> Result<()> {
const CONFIG_INPUTS: &[RootOption] = &[
RootOption::ConfigFile,
RootOption::Database,
RootOption::CacheDirectory,
RootOption::WorkerThreads,
RootOption::IndexRefresh,
];
const CONFIG_AND_CACHE: &[RootOption] = &[RootOption::ConfigFile, RootOption::CacheDirectory];
const CONFIG_DATABASE_AND_CACHE: &[RootOption] = &[
RootOption::ConfigFile,
RootOption::Database,
RootOption::CacheDirectory,
];
const CONFIG_FILE_ONLY: &[RootOption] = &[RootOption::ConfigFile];
const DATABASE_COMMAND_INPUTS: &[RootOption] = &[RootOption::ConfigFile, RootOption::Database];
const ORDINARY_COMMAND_INPUTS: &[RootOption] = &[
RootOption::ConfigFile,
RootOption::Database,
RootOption::CacheDirectory,
RootOption::WorkerThreads,
RootOption::IndexRefresh,
RootOption::ReleaseNotification,
];
const NO_ROOT_OPTIONS: &[RootOption] = &[];
let (command_name, allowed) = match &cli.command {
Commands::Integrations(IntegrationsCmd::Install(_)) => {
("aise integrations install", CONFIG_FILE_ONLY)
}
Commands::Integrations(IntegrationsCmd::Status(_)) => {
("aise integrations status", CONFIG_FILE_ONLY)
}
Commands::Integrations(IntegrationsCmd::Uninstall(_)) => {
("aise integrations uninstall", CONFIG_FILE_ONLY)
}
Commands::Integrations(IntegrationsCmd::Recover(_)) => {
("aise integrations recover", CONFIG_FILE_ONLY)
}
Commands::Package(PackageCmd::Status(_)) => ("aise package status", NO_ROOT_OPTIONS),
Commands::Package(PackageCmd::Check(_)) => ("aise package check", CONFIG_AND_CACHE),
Commands::Package(PackageCmd::Update(_)) => ("aise package update", CONFIG_AND_CACHE),
Commands::Mcp(_) => ("aise mcp serve", CONFIG_INPUTS),
Commands::Config(ConfigCmd::File) => ("aise config file", CONFIG_FILE_ONLY),
Commands::Config(ConfigCmd::Example) => ("aise config example", NO_ROOT_OPTIONS),
Commands::Config(ConfigCmd::Init(_)) => ("aise config init", CONFIG_FILE_ONLY),
Commands::Config(ConfigCmd::Show(_)) => ("aise config show", CONFIG_INPUTS),
Commands::Config(ConfigCmd::Origins) => ("aise config origins", CONFIG_INPUTS),
Commands::Config(ConfigCmd::Paths(_)) => ("aise config paths", CONFIG_DATABASE_AND_CACHE),
Commands::Db(_) => ("aise db", DATABASE_COMMAND_INPUTS),
Commands::Migrate(_) => ("aise migrate", NO_ROOT_OPTIONS),
Commands::Dates => ("aise dates", NO_ROOT_OPTIONS),
Commands::RefreshIndex => ("aise __refresh-index", NO_ROOT_OPTIONS),
_ => ("this command", ORDINARY_COMMAND_INPUTS),
};
for option in [
RootOption::ConfigFile,
RootOption::Database,
RootOption::CacheDirectory,
RootOption::WorkerThreads,
RootOption::IndexRefresh,
RootOption::ReleaseNotification,
] {
if option.is_present(cli) && !allowed.contains(&option) {
bail!(
"{} does not apply to `{command_name}`; remove it",
option.flag()
);
}
}
Ok(())
}
fn run_migration(command: MigrationCmd) -> Result<()> {
match command {
MigrationCmd::Database(args) => {
let mut options =
DatabaseMigrationOptions::new(args.source, args.destination, args.receipt);
options.pages_per_step = args.pages_per_step;
options.pause_between_steps = std::time::Duration::from_millis(args.pause_ms);
let receipt = migrate_database(&options)?;
println!("{}", serde_json::to_string_pretty(&receipt)?);
}
MigrationCmd::Config(args) => {
let import = import_legacy_config(
&args.source_json,
args.destination.clone(),
args.database_path,
args.cache_dir,
)?;
println!("{}", serde_json::to_string_pretty(&import.report)?);
if args.apply {
publish_imported_config(
&import,
&ConfigPublishOptions {
destination: args.destination,
replace_existing: args.replace,
rollback_copy: args.rollback_copy,
},
)?;
}
}
MigrationCmd::Verify(args) => {
let receipt = load_receipt(&args.receipt)?;
verify_migration(&receipt)?;
println!("migration verified: {}", receipt.destination.display());
}
MigrationCmd::Recover(args) => {
let receipt = recover_database_migration(&args.receipt)?;
println!(
"migration recovered and verified: {}",
receipt.destination.display()
);
}
}
Ok(())
}
fn run_config_cmd(resolved: &ResolvedConfig, cmd: ConfigCmd) -> Result<()> {
match cmd {
ConfigCmd::File => println!("{}", resolved.config_path.display()),
ConfigCmd::Example => print!("{}", crate::config::CONFIG_EXAMPLE_TOML),
ConfigCmd::Init(args) => write_config_example(&resolved.config_path, args.force)?,
ConfigCmd::Show(args) => match args.format {
ConfigOutputFormat::Toml => {
print!("{}", toml::to_string_pretty(&resolved.config)?)
}
ConfigOutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&resolved.config)?)
}
},
ConfigCmd::Origins => println!("{}", serde_json::to_string_pretty(&resolved.origins)?),
ConfigCmd::Paths(args) => {
print_config_paths(&resolved.config, &resolved.config_path, args.format)?
}
}
Ok(())
}
fn initial_config_text() -> String {
let defaults = Config::default();
let db_path =
toml::Value::String(defaults.db_path().to_string_lossy().into_owned()).to_string();
let cache_dir =
toml::Value::String(defaults.cache_dir().to_string_lossy().into_owned()).to_string();
let mut text = String::with_capacity(crate::config::CONFIG_EXAMPLE_TOML.len() + 256);
for line in crate::config::CONFIG_EXAMPLE_TOML.lines() {
if line == "# db_path = \"/absolute/path/to/index.db\"" {
text.push_str(
"# `aise config init` recorded where this machine keeps the index; the two lines \
below\n# pin it so a later default cannot move it.\n",
);
text.push_str(&format!("db_path = {db_path}"));
} else if line == "# cache_dir = \"/absolute/path/to/cache\"" {
text.push_str(&format!("cache_dir = {cache_dir}"));
} else if is_setting_line(line) {
text.push_str("# ");
text.push_str(line);
} else {
text.push_str(line);
}
text.push('\n');
}
text
}
fn is_setting_line(line: &str) -> bool {
let body = line.trim_start();
!body.is_empty() && !body.starts_with('#') && !body.starts_with('[') && body.contains('=')
}
fn write_config_example(path: &std::path::Path, force: bool) -> Result<()> {
let mode = if force {
AtomicWriteMode::Replace
} else {
AtomicWriteMode::CreateNew
};
let initialized = initial_config_text();
atomic_write_file(path, initialized.as_bytes(), mode).with_context(
|| {
if force {
format!("failed to initialize config file {}", path.display())
} else {
format!(
"failed to initialize config file {}; use `aise config init --force` only to replace an existing regular file",
path.display()
)
}
},
)?;
println!("wrote {}", path.display());
Ok(())
}
fn mib(bytes: u64) -> String {
format!("{:.1} MiB", bytes as f64 / 1_048_576.0)
}
fn reindex(config: &Config, db: &Db, full: bool) -> Result<indexer::ExplicitReindexOutcome> {
let mut progress = |index: usize, total: usize, updated: usize| {
if total >= 20 && (updated.is_multiple_of(10) || index == total) {
eprint!("\rindexing: {index}/{total} files ({updated} updated)");
}
};
let outcome = indexer::explicit_reindex_and_migrate(config, db, full, Some(&mut progress))?;
if outcome.files_seen >= 20 {
eprintln!();
}
Ok(outcome)
}
fn prepare_index_for_immediate_read(config: &Config, db: &Db) -> Result<()> {
match indexer::prepare_index_for_read_now(config, db)? {
None
| Some(indexer::AutoReindexOutcome::Updated { .. })
| Some(indexer::AutoReindexOutcome::SkippedFresh) => Ok(()),
Some(indexer::AutoReindexOutcome::SkippedBusy) => {
eprintln!(
"aise: auto-reindex skipped because another process is writing; serving existing index"
);
Ok(())
}
Some(indexer::AutoReindexOutcome::SkippedLockUnavailable { reason }) => {
eprintln!(
"aise: auto-reindex skipped because the update lock is unavailable; serving existing index ({reason})"
);
Ok(())
}
}
}
fn schedule_auto_refresh_after_output(
config: &Config,
db: &Db,
implicit_read: bool,
scheduled: &mut bool,
) {
if *scheduled || !implicit_read || config.index.refresh != IndexRefresh::Auto {
return;
}
*scheduled = true;
if let Err(error) = io::stdout().flush() {
eprintln!(
"aise: background index refresh not started because stdout flush failed: {error}"
);
return;
}
match indexer::auto_refresh_is_due(db, config.index.auto_reindex_interval_ms) {
Ok(true) => {}
Ok(false) => return,
Err(error) => {
eprintln!("aise: background index refresh not started because refresh state could not be checked: {error:#}");
return;
}
}
if let Err(error) = spawn_background_refresh(
config,
crate::background_refresh::BackgroundRefreshOrigin::Cli,
) {
eprintln!("aise: background index refresh could not start: {error:#}");
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct BackgroundRefreshRequest {
config: Config,
origin: crate::background_refresh::BackgroundRefreshOrigin,
}
fn spawn_background_refresh(
config: &Config,
origin: crate::background_refresh::BackgroundRefreshOrigin,
) -> Result<()> {
let executable = crate::update::background_child_executable()?;
let mut child = Command::new(&executable)
.arg("__refresh-index")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("failed to start {} __refresh-index", executable.display()))?;
let mut stdin = child
.stdin
.take()
.context("background refresh process did not expose its configuration pipe")?;
serde_json::to_writer(
&mut stdin,
&BackgroundRefreshRequest {
config: config.clone(),
origin,
},
)
.context("failed to send resolved configuration to background refresh process")?;
stdin
.flush()
.context("failed to flush background refresh configuration")?;
Ok(())
}
fn start_initial_indexing_after_integration_install(
outcome: crate::integrations::IntegrationInstallOutcome,
overrides: ConfigOverrides,
config_path: &std::path::Path,
) {
if !outcome.should_start_initial_indexing() {
return;
}
let resolved = match Config::resolve(overrides) {
Ok(resolved) => resolved,
Err(error) => {
eprintln!(
"aise: integration installation succeeded, but session index preparation could not start because configuration {} could not be resolved: {error:#}. \
The installed integration files were preserved. Fix the configuration and run `aise reindex`; verify readiness with `aise doctor`.",
config_path.display()
);
return;
}
};
if let Err(error) = spawn_background_refresh(
&resolved.config,
crate::background_refresh::BackgroundRefreshOrigin::IntegrationInstall,
) {
eprintln!(
"aise: integration installation succeeded, but session index preparation could not start because the background refresh process failed to launch: {error:#}. \
The installed integration files were preserved. Run `aise reindex`; verify readiness with `aise doctor`."
);
return;
}
println!(
"started session index preparation in the background; run `aise doctor` to check readiness and freshness"
);
}
fn run_background_refresh_from_stdin() -> Result<()> {
let request: BackgroundRefreshRequest = serde_json::from_reader(io::stdin().lock())
.context("failed to read resolved background refresh configuration from stdin")?;
crate::background_refresh::run(&request.config, request.origin, &|| false)?;
Ok(())
}
fn render_rows<T: serde::Serialize + Row>(rows: &[T], format: OutputFormat) -> Result<()> {
let stdout = std::io::stdout();
let mut out = stdout.lock();
render(rows, format, &mut out)?;
out.flush()?;
Ok(())
}
struct SessionRecordOutput<'a> {
session: &'a SessionRecord,
include_raw_metadata: bool,
}
impl<'a> SessionRecordOutput<'a> {
fn new(session: &'a SessionRecord, include_raw_metadata: bool) -> Self {
Self {
session,
include_raw_metadata,
}
}
}
impl serde::Serialize for SessionRecordOutput<'_> {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let session = self.session;
let mut row = serializer.serialize_struct(
"SessionRecord",
if self.include_raw_metadata { 19 } else { 18 },
)?;
row.serialize_field("id", &session.id)?;
row.serialize_field("provider", &session.provider)?;
row.serialize_field("provider_session_id", &session.provider_session_id)?;
row.serialize_field("title", &session.title)?;
row.serialize_field("summary", &session.summary)?;
row.serialize_field("cwd", &session.cwd)?;
row.serialize_field("repo_root", &session.repo_root)?;
row.serialize_field("created_at", &session.created_at)?;
row.serialize_field("updated_at", &session.updated_at)?;
row.serialize_field("last_message_at", &session.last_message_at)?;
row.serialize_field("preview_text", &session.preview_text)?;
row.serialize_field("source_path", &session.source_path)?;
row.serialize_field("message_count", &session.message_count)?;
row.serialize_field("parse_version", &session.parse_version)?;
if self.include_raw_metadata {
row.serialize_field("raw_metadata_json", &session.raw_metadata_json)?;
}
row.serialize_field("parse_warning", &session.parse_warning)?;
row.serialize_field("discovery_source", &session.discovery_source)?;
row.serialize_field("parent_session_id", &session.parent_session_id)?;
row.serialize_field("agent_label", &session.agent_label)?;
row.end()
}
}
#[derive(Serialize)]
struct SearchHitOutput<'a> {
#[serde(flatten)]
session: SessionRecordOutput<'a>,
score: i64,
match_source: &'a str,
match_snippet: &'a str,
}
trait SessionMachineOutput: serde::Serialize + Row {
type Output<'a>: serde::Serialize
where
Self: 'a;
fn machine_output(&self, include_raw_metadata: bool) -> Self::Output<'_>;
}
impl SessionMachineOutput for SessionRecord {
type Output<'a> = SessionRecordOutput<'a>;
fn machine_output(&self, include_raw_metadata: bool) -> Self::Output<'_> {
SessionRecordOutput::new(self, include_raw_metadata)
}
}
impl SessionMachineOutput for crate::models::SearchHit {
type Output<'a> = SearchHitOutput<'a>;
fn machine_output(&self, include_raw_metadata: bool) -> Self::Output<'_> {
SearchHitOutput {
session: SessionRecordOutput::new(&self.session, include_raw_metadata),
score: self.score,
match_source: &self.match_source,
match_snippet: &self.match_snippet,
}
}
}
fn render_session_rows<T: SessionMachineOutput>(
rows: &[T],
format: OutputFormat,
include: &[SessionInclude],
) -> Result<()> {
let stdout = std::io::stdout();
let mut out = stdout.lock();
render_session_rows_to(rows, format, include, &mut out)?;
out.flush()?;
Ok(())
}
fn render_session_rows_to<T: SessionMachineOutput, W: Write>(
rows: &[T],
format: OutputFormat,
include: &[SessionInclude],
out: &mut W,
) -> Result<()> {
let include_raw_metadata = include.contains(&SessionInclude::RawMetadata);
match format {
OutputFormat::Json => {
let projected = rows
.iter()
.map(|row| row.machine_output(include_raw_metadata))
.collect::<Vec<_>>();
writeln!(out, "{}", serde_json::to_string_pretty(&projected)?)?;
}
OutputFormat::Jsonl => {
for row in rows {
writeln!(
out,
"{}",
serde_json::to_string(&row.machine_output(include_raw_metadata))?
)?;
}
}
OutputFormat::Table | OutputFormat::Csv | OutputFormat::Plain => {
render(rows, format, out)?;
}
}
Ok(())
}
fn configured_search_limit(limit: Option<usize>, config: &Config) -> usize {
limit.unwrap_or(config.search.default_limit)
}
fn parse_first_canonical_sessions(value: &str) -> std::result::Result<NonZeroUsize, String> {
value
.parse::<usize>()
.ok()
.and_then(NonZeroUsize::new)
.ok_or_else(|| {
format!(
"first-canonical-sessions must be a positive integer; omit it to analyze every eligible session; got {value:?}"
)
})
}
fn export_filters_are_empty(filters: &SearchFilters) -> bool {
filters.provider.is_none()
&& filters.path_prefix.is_none()
&& filters.exclude_path_prefixes.is_empty()
&& filters.exclude_session_ids.is_empty()
&& filters.session_kinds.is_none()
&& filters.parent_session_id.is_none()
&& filters.since.is_none()
&& filters.until.is_none()
&& !filters.warnings_only
}
fn build_filters(args: &SessionFilterArgs, limit: usize) -> Result<SearchFilters> {
let (since, until) = args.dates.resolve_now()?;
let filters = SearchFilters {
provider: args.provider,
path_prefix: args.path.as_deref().map(crate::util::normalize_path_prefix),
exclude_path_prefixes: args
.exclude_paths
.iter()
.map(|path| crate::util::normalize_path_prefix(path))
.collect(),
exclude_session_ids: args.exclude_sessions.clone(),
session_kinds: args
.session_kind
.map(|kind| vec![kind])
.or_else(|| (!args.session_kinds.is_empty()).then(|| args.session_kinds.clone())),
parent_session_id: args.parent_session.clone(),
since,
until,
limit,
warnings_only: args.warnings_only,
};
filters.validate()?;
Ok(filters)
}
fn print_sessions(sessions: &[SessionRecord]) {
if sessions.is_empty() {
println!("no sessions found");
return;
}
for session in sessions {
print_session_row(session, None, None);
}
}
fn print_session_row(session: &SessionRecord, match_source: Option<&str>, score: Option<i64>) {
let title = session
.title
.as_deref()
.map(|value| truncate_for_display(value, 72))
.unwrap_or_else(|| session.preview_text.clone());
let cwd = session.cwd.as_deref().unwrap_or("-");
let mut suffix = String::new();
if let Some(source) = match_source {
suffix.push_str(&format!(" match={source}"));
}
if let Some(score) = score {
suffix.push_str(&format!(" score={score}"));
}
println!(
"{} {:<6} {:<38} {:<72}{}",
relative_age(session.updated_at),
session.provider,
session.provider_session_id,
title,
suffix
);
println!(" cwd={} preview={}", cwd, session.preview_text);
if session.parent_session_id.is_some() || session.agent_label.is_some() {
let agent = session.agent_label.as_deref().unwrap_or("-");
let parent = session.parent_session_id.as_deref().unwrap_or("-");
println!(" agent={agent} parent={parent}");
}
if let Some(warning) = &session.parse_warning {
println!(" warning={warning}");
}
}
fn print_search_hit(hit: &crate::models::SearchHit, query: &str) {
let title = hit
.session
.title
.as_deref()
.map(|value| truncate_for_display(value, 72))
.unwrap_or_else(|| hit.session.preview_text.clone());
let title = highlight_matches(&title, query);
let cwd = hit.session.cwd.as_deref().unwrap_or("-");
println!(
"{} {:<6} {:<38} {} match={} score={}",
relative_age(hit.session.updated_at),
hit.session.provider,
hit.session.provider_session_id,
title,
hit.match_source,
hit.score
);
println!(
" cwd={} preview={}",
cwd,
highlight_matches(&hit.session.preview_text, query)
);
println!(
" hit[{}]: {}",
hit.match_source,
highlight_matches(&hit.match_snippet, query)
);
if let Some(warning) = &hit.session.parse_warning {
println!(" warning={warning}");
}
}
fn print_session_detail(session: &SessionRecord) {
println!("ID: {}", session.id);
println!("Provider: {}", session.provider);
println!("Provider Session ID: {}", session.provider_session_id);
println!("Title: {}", session.title.as_deref().unwrap_or("-"));
println!("Summary: {}", session.summary.as_deref().unwrap_or("-"));
println!("CWD: {}", session.cwd.as_deref().unwrap_or("-"));
println!("Repo Root: {}", session.repo_root.as_deref().unwrap_or("-"));
println!(
"Created: {}",
session
.created_at
.map(|value| value.to_rfc3339())
.unwrap_or_else(|| "-".to_string())
);
println!(
"Updated: {}",
session
.updated_at
.map(|value| value.to_rfc3339())
.unwrap_or_else(|| "-".to_string())
);
println!("Messages: {}", session.message_count.unwrap_or_default());
println!("Source Path: {}", session.source_path);
println!("Discovery: {}", session.discovery_source);
if let Some(warning) = &session.parse_warning {
println!("Parse Warning: {warning}");
}
}
fn print_unindexed_explanation(unindexed: &[crate::diagnostics::UnindexedFile]) {
if unindexed.is_empty() {
println!("\nUnindexed files: none; every discovered file produced a session.");
return;
}
println!("\nUnindexed files: {}", unindexed.len());
for item in unindexed {
println!(" {} [{}]", item.path, item.provider);
match &item.id_already_held_by {
Some(holder) => println!(
" resolves to session id {}, which is already held by {holder}",
item.resolves_to
),
None => println!(
" resolves to session id {}, which no indexed file holds, so this file was \
skipped for another reason",
item.resolves_to
),
}
}
}
fn print_doctor(
config: &Config,
db: &Db,
format: DoctorFormat,
explain_unindexed: bool,
) -> Result<()> {
let diagnostics = crate::diagnostics::collect(config, db)?;
let unindexed = explain_unindexed
.then(|| crate::diagnostics::explain_unindexed(config, db))
.transpose()?;
if format == DoctorFormat::Json {
let mut document = serde_json::to_value(&diagnostics)?;
if let Some(unindexed) = unindexed {
document
.as_object_mut()
.expect("DiagnosticStatus serializes as an object")
.insert(
"unindexed_file_explanations".to_string(),
serde_json::to_value(unindexed)?,
);
}
println!("{}", serde_json::to_string_pretty(&document)?);
return Ok(());
}
let status = &diagnostics.index_status;
let health = &diagnostics.providers;
let warnings = status.parser_health.parse_warnings;
println!("DB: {}", config.db_path().display());
let allocation = db.storage_allocation()?;
println!(
"Storage: {} bytes total; {} bytes reclaimable",
allocation.total_bytes, allocation.reclaimable_bytes
);
if let Some(guidance) = storage_compaction_guidance(allocation) {
println!("Maintenance: {guidance}");
}
println!(
"Parser health: {} current, {} stale ({} repairable, {} unavailable); schema {}/{}",
status.parser_health.current_sessions,
status.parser_health.stale_sessions,
status.repairable_stale_sessions,
status.unavailable_stale_sessions,
status.parser_health.schema_version,
status.parser_health.expected_schema_version
);
if status.unavailable_stale_sessions > 0 {
println!(
" of those, {} are retained: the transcript was deleted from disk, so the \
indexed copy is all that remains and no repair applies",
status.unavailable_stale_sessions
);
}
if status.unindexed_files > 0 {
println!(
"Unindexed: {} discovered file(s) produced no session, so their content is \
absent from every search result",
status.unindexed_files
);
}
for command in &status.repair_commands {
println!("Repair: {command}");
}
print_auto_reindex_status(config, db)?;
println!(
"Index snapshot: {}{}",
status.readiness.snapshot.availability.as_str(),
status
.readiness
.snapshot
.last_successful_refresh_at
.map(|value| format!("; last successful refresh {}", value.to_rfc3339()))
.unwrap_or_default()
);
let refresh = &status.readiness.refresh;
println!("Index refresh: {}", refresh.state.as_str());
if let (Some(processed), Some(discovered)) = (refresh.files_processed, refresh.files_discovered)
{
println!(
"Index refresh progress: {processed}/{discovered} files; {} session(s) updated",
refresh.sessions_updated.unwrap_or_default()
);
}
if let Some(message) = &refresh.message {
println!("Index refresh detail: {message}");
}
if let Some(command) = &refresh.next_command {
println!("Index refresh next command: {command}");
}
println!("Parse warnings indexed: {warnings}");
for warning in &diagnostics.discovery_warnings {
println!(
"Discovery warning: {} {} {}: {} {}",
warning.provider, warning.operation, warning.path, warning.message, warning.guidance
);
}
for item in health {
println!("\nProvider: {}", item.provider);
println!(
" binary: {}",
if item.cli_available {
"present"
} else {
"missing"
}
);
println!(" roots: {}", item.roots.join(", "));
println!(" files discovered: {}", item.discovered_files);
println!(" sessions indexed: {}", item.indexed_sessions);
if item.unindexed_files > 0 {
println!(
" files not indexed: {} (discovered but absent from the index)",
item.unindexed_files
);
}
println!(
" parser: {} current, {} stale ({} repairable, {} unavailable; expected {})",
item.current_sessions,
item.stale_sessions,
item.repairable_stale_sessions,
item.unavailable_stale_sessions,
item.expected_parse_version
);
println!(
" resume: {}",
item.resume_command.as_deref().unwrap_or("not supported")
);
}
if let Some(unindexed) = unindexed {
print_unindexed_explanation(&unindexed);
}
Ok(())
}
fn storage_compaction_guidance(allocation: crate::db::StorageAllocation) -> Option<String> {
(allocation.reclaimable_bytes > 0).then(|| {
format!(
"{} bytes ({}) of {} are reclaimable; run `aise compact` when an exclusive database lock and temporary disk space are available",
allocation.reclaimable_bytes,
mib(allocation.reclaimable_bytes),
mib(allocation.total_bytes)
)
})
}
fn print_auto_reindex_status(config: &Config, db: &Db) -> Result<()> {
let completed_at = db.auto_reindex_completed_at()?;
let fresh = db.auto_reindex_is_fresh(config.index.auto_reindex_interval_ms)?;
let window = if config.index.auto_reindex_interval_ms == 0 {
"free-read window disabled".to_string()
} else {
format!(
"free-read window {} ms",
config.index.auto_reindex_interval_ms
)
};
match completed_at {
Some(value) => println!(
"Auto-reindex last completed: {} ({}, {}, {})",
value.to_rfc3339(),
relative_age(Some(value)),
if fresh { "fresh" } else { "stale" },
window
),
None => println!("Auto-reindex last completed: never ({window})"),
}
Ok(())
}
#[derive(Debug, Serialize)]
struct ConfigPathsReport {
config_file: PathBuf,
database: PathBuf,
cache: PathBuf,
search_scope: ConfigSearchScopeReport,
background_refresh_status: PathBuf,
provider_roots: Vec<ProviderRootsReport>,
codex_metadata_homes: Vec<PathBuf>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "mode", rename_all = "kebab-case")]
enum ConfigSearchScopeReport {
All,
AllowedRoots { roots: Vec<AllowedRootReport> },
}
#[derive(Debug, Serialize)]
struct AllowedRootReport {
path: PathBuf,
sources: Vec<AllowedRootSourceReport>,
}
#[derive(Debug, Serialize)]
struct AllowedRootSourceReport {
origin: String,
configured_path: PathBuf,
}
#[derive(Debug, Serialize)]
struct ProviderRootsReport {
provider: Provider,
enabled: bool,
roots: Vec<PathBuf>,
}
fn config_paths_report(
config: &Config,
config_path: &std::path::Path,
) -> Result<ConfigPathsReport> {
let access = crate::search_scope::EffectiveAccessScope::resolve(
&config.search.scope,
crate::search_scope::TrustedAccessInputs::capture(&config.search.scope, Vec::new())?,
)?;
let search_scope = match access {
crate::search_scope::EffectiveAccessScope::All => ConfigSearchScopeReport::All,
crate::search_scope::EffectiveAccessScope::AllowedRoots { roots } => {
ConfigSearchScopeReport::AllowedRoots {
roots: roots
.into_iter()
.map(|root| AllowedRootReport {
path: root.path().to_path_buf(),
sources: root
.sources()
.iter()
.map(|source| AllowedRootSourceReport {
origin: source.origin().as_str().to_owned(),
configured_path: source.configured_path().to_path_buf(),
})
.collect(),
})
.collect(),
}
}
};
let provider_roots = crate::source::PROVIDERS
.into_iter()
.map(|provider| ProviderRootsReport {
provider,
enabled: crate::source::provider_enabled(config, provider),
roots: crate::source::provider_roots(config, provider),
})
.collect();
Ok(ConfigPathsReport {
config_file: config_path.to_path_buf(),
database: config.db_path(),
cache: config.cache_dir(),
search_scope,
background_refresh_status: crate::background_refresh::report_path(config),
provider_roots,
codex_metadata_homes: crate::source::provider_roots(config, Provider::Codex)
.iter()
.map(|root| crate::providers::codex::codex_home_from_session_root(root))
.collect(),
})
}
fn print_config_paths(
config: &Config,
config_path: &std::path::Path,
format: ReportOutputFormat,
) -> Result<()> {
let report = config_paths_report(config, config_path)?;
if format == ReportOutputFormat::Json {
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
let stdout = io::stdout();
let mut out = stdout.lock();
writeln!(out, "Config: {}", report.config_file.display())?;
writeln!(out, "DB: {}", report.database.display())?;
writeln!(out, "Cache: {}", report.cache.display())?;
match &report.search_scope {
ConfigSearchScopeReport::All => {
writeln!(out, "Search scope: all (unrestricted)")?;
}
ConfigSearchScopeReport::AllowedRoots { roots } => {
writeln!(out, "Search scope: allowed-roots")?;
for root in roots {
let sources = root
.sources
.iter()
.map(|source| format!("{}:{}", source.origin, source.configured_path.display()))
.collect::<Vec<_>>()
.join(", ");
writeln!(
out,
"Search allowed root: {} ({sources})",
root.path.display()
)?;
}
}
}
writeln!(
out,
"Background refresh status: {}",
report.background_refresh_status.display()
)?;
for provider in &report.provider_roots {
writeln!(
out,
"{} roots: {}{}",
provider.provider.display_name(),
provider
.roots
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", "),
if provider.enabled { "" } else { " (disabled)" }
)?;
}
for line in codex_metadata_home_lines(&report) {
writeln!(out, "{line}")?;
}
Ok(())
}
fn codex_metadata_home_lines(report: &ConfigPathsReport) -> Vec<String> {
report
.codex_metadata_homes
.iter()
.map(|home| format!("Codex metadata home: {}", home.display()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_command_after_an_upgrade_refreshes_unless_it_manages_integrations() {
let refreshes = |args: &[&str]| {
let cli =
Cli::try_parse_from(std::iter::once("aise").chain(args.iter().copied())).unwrap();
refreshes_integrations_after_upgrade(&cli.command)
};
for args in [
&["integrations", "status"][..],
&["mcp", "serve"],
&["config", "show"],
&["package", "status"],
&["dates"],
&["list"],
&["skills", "update"],
] {
assert!(refreshes(args), "{args:?} should refresh first");
}
for args in [
&["integrations", "install"][..],
&["integrations", "uninstall"],
&["package", "update"],
&["skills", "update", "--dry-run"],
&[
"skills",
"restore",
"ai-session-search",
"--skill-root",
"/x",
],
] {
assert!(!refreshes(args), "{args:?} manages integrations itself");
}
}
use clap::CommandFactory;
#[test]
fn config_paths_reports_every_root_derived_codex_metadata_home() {
let temp = tempfile::tempdir().unwrap();
let home_a = temp.path().join("home-a");
let home_b = temp.path().join("home-b");
std::fs::create_dir_all(home_a.join("sessions")).unwrap();
std::fs::create_dir_all(home_b.join("sessions")).unwrap();
let home_a = std::fs::canonicalize(home_a).unwrap();
let home_b = std::fs::canonicalize(home_b).unwrap();
let mut config = Config::default();
config.providers.codex.paths = vec![
home_a.join("sessions").display().to_string(),
home_b.join("sessions").display().to_string(),
];
let report = config_paths_report(&config, &temp.path().join("config.toml")).unwrap();
assert_eq!(
report.codex_metadata_homes,
vec![home_a.clone(), home_b.clone()]
);
let json = serde_json::to_value(&report).unwrap();
assert_eq!(
json["codex_metadata_homes"],
serde_json::json!([home_a, home_b])
);
assert!(json.get("codex_metadata_home").is_none());
assert_eq!(codex_metadata_home_lines(&report).len(), 2);
}
fn session_with_raw_metadata() -> SessionRecord {
SessionRecord {
id: "codex:test-session".into(),
provider: Provider::Codex,
provider_session_id: "test-session".into(),
title: Some("metadata fixture".into()),
summary: None,
cwd: Some("/tmp/project".into()),
repo_root: Some("/tmp/project".into()),
created_at: None,
updated_at: None,
last_message_at: None,
preview_text: "fixture preview".into(),
source_path: "/tmp/session.jsonl".into(),
message_count: Some(1),
parse_version: "test-v1".into(),
raw_metadata_json: Some(r#"{"large":"provider payload"}"#.into()),
parse_warning: None,
discovery_source: "test".into(),
parent_session_id: None,
agent_label: None,
}
}
fn assert_parses<const N: usize>(args: [&str; N]) {
Cli::try_parse_from(args)
.unwrap_or_else(|err| panic!("expected CLI args to parse: {args:?}: {err}"));
}
fn assert_rejects<const N: usize>(args: [&str; N]) {
assert!(
Cli::try_parse_from(args).is_err(),
"expected CLI args to be rejected: {args:?}"
);
}
fn assert_root_options_apply<const N: usize>(args: [&str; N]) {
let cli = Cli::try_parse_from(args)
.unwrap_or_else(|err| panic!("expected CLI args to parse: {args:?}: {err}"));
validate_root_options(&cli)
.unwrap_or_else(|err| panic!("expected root options to apply: {args:?}: {err}"));
}
fn assert_root_option_is_irrelevant<const N: usize>(args: [&str; N], flag: &str) {
let cli = Cli::try_parse_from(args)
.unwrap_or_else(|err| panic!("expected CLI args to parse: {args:?}: {err}"));
let error = validate_root_options(&cli).unwrap_err().to_string();
assert!(error.contains(flag), "{error}");
assert!(error.contains("does not apply"), "{error}");
}
fn documented_aise_invocations(document: &str) -> Vec<(usize, Vec<String>)> {
let mut invocations = Vec::new();
let mut in_shell_block = false;
for (index, raw) in document.lines().enumerate() {
let line = raw.trim();
if let Some(fence) = line.strip_prefix("```") {
in_shell_block = !in_shell_block
&& matches!(fence.trim(), "sh" | "bash" | "shell" | "zsh" | "console");
continue;
}
if !in_shell_block || line.starts_with('#') {
continue;
}
let Some(command) = line.strip_prefix("$ ").or(Some(line)) else {
continue;
};
let Some(rest) = command.strip_prefix("aise ") else {
continue;
};
let words = shlex::split(&format!("aise {rest}"))
.unwrap_or_else(|| panic!("line {}: unbalanced quoting: {raw}", index + 1));
let mut invocation = Vec::new();
for word in words {
if matches!(word.as_str(), "|" | ";" | "&&" | "||" | ">" | ">>" | "2>&1") {
break;
}
invocation.push(match word.as_str() {
"SESSION_ID" => "claude:00000000-0000-4000-8000-000000000000".to_owned(),
other => other.replace("SESSION_ID", "00000000-0000-4000-8000-000000000000"),
});
}
invocations.push((index + 1, invocation));
}
invocations
}
#[test]
fn session_search_without_a_query_names_the_equivalent_list_command() {
let cli = Cli::try_parse_from([
"aise",
"search",
"--path",
"/work/repo",
"--when",
"7d",
"--limit",
"10",
])
.expect("a query-less search parses so the guidance can be rendered");
let Commands::Search(args) = cli.command else {
panic!("expected search command")
};
let error = args.ranking_query().unwrap_err().to_string();
assert!(
error.contains("aise list --path /work/repo --when 7d --limit 10"),
"{error}"
);
let cli = Cli::try_parse_from(["aise", "search", " ", "--provider", "codex"]).unwrap();
let Commands::Search(args) = cli.command else {
panic!("expected search command")
};
let error = args.ranking_query().unwrap_err().to_string();
assert!(error.contains("aise list --provider codex"), "{error}");
let cli = Cli::try_parse_from(["aise", "search", "receipt corpus"]).unwrap();
let Commands::Search(args) = cli.command else {
panic!("expected search command")
};
assert_eq!(args.ranking_query().unwrap(), "receipt corpus");
}
#[test]
fn the_suggested_list_command_round_trips_every_session_filter_and_output_option() {
let typed = [
"aise",
"search",
"--provider",
"claude",
"--path",
"/work/repo",
"--exclude-path",
"/work/repo/vendor",
"--exclude-path",
"/work/repo/target",
"--exclude-session",
"claude:79accec8",
"--exclude-session",
"codex:1f2e3d4c",
"--session-kinds",
"user,subagent",
"--parent-session",
"claude:aabbccdd",
"--warnings-only",
"--since",
"2026-01-15",
"--until",
"2026-02",
"--limit",
"25",
"--format",
"json",
"--include",
"raw-metadata",
];
let cli = Cli::try_parse_from(typed).expect("a query-less search parses");
let Commands::Search(args) = cli.command else {
panic!("expected search command")
};
let suggested = args.equivalent_list_command();
let parts = shlex::split(&suggested)
.unwrap_or_else(|| panic!("the suggested command is one POSIX line: {suggested}"));
let parsed = Cli::try_parse_from(&parts)
.unwrap_or_else(|error| panic!("the suggested command parses: {suggested}\n{error}"));
let Commands::List(listed) = parsed.command else {
panic!("the suggestion runs `aise list`: {suggested}")
};
assert_eq!(
format!("{listed:?}"),
format!("{:?}", args.filters),
"`{suggested}` selects a different session set or emits different fields than the \
search that suggested it"
);
let cli = Cli::try_parse_from(["aise", "search", "--session-kind", "subagent"]).unwrap();
let Commands::Search(args) = cli.command else {
panic!("expected search command")
};
let suggested = args.equivalent_list_command();
let parts = shlex::split(&suggested).expect("one POSIX line");
let Commands::List(listed) = Cli::try_parse_from(&parts)
.unwrap_or_else(|error| panic!("{suggested}\n{error}"))
.command
else {
panic!("the suggestion runs `aise list`: {suggested}")
};
assert_eq!(
format!("{listed:?}"),
format!("{:?}", args.filters),
"{suggested}"
);
}
#[test]
fn root_help_names_every_visible_command_once_and_lists_everyday_commands_first() {
let command = Cli::command();
let visible: Vec<&str> = command
.get_subcommands()
.filter(|sub| !sub.is_hide_set())
.map(|sub| sub.get_name())
.collect();
let grouped: Vec<&str> = ROOT_COMMAND_GROUPS
.lines()
.take_while(|line| !line.is_empty())
.filter_map(|line| line.split_once(':').map(|(_, names)| names))
.flat_map(|names| names.split(','))
.map(str::trim)
.filter(|name| !name.is_empty())
.collect();
for name in &visible {
assert_eq!(
grouped.iter().filter(|g| g == &name).count(),
1,
"visible command `{name}` must appear exactly once in ROOT_COMMAND_GROUPS"
);
}
for name in &grouped {
assert!(
visible.contains(name),
"ROOT_COMMAND_GROUPS names `{name}`, which is not a visible command"
);
}
let rendered = Cli::command().render_help().to_string();
let commands_section = rendered
.split("Commands:")
.nth(1)
.and_then(|rest| rest.split("Options:").next())
.expect("root help has a Commands section");
let listed: Vec<&str> = commands_section
.lines()
.filter_map(|line| line.split_whitespace().next())
.collect();
let everyday = ["search", "messages", "show", "list", "resume", "files"];
assert_eq!(
&listed[..everyday.len()],
&everyday,
"the everyday commands lead the root help: {listed:?}"
);
assert!(
rendered.contains("Start here (everyday):"),
"the group legend is printed after the options"
);
}
#[test]
fn path_scope_help_states_component_boundary_matching_on_both_commands() {
let root = Cli::command();
let mut search = root.find_subcommand("search").expect("search").clone();
let session_help = search.render_long_help().to_string();
let mut messages_search = root
.find_subcommand("messages")
.expect("messages")
.find_subcommand("search")
.expect("messages search")
.clone();
let message_help = messages_search.render_long_help().to_string();
for help in [&session_help, &message_help] {
assert!(
help.contains("component boundary"),
"path scope help must state component-boundary matching: {help}"
);
assert!(
!help.contains("sibling sharing the leading path"),
"a lexical sibling never matches: {help}"
);
}
assert!(
message_help.contains("last N matching messages of that session"),
"--match-window latest selects which messages, not which occurrence: {message_help}"
);
}
#[test]
fn messages_search_help_files_every_option_under_a_named_section() {
let root = Cli::command();
let messages = root
.find_subcommand("messages")
.expect("messages subcommand");
let search = messages
.find_subcommand("search")
.expect("messages search subcommand");
let unsectioned: Vec<String> = search
.get_arguments()
.filter(|arg| arg.get_help_heading().is_none())
.filter(|arg| !arg.is_positional() && arg.get_id() != "help")
.map(|arg| arg.get_id().to_string())
.collect();
assert!(
unsectioned.is_empty(),
"every messages-search option needs a help_heading; missing: {unsectioned:?}"
);
let rendered = search.clone().render_long_help().to_string();
let sections = [
"Query (what to find):",
"Filters (which messages):",
"Time window (formats: `aise dates`):",
"Result window and context (how many, from where):",
"Presentation and output (how matches are shown; matching stays the same):",
"Advanced (purpose bundles, receipts, self-description):",
];
let mut last = 0;
for section in sections {
let position = rendered.find(section).unwrap_or_else(|| {
panic!("section `{section}` missing from messages search --help")
});
assert!(
position > last,
"sections must render in the documented order"
);
last = position;
}
}
#[test]
fn every_documented_aise_example_parses_against_the_current_cli() {
let documents = [
(
"skills/ai-session-search/SKILL.md",
include_str!("../skills/ai-session-search/SKILL.md"),
),
("README.md", include_str!("../../../README.md")),
];
let mut failures = Vec::new();
let mut checked = 0usize;
for (name, document) in documents {
for (line, invocation) in documented_aise_invocations(document) {
checked += 1;
let rendered = invocation.join(" ");
let cli = match Cli::try_parse_from(&invocation) {
Ok(cli) => cli,
Err(error)
if matches!(
error.kind(),
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayVersion
) =>
{
continue;
}
Err(error) => {
let first_line = error.to_string();
let first_line = first_line.lines().next().unwrap_or_default().to_owned();
failures.push(format!("{name}:{line}: {rendered}\n {first_line}"));
continue;
}
};
if let Commands::Skills(command) = cli.command {
if !command.is_management() {
if let Err(error) = command.into_execution() {
failures.push(format!("{name}:{line}: {rendered}\n {error}"));
}
}
}
}
}
assert!(
checked >= 40,
"expected the documents to carry examples; found {checked}"
);
assert!(
failures.is_empty(),
"{} documented aise invocation(s) do not parse against this build:\n{}",
failures.len(),
failures.join("\n")
);
}
#[test]
fn skills_accept_inferred_and_collision_escape_execution() {
assert_parses(["aise", "skills", "corrections", "--when", "7d"]);
assert_parses(["aise", "skills", "./my-review/SKILL.md", "--when", "7d"]);
assert_parses(["aise", "skills", "run", "list", "--when", "7d"]);
}
#[test]
fn skill_execution_parses_ordered_additional_name_and_path_selectors() {
let cli = Cli::try_parse_from([
"aise",
"skills",
"corrections",
"--skill",
"my-review",
"--skill",
"./other-review/SKILL.md",
])
.unwrap();
let Commands::Skills(command) = cli.command else {
panic!("expected skills command")
};
let execution = command.into_execution().unwrap();
assert_eq!(
execution.args.additional_skills,
vec![
OsString::from("my-review"),
OsString::from("./other-review/SKILL.md")
]
);
}
#[test]
fn global_options_after_an_inferred_skill_remain_global() {
let cli = parse_cli_from([
"aise",
"skills",
"corrections",
"--when",
"7d",
"--config",
"/tmp/aise-config.toml",
])
.unwrap();
assert_eq!(cli.config, Some(PathBuf::from("/tmp/aise-config.toml")));
let Commands::Skills(command) = cli.command else {
panic!("expected skills command")
};
let execution = command.into_execution().unwrap();
assert_eq!(execution.args.dates.when.as_deref(), Some("7d"));
}
#[test]
fn correction_specific_root_command_is_not_part_of_the_release_cli() {
assert_rejects(["aise", "corrections", "--when", "7d"]);
}
#[test]
fn session_class_filters_reach_every_session_command() {
for base in [vec!["aise", "search", "migrations"], vec!["aise", "list"]] {
for accepted in [
["--session-kinds", "subagent"],
["--session-kinds", "user,subagent"],
["--session-kind", "user"],
[
"--parent-session",
"claude:7e745098-c299-4cf5-bdbe-5cdb1fb5a62d",
],
] {
let mut args = base.clone();
args.extend_from_slice(&accepted);
Cli::try_parse_from(&args)
.unwrap_or_else(|err| panic!("expected {args:?} to parse: {err}"));
}
for rejected in [
vec!["--session-kinds", "agent"],
vec!["--session-kinds", "top-level"],
vec!["--session-kind", "user", "--session-kinds", "subagent"],
] {
let mut args = base.clone();
args.extend_from_slice(&rejected);
assert!(
Cli::try_parse_from(&args).is_err(),
"expected {args:?} to be rejected"
);
}
}
}
#[test]
fn session_class_args_resolve_to_filters() {
let filters = |args: &[&str]| {
let cli = Cli::try_parse_from(
std::iter::once("aise")
.chain(args.iter().copied())
.collect::<Vec<_>>(),
)
.expect("args parse");
match cli.command {
Commands::List(args) => build_filters(&args.filters, 10).expect("built"),
other => panic!("expected list, got {other:?}"),
}
};
assert_eq!(filters(&["list"]).session_kinds, None);
assert_eq!(
filters(&["list", "--session-kinds", "subagent"]).session_kinds,
Some(vec![SessionKind::Subagent])
);
assert_eq!(
filters(&["list", "--session-kinds", "user,subagent"]).session_kinds,
Some(vec![SessionKind::User, SessionKind::Subagent])
);
assert_eq!(
filters(&["list", "--session-kind", "user"]).session_kinds,
Some(vec![SessionKind::User]),
"the one-value alias resolves into the same set the list does"
);
assert_eq!(
filters(&["list", "--parent-session", "claude:abc"]).parent_session_id,
Some("claude:abc".to_string())
);
let cli = Cli::try_parse_from([
"aise",
"list",
"--session-kinds",
"user",
"--parent-session",
"claude:abc",
])
.expect("arguments parse before shared semantic validation");
let Commands::List(args) = cli.command else {
panic!("expected list")
};
let error = build_filters(&args.filters, 10)
.expect_err("a parent cannot have a user-started child session")
.to_string();
assert!(error.contains("session_kinds"), "{error}");
assert!(error.contains("parent_session_id"), "{error}");
assert!(error.contains("subagent"), "{error}");
}
#[test]
fn session_machine_output_omits_raw_metadata_unless_explicitly_included() {
let sessions = [session_with_raw_metadata()];
for format in [OutputFormat::Json, OutputFormat::Jsonl] {
let mut default_output = Vec::new();
render_session_rows_to(&sessions, format, &[], &mut default_output).unwrap();
let default_output = String::from_utf8(default_output).unwrap();
assert!(
!default_output.contains("raw_metadata_json"),
"{format:?} must omit the field rather than serialize null: {default_output}"
);
assert!(
!default_output.contains("provider payload"),
"{format:?} leaked the provider metadata payload: {default_output}"
);
let mut included_output = Vec::new();
render_session_rows_to(
&sessions,
format,
&[SessionInclude::RawMetadata],
&mut included_output,
)
.unwrap();
let included_output = String::from_utf8(included_output).unwrap();
assert!(included_output.contains("raw_metadata_json"));
assert!(included_output.contains("provider payload"));
}
}
#[test]
fn ranked_search_machine_output_applies_the_same_metadata_policy() {
let hits = [crate::models::SearchHit {
session: session_with_raw_metadata(),
score: 42,
match_source: "preview".into(),
match_snippet: "matched needle".into(),
}];
let mut default_output = Vec::new();
render_session_rows_to(&hits, OutputFormat::Json, &[], &mut default_output).unwrap();
let default_value: serde_json::Value = serde_json::from_slice(&default_output).unwrap();
let hit = &default_value.as_array().unwrap()[0];
assert!(hit.get("raw_metadata_json").is_none());
assert_eq!(hit["score"], 42);
assert_eq!(hit["match_source"], "preview");
assert_eq!(hit["match_snippet"], "matched needle");
let mut included_output = Vec::new();
render_session_rows_to(
&hits,
OutputFormat::Jsonl,
&[SessionInclude::RawMetadata],
&mut included_output,
)
.unwrap();
let included_value: serde_json::Value = serde_json::from_slice(&included_output).unwrap();
assert_eq!(
included_value["raw_metadata_json"],
r#"{"large":"provider payload"}"#
);
assert_eq!(included_value["score"], 42);
}
#[test]
fn session_include_does_not_change_established_tabular_rows() {
let sessions = [session_with_raw_metadata()];
for format in [OutputFormat::Table, OutputFormat::Csv, OutputFormat::Plain] {
let mut baseline = Vec::new();
render(&sessions, format, &mut baseline).unwrap();
let mut with_include = Vec::new();
render_session_rows_to(
&sessions,
format,
&[SessionInclude::RawMetadata],
&mut with_include,
)
.unwrap();
assert_eq!(with_include, baseline, "{format:?} output changed");
}
}
#[test]
fn list_and_search_share_the_explicit_raw_metadata_include_control() {
for args in [
vec![
"aise",
"list",
"--format",
"json",
"--include",
"raw-metadata",
],
vec![
"aise",
"search",
"needle",
"--format",
"jsonl",
"--include",
"raw-metadata",
],
] {
let cli = Cli::try_parse_from(&args)
.unwrap_or_else(|error| panic!("expected {args:?} to parse: {error}"));
let includes = match cli.command {
Commands::List(args) => args.include,
Commands::Search(args) => args.filters.include,
other => panic!("expected list or search, got {other:?}"),
};
assert_eq!(includes, vec![SessionInclude::RawMetadata]);
}
assert_rejects(["aise", "list", "--format", "json", "--include", "unknown"]);
for subcommand in ["list", "search"] {
let args = if subcommand == "search" {
vec!["aise", "search", "needle", "--help"]
} else {
vec!["aise", "list", "--help"]
};
let help = Cli::try_parse_from(args).unwrap_err().to_string();
assert!(help.contains("--include"), "{subcommand}: {help}");
assert!(help.contains("raw-metadata"), "{subcommand}: {help}");
assert!(help.contains("omitted by default"), "{subcommand}: {help}");
}
}
#[test]
fn analyze_accepts_shared_scope_policy_and_publication_controls() {
let cli = Cli::try_parse_from([
"aise",
"analyze",
"--provider",
"codex",
"--first-canonical-sessions",
"2",
"--output",
"/tmp/analysis-bundle",
"--policy",
"/tmp/policy.json",
"--publication-format",
"json",
])
.unwrap();
let Commands::Analyze(args) = cli.command else {
panic!("expected analyze command");
};
assert_eq!(args.filters.provider, Some(Provider::Codex));
assert_eq!(args.first_canonical_sessions, NonZeroUsize::new(2));
assert_eq!(args.publication_formats, [AnalysisFormatArg::Json]);
let cli = Cli::try_parse_from(["aise", "analyze", "--output", "/tmp/full-analysis-bundle"])
.unwrap();
let Commands::Analyze(args) = cli.command else {
panic!("expected analyze command");
};
assert_eq!(args.first_canonical_sessions, None);
assert_eq!(
args.publication_formats,
[AnalysisFormatArg::Json, AnalysisFormatArg::Markdown]
);
}
#[test]
fn analyze_help_does_not_claim_the_search_default_limit() {
let help = Cli::try_parse_from(["aise", "analyze", "--help"])
.unwrap_err()
.to_string();
assert!(help.contains("canonical session-ID order"));
assert!(help.contains("Omit it to analyze every eligible session"));
assert!(!help.contains("--limit"));
assert!(!help.contains("page-size"));
assert!(!help.contains("use `[search].default_limit`"));
}
#[test]
fn analyze_rejects_zero_canonical_prefix_with_the_all_sessions_replacement() {
let error = Cli::try_parse_from([
"aise",
"analyze",
"--first-canonical-sessions",
"0",
"--output",
"/tmp/analysis-bundle",
])
.unwrap_err()
.to_string();
assert!(error.contains("must be a positive integer"), "{error}");
assert!(
error.contains("omit it to analyze every eligible session"),
"{error}"
);
}
#[test]
fn session_query_limit_distinguishes_omitted_unlimited_and_bounded() {
let mut config = Config::default();
config.search.default_limit = 37;
assert_eq!(configured_search_limit(None, &config), 37);
assert_eq!(configured_search_limit(Some(0), &config), 0);
assert_eq!(configured_search_limit(Some(9), &config), 9);
let help = Cli::try_parse_from(["aise", "search", "--help"])
.unwrap_err()
.to_string();
assert!(help.contains("Omit to use `[search].default_limit`; zero means all"));
}
#[test]
fn root_help_names_every_session_source() {
let help = Cli::try_parse_from(["aise", "--help"])
.unwrap_err()
.to_string();
for provider in crate::source::PROVIDERS {
assert!(
help.contains(provider.display_name()),
"root help must name {}: {help}",
provider.display_name()
);
}
assert!(!help.contains("supported agents"));
assert!(!help.contains("all agents"));
assert!(!help.contains("__refresh-index"));
assert!(help.contains("Overrides AI_SESSION_SEARCH_INDEX_REFRESH and config.toml"));
}
#[test]
fn help_never_recommends_removed_root_status_command() {
for args in [
&["aise", "reindex", "--help"][..],
&["aise", "compact", "--help"],
&["aise", "doctor", "--help"],
&["aise", "package", "status", "--help"],
] {
let help = Cli::try_parse_from(args).unwrap_err().to_string();
assert!(!help.contains("`aise status`"), "{help}");
}
}
#[test]
fn provider_filters_use_one_concrete_session_source_term() {
for args in [
vec!["aise", "list", "--help"],
vec!["aise", "planning", "--help"],
vec!["aise", "stats", "--help"],
vec!["aise", "repeats", "--help"],
] {
let help = Cli::try_parse_from(args).unwrap_err().to_string();
assert!(
help.contains("Restrict to one indexed session source"),
"provider help is not concrete: {help}"
);
assert!(!help.contains("Restrict to one harness"), "{help}");
}
let cli = Cli::try_parse_from(["aise", "skills", "corrections", "--help"]).unwrap();
let Commands::Skills(command) = cli.command else {
panic!("expected skills command")
};
let help = command.into_execution().unwrap_err().to_string();
assert!(
help.contains("Restrict to one indexed session source"),
"provider help is not concrete: {help}"
);
assert!(!help.contains("Restrict to one harness"), "{help}");
}
#[test]
fn messages_summary_names_every_subcommand() {
let cli = Cli::command();
let messages = cli
.get_subcommands()
.find(|c| c.get_name() == "messages")
.expect("messages subcommand exists");
let about = messages
.get_about()
.expect("messages command has an about summary")
.to_string();
let subnames: Vec<&str> = messages
.get_subcommands()
.map(|c| c.get_name())
.filter(|name| *name != "help") .collect();
assert!(
subnames.len() >= 4,
"expected at least search/get/timeline/evidence, got {subnames:?}"
);
for name in &subnames {
assert!(
about.contains(name),
"messages summary must name subcommand `{name}`: {about}"
);
}
assert!(
!about.contains("(search|get|timeline)"),
"messages summary regressed to the evidence-omitting list: {about}"
);
}
#[test]
fn messages_search_limit_help_explains_latest_match_window() {
let help = Cli::try_parse_from(["aise", "messages", "search", "--help"])
.unwrap_err()
.to_string();
assert!(
help.contains("--match-window latest"),
"limit help must name the latest-match selector: {help}"
);
assert!(
help.contains("with one session"),
"limit help must state the latest-window session scope: {help}"
);
}
#[test]
fn messages_search_exposes_only_the_final_presentation_and_include_controls() {
let cli = Cli::command();
let search = cli
.get_subcommands()
.find(|command| command.get_name() == "messages")
.unwrap()
.get_subcommands()
.find(|command| command.get_name() == "search")
.unwrap();
let long_names = search
.get_arguments()
.filter_map(clap::Arg::get_long)
.collect::<std::collections::BTreeSet<_>>();
for required in ["detail", "field-view-chars", "match-view-chars", "include"] {
assert!(
long_names.contains(required),
"messages search must expose final --{required} syntax"
);
}
for removed in ["include-refs", "match-evidence-max-chars"] {
assert!(
!long_names.contains(removed),
"messages search must not retain provisional --{removed} syntax"
);
}
}
#[test]
fn config_commands_parse() {
assert_parses(["aise", "config", "file"]);
assert_parses(["aise", "config", "paths"]);
assert_parses(["aise", "config", "example"]);
assert_parses(["aise", "config", "init", "--force"]);
assert_parses(["aise", "config", "show"]);
assert_parses(["aise", "config", "show", "--format", "json"]);
assert_parses(["aise", "config", "origins"]);
assert_parses([
"aise",
"--config",
"/tmp/config.toml",
"--database",
"/tmp/index.db",
"--cache-dir",
"/tmp/cache",
"config",
"paths",
]);
assert!(Cli::try_parse_from(["aise", "--threads", "0", "config", "paths"]).is_err());
assert_parses(["aise", "package", "status"]);
assert_rejects(["aise", "paths"]);
assert_rejects(["aise", "config", "path"]);
assert_rejects(["aise", "config", "explain"]);
assert_rejects(["aise", "installation"]);
assert_rejects(["aise", "package"]);
}
#[test]
fn package_lifecycle_commands_have_distinct_effects() {
assert_parses(["aise", "package", "status"]);
assert_parses(["aise", "package", "status", "--format", "json"]);
assert_parses(["aise", "package", "check"]);
assert_parses(["aise", "package", "check", "--format", "json"]);
assert_parses(["aise", "package", "update"]);
assert_parses(["aise", "package", "update", "--yes"]);
assert_rejects(["aise", "update"]);
assert_rejects(["aise", "package", "status", "--yes"]);
assert_rejects(["aise", "package", "check", "--yes"]);
assert_rejects(["aise", "package", "update", "--check-only"]);
}
#[test]
fn root_options_are_rejected_when_the_selected_command_ignores_them() {
assert_root_options_apply([
"aise",
"package",
"check",
"--config",
"/tmp/config.toml",
"--cache-dir",
"/tmp/cache",
]);
assert_root_options_apply([
"aise",
"--config",
"/tmp/config.toml",
"--database",
"/tmp/index.db",
"--cache-dir",
"/tmp/cache",
"config",
"paths",
]);
assert_root_option_is_irrelevant(
["aise", "--database", "/tmp/index.db", "package", "status"],
"--database",
);
assert_root_option_is_irrelevant(
["aise", "--threads", "2", "config", "paths"],
"--threads",
);
assert_root_option_is_irrelevant(
["aise", "--skip-release-notification", "package", "check"],
"--skip-release-notification",
);
assert_root_option_is_irrelevant(
["aise", "--config", "/tmp/config.toml", "config", "example"],
"--config",
);
assert_root_option_is_irrelevant(
["aise", "--skip-release-notification", "mcp", "serve"],
"--skip-release-notification",
);
}
#[test]
fn root_options_parse_before_or_after_subcommands_and_remain_semantically_checked() {
let root_help = Cli::try_parse_from(["aise", "--help"])
.unwrap_err()
.to_string();
assert!(root_help.contains("--database"));
assert!(root_help.contains("--skip-release-notification"));
assert!(
!root_help.contains("accepted by every command"),
"root options are shared syntactically but command applicability is validated"
);
assert_root_options_apply(["aise", "search", "needle", "--config", "/tmp/config.toml"]);
assert_root_option_is_irrelevant(
["aise", "package", "status", "--database", "/tmp/index.db"],
"--database",
);
}
#[test]
fn integration_commands_use_one_explicit_namespace() {
let install_help = Cli::try_parse_from(["aise", "integrations", "install", "--help"])
.unwrap_err()
.to_string();
assert!(install_help.contains("starts best-effort session index preparation"));
assert!(install_help.contains("run `aise doctor` to check readiness and freshness"));
assert!(install_help.contains("pi, prime-agent"), "{install_help}");
assert!(
install_help.contains("Pi and Prime Agent instead receive their native skill"),
"{install_help}"
);
let cli = Cli::try_parse_from([
"aise",
"integrations",
"install",
"--client",
"antigravity",
"--client",
"opencode",
"--exclude-client",
"opencode",
])
.unwrap();
let Commands::Integrations(IntegrationsCmd::Install(args)) = cli.command else {
panic!("expected integrations install command");
};
assert_eq!(
args.targets.clients,
[
crate::integrations::McpClient::Antigravity,
crate::integrations::McpClient::Opencode,
]
);
assert_eq!(
args.targets.excluded_clients,
[crate::integrations::McpClient::Opencode]
);
assert!(matches!(
Cli::try_parse_from(["aise", "integrations", "install", "--no-mcp"])
.unwrap()
.command,
Commands::Integrations(IntegrationsCmd::Install(args)) if args.no_mcp
));
assert!(matches!(
Cli::try_parse_from(["aise", "integrations", "status", "--no-mcp"])
.unwrap()
.command,
Commands::Integrations(IntegrationsCmd::Status(args)) if args.no_mcp
));
assert!(matches!(
Cli::try_parse_from(["aise", "integrations", "status", "--client", "opencode"])
.unwrap()
.command,
Commands::Integrations(IntegrationsCmd::Status(_))
));
assert!(matches!(
Cli::try_parse_from([
"aise",
"integrations",
"uninstall",
"--client",
"opencode",
"--keep-instructions",
])
.unwrap()
.command,
Commands::Integrations(IntegrationsCmd::Uninstall(_))
));
assert!(matches!(
Cli::try_parse_from(["aise", "integrations", "uninstall", "--keep-mcp"])
.unwrap()
.command,
Commands::Integrations(IntegrationsCmd::Uninstall(args)) if args.keep_mcp
));
assert_rejects(["aise", "integrations", "uninstall", "--no-instructions"]);
assert_rejects(["aise", "install"]);
assert_rejects(["aise", "status"]);
assert_rejects(["aise", "uninstall"]);
assert_rejects(["aise", "mcp", "install", "--client", "antigravity"]);
assert_rejects(["aise", "mcp", "status"]);
assert_rejects(["aise", "mcp", "uninstall"]);
assert_parses(["aise", "integrations", "recover"]);
assert_parses([
"aise",
"integrations",
"recover",
"--transaction-receipt",
"/tmp/integrations.json",
]);
assert_rejects(["aise", "mcp", "recover"]);
assert_parses(["aise", "mcp", "serve"]);
}
#[test]
fn config_init_leaves_every_setting_but_the_state_paths_commented() {
let initialized = initial_config_text();
fn leaves(table: &toml::Table, prefix: &str, into: &mut Vec<String>) {
for (key, value) in table {
match value {
toml::Value::Table(inner) => leaves(inner, &format!("{prefix}{key}."), into),
_ => into.push(format!("{prefix}{key}")),
}
}
}
let mut live = Vec::new();
leaves(&initialized.parse::<toml::Table>().unwrap(), "", &mut live);
live.sort();
assert_eq!(live, ["index.cache_dir", "index.db_path"]);
let parsed: crate::config::Config = toml::from_str(&initialized).unwrap();
assert_eq!(
serde_json::to_value(&parsed).unwrap(),
serde_json::to_value(crate::config::Config::default()).unwrap(),
"the initialized file must resolve to exactly the built-in defaults"
);
assert!(
initialized.contains("\n# preview_body_lines = 34\n"),
"a setting stays in place, one `# ` away from taking effect"
);
}
#[test]
fn config_init_requires_force_and_atomically_replaces_regular_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_config_example(&path, false).unwrap();
let initialized = fs::read_to_string(&path).unwrap();
let parsed: crate::config::Config = toml::from_str(&initialized).unwrap();
assert_eq!(parsed.db_path(), crate::config::Config::default().db_path());
assert_eq!(
parsed.cache_dir(),
crate::config::Config::default().cache_dir()
);
assert!(initialized.contains("\ndb_path = "));
assert!(initialized.contains("\ncache_dir = "));
fs::write(&path, "preserve until publication").unwrap();
assert!(write_config_example(&path, false).is_err());
assert_eq!(
fs::read_to_string(&path).unwrap(),
"preserve until publication"
);
write_config_example(&path, true).unwrap();
let replaced = fs::read_to_string(&path).unwrap();
assert!(replaced.contains("\ndb_path = "));
assert!(replaced.contains("\ncache_dir = "));
}
#[test]
fn show_accepts_bounded_head_tail_and_all_transcript_modes() {
let cli = Cli::try_parse_from(["aise", "show", "abc"]).unwrap();
let Commands::Show(args) = cli.command else {
panic!("expected show command");
};
assert_eq!(args.transcript_lines, None);
assert!(!args.summary);
assert_eq!(args.summary_items, None);
assert_eq!(args.preview_chars, None);
assert_parses(["aise", "show", "abc", "--summary"]);
assert_parses([
"aise",
"show",
"abc",
"--summary",
"--summary-items",
"-12",
"--preview-chars",
"180",
]);
assert_rejects(["aise", "show", "abc", "--summary", "--raw"]);
assert_rejects([
"aise",
"show",
"abc",
"--summary",
"--transcript-lines",
"20",
]);
assert_parses(["aise", "show", "abc", "--transcript-lines", "20"]);
assert_parses(["aise", "show", "abc", "--transcript-lines", "-20"]);
assert_parses(["aise", "show", "abc", "--transcript-lines", "0"]);
}
#[test]
fn doctor_accepts_machine_readable_json_format() {
let cli = Cli::try_parse_from(["aise", "doctor", "--format", "json"]).unwrap();
let Commands::Doctor(args) = cli.command else {
panic!("expected doctor command");
};
assert_eq!(args.format, DoctorFormat::Json);
assert!(Cli::try_parse_from(["aise", "doctor", "--format", "csv"]).is_err());
}
#[test]
fn storage_guidance_is_exact_and_requires_reclaimable_bytes() {
assert!(storage_compaction_guidance(crate::db::StorageAllocation {
total_bytes: 4096,
reclaimable_bytes: 0,
})
.is_none());
assert_eq!(
storage_compaction_guidance(crate::db::StorageAllocation {
total_bytes: 8 * 1024 * 1024,
reclaimable_bytes: 4 * 1024 * 1024,
})
.unwrap(),
"4194304 bytes (4.0 MiB) of 8.0 MiB are reclaimable; run `aise compact` when an exclusive database lock and temporary disk space are available"
);
}
#[test]
fn messages_evidence_accepts_time_profile_include() {
let cli = Cli::try_parse_from([
"aise",
"messages",
"evidence",
"abc",
"--include",
"time-profile",
"--format",
"json",
])
.unwrap();
let Commands::Messages(crate::messages::MessagesCmd::Evidence(args)) = cli.command else {
panic!("expected messages evidence command");
};
assert_eq!(
args.include,
vec![crate::messages::EvidenceInclude::TimeProfile]
);
}
#[test]
fn messages_search_accepts_general_tool_argument_pointer_and_offset() {
let cli = Cli::try_parse_from([
"aise",
"messages",
"search",
"cargo test",
"--field",
"tool-argument",
"--argument-path",
"/cmd",
"--offset",
"2",
])
.unwrap();
let Commands::Messages(crate::messages::MessagesCmd::Search(args)) = cli.command else {
panic!("expected messages search command");
};
assert_eq!(args.field, crate::models::SearchField::ToolArgument);
assert_eq!(args.argument_path.as_deref(), Some("/cmd"));
assert_eq!(args.offset, 2);
}
#[test]
fn messages_commands_accept_signed_lines_per_message() {
let cli = Cli::try_parse_from([
"aise",
"messages",
"search",
"exit status",
"--lines-per-message",
"-3",
])
.unwrap();
let Commands::Messages(crate::messages::MessagesCmd::Search(args)) = cli.command else {
panic!("expected messages search command");
};
assert_eq!(args.lines_per_message, Some(-3));
assert_parses(["aise", "messages", "get", "abc", "--lines-per-message", "5"]);
assert_parses([
"aise",
"messages",
"timeline",
"abc",
"--lines-per-message",
"-1",
]);
let help = Cli::try_parse_from(["aise", "messages", "search", "--help"])
.unwrap_err()
.to_string();
for required in [
"does not change matches, ranking, result count, pagination, context membership, or reference extraction",
"keep many search hits or long tool outputs skimmable without discarding hits",
"0 keeps its complete content",
] {
assert!(help.contains(required), "missing {required:?} in help: {help}");
}
}
#[test]
fn messages_search_accepts_leading_dash_literals() {
let cli = Cli::try_parse_from(["aise", "messages", "search", "-e", "--path"]).unwrap();
let Commands::Messages(crate::messages::MessagesCmd::Search(args)) = cli.command else {
panic!("expected messages search command");
};
assert_eq!(args.query_arg.as_deref(), Some("--path"));
assert_parses(["aise", "messages", "search", "--", "--path"]);
assert_parses([
"aise",
"messages",
"search",
"--query-mode",
"regex",
"--",
"^/[^[:space:]]+",
]);
assert_parses([
"aise",
"migrate",
"config",
"--source-json",
"old/config.json",
"--destination",
"new/config.toml",
"--database-path",
"new/index.db",
"--cache-dir",
"new/cache",
]);
assert_rejects([
"aise",
"migrate",
"config",
"--source-json",
"old/config.json",
"--destination",
"new/config.toml",
"--database-path",
"new/index.db",
"--cache-dir",
"new/cache",
"--replace",
]);
assert_parses([
"aise",
"messages",
"search",
"--query-mode",
"fuzzy",
"--",
"--hyphenated query",
]);
assert_parses(["aise", "repeats", "--regex", "--", "magic|config"]);
assert_parses(["aise", "search", "--limit", "1", "--", "--path"]);
}
#[test]
fn stale_message_scope_aliases_name_the_canonical_replacements() {
let project = parse_cli_from([
"aise",
"messages",
"search",
"--project",
"/tmp/project",
"workflow",
])
.unwrap_err()
.to_string();
assert!(project.contains("--workspace-path"), "{project}");
assert!(
!project.contains("similar argument exists: '--role'"),
"{project}"
);
let kind = parse_cli_from(["aise", "messages", "search", "workflow", "--type", "user"])
.unwrap_err()
.to_string();
assert!(kind.contains("--role"), "{kind}");
for invocation in [
vec![
"aise",
"--index-refresh",
"existing-only",
"messages",
"search",
"--project",
"/tmp/project",
"workflow",
],
vec![
"aise",
"messages",
"search",
"--project=/tmp/project",
"workflow",
],
] {
let text = parse_cli_from(invocation.clone()).unwrap_err().to_string();
assert!(text.contains("--workspace-path"), "{invocation:?}: {text}");
assert!(!text.contains("--role"), "{invocation:?}: {text}");
}
assert!(
!project.contains("--role"),
"usage line must not name --role: {project}"
);
assert!(
project.contains("Usage: aise messages search [OPTIONS] [QUERY]"),
"the usage line is the subcommand's real usage: {project}"
);
}
#[test]
fn stale_message_mode_flags_name_the_canonical_replacement_and_its_value() {
for (stale, replacement) in [
("--regex", "--query-mode regex"),
("--fuzzy", "--query-mode fuzzy"),
("--explain", "--receipt-level summary"),
] {
let text = parse_cli_from(["aise", "messages", "search", "workflow", stale])
.unwrap_err()
.to_string();
assert!(
text.contains(replacement),
"`{stale}` must name `{replacement}`: {text}"
);
assert!(
text.contains("Usage: aise messages search [OPTIONS] [QUERY]"),
"`{stale}` keeps the subcommand's real usage line: {text}"
);
}
}
#[test]
fn stale_message_flags_answer_with_the_replacement_and_no_pass_as_value_tip() {
for stale in ["--project", "--type", "--regex", "--fuzzy", "--explain"] {
let text = parse_cli_from(["aise", "messages", "search", "workflow", stale])
.unwrap_err()
.to_string();
assert!(
text.contains("a similar argument exists"),
"`{stale}` names its replacement: {text}"
);
assert!(
!text.contains("as a value"),
"`{stale}` must not also offer clap's pass-as-value tip: {text}"
);
}
}
#[test]
fn message_search_has_no_subcommands_so_clearing_the_suggestion_slot_drops_only_the_value_tip()
{
let mut root = Cli::command();
root.build();
let search = root
.find_subcommand_mut("messages")
.and_then(|messages| messages.find_subcommand_mut("search"))
.expect("messages search exists");
let children: Vec<_> = search
.get_subcommands()
.map(clap::Command::get_name)
.collect();
assert!(
children.is_empty(),
"clearing the suggestion slot would now delete clap's `'<subcommand> <flag>' exists` \
tip: {children:?}"
);
}
#[test]
fn stale_message_mode_flags_remain_rejected() {
for stale in ["--regex", "--fuzzy", "--explain"] {
assert!(
parse_cli_from(["aise", "messages", "search", "workflow", stale]).is_err(),
"`{stale}` stays rejected"
);
}
}
#[test]
fn message_role_filter_has_one_canonical_cli_name() {
assert_parses(["aise", "messages", "search", "query", "--role", "user"]);
assert_parses(["aise", "messages", "get", "session", "--role", "user"]);
assert!(
Cli::try_parse_from(["aise", "messages", "search", "query", "--type", "user"]).is_err()
);
assert!(
Cli::try_parse_from(["aise", "messages", "get", "session", "--type", "user"]).is_err()
);
}
#[test]
fn top_level_search_help_points_to_message_search_modes() {
let mut cmd = Cli::command();
let search = cmd
.find_subcommand_mut("search")
.expect("search subcommand");
let mut help = Vec::new();
search.write_long_help(&mut help).unwrap();
let help = String::from_utf8(help).unwrap();
assert!(help.contains("--query-mode regex|fuzzy"), "{help}");
assert!(
help.contains(
"Session-level keywords, phrase, code snippet, path fragment, or title text"
),
"{help}"
);
for provider in crate::source::PROVIDERS {
assert!(
help.contains(provider.as_str()),
"missing {provider} in {help}"
);
}
}
#[test]
fn session_help_defines_span_overlap_without_redefining_event_dates() {
let mut cli = Cli::command();
for name in ["list", "search"] {
let mut help = Vec::new();
cli.find_subcommand_mut(name)
.unwrap_or_else(|| panic!("{name} subcommand"))
.write_long_help(&mut help)
.unwrap();
let help = String::from_utf8(help).unwrap();
for required in [
"known indexed session span",
"created_at through updated_at",
"can contain gaps",
"not continuous runtime",
"Inclusive lower bound of the query period",
"Inclusive upper bound of the query period",
] {
assert!(
help.contains(required),
"{name} help omits {required}: {help}"
);
}
}
}
#[test]
fn vocab_help_answers_what_it_is_for_before_naming_the_view_it_reads() {
let mut cli = Cli::command();
let mut help = Vec::new();
cli.find_subcommand_mut("vocab")
.expect("vocab subcommand")
.write_long_help(&mut help)
.unwrap();
let help = String::from_utf8(help).unwrap();
for required in [
"--prefix",
"messages containing",
"occurrences",
"detail=none",
"aise messages search",
"aise stats",
] {
assert!(
help.contains(required),
"vocab help omits {required}: {help}"
);
}
}
const COMMAND_SUMMARY_CHARS: usize = 110;
#[test]
fn every_command_summary_fits_the_list_and_detail_goes_to_long_help() {
let cli = Cli::command();
for command in cli.get_subcommands() {
let name = command.get_name();
if command.is_hide_set() {
continue;
}
let about = command
.get_about()
.unwrap_or_else(|| panic!("{name} has no summary"))
.to_string();
assert!(
about.chars().count() <= COMMAND_SUMMARY_CHARS,
"`aise {name}` summarises itself in {} characters, over the {COMMAND_SUMMARY_CHARS} \
the command list holds. Put the first sentence first, then a blank line, and clap \
keeps the rest for `--help`: {about}",
about.chars().count()
);
}
}
#[test]
fn counting_commands_keep_their_caveat_where_the_reader_asks_for_detail() {
use clap::ValueEnum;
let mut cli = Cli::command();
let excluded: Vec<String> = crate::models::MessageKind::value_variants()
.iter()
.filter(|kind| !crate::models::MessageKind::default_search_set().contains(kind))
.filter_map(|kind| Some(kind.to_possible_value()?.get_name().replace('-', " ")))
.collect();
assert!(!excluded.is_empty(), "the default set excludes nothing");
for name in ["stats", "vocab"] {
let mut help = Vec::new();
cli.find_subcommand_mut(name)
.unwrap_or_else(|| panic!("{name} subcommand"))
.write_long_help(&mut help)
.unwrap();
let help = String::from_utf8(help).unwrap();
for class in &excluded {
assert!(
help.contains(class),
"`aise {name} --help` never names {class}, the class its counts and a raw \
`group by role` disagree over: {help}"
);
}
}
}
#[test]
fn db_query_long_help_carries_the_tables_the_notes_and_the_indexed_commands() {
let mut cmd = Cli::command();
let query = cmd
.find_subcommand_mut("db")
.expect("db subcommand")
.find_subcommand_mut("query")
.expect("query subcommand");
let mut help = Vec::new();
query.write_long_help(&mut help).unwrap();
let help = String::from_utf8(help).unwrap();
for command in [
"aise messages search",
"aise files search",
"aise search",
"aise list",
"aise stats",
"aise vocab",
] {
assert!(help.contains(command), "missing {command} in {help}");
}
for table in ["sessions", "messages", "file_edits", "transcripts"] {
assert!(help.contains(table), "missing {table} in {help}");
}
assert!(help.contains("aise db schema --table"), "{help}");
for (table, column, note) in crate::sql_query::SCHEMA_COLUMN_NOTES {
assert!(
help.contains(*note),
"missing the {table}.{column} note in {help}"
);
}
}
#[test]
fn messages_search_query_mode_is_explicit_and_closed() {
assert_parses([
"aise",
"messages",
"search",
"magic values",
"--query-mode",
"fuzzy",
]);
assert_parses([
"aise",
"messages",
"search",
"-e",
"--path",
"--query-mode",
"fuzzy",
]);
assert_parses([
"aise",
"messages",
"search",
"magic.*values",
"--query-mode",
"regex",
]);
assert_parses([
"aise",
"messages",
"search",
"-e",
"--path",
"--query-mode",
"regex",
]);
assert_rejects([
"aise",
"messages",
"search",
"magic values",
"--query-mode",
"fuzzy",
"--rank",
]);
assert_rejects([
"aise",
"messages",
"search",
"magic",
"--query-mode",
"fuzzy",
"values",
]);
assert_parses(["aise", "messages", "search", "--query-mode", "fuzzy"]);
assert_rejects([
"aise",
"messages",
"search",
"magic.*values",
"--query-mode",
"unknown",
]);
}
#[test]
fn messages_search_help_distinguishes_unlimited_exact_from_bounded_fuzzy() {
let mut cmd = Cli::command();
let messages = cmd
.find_subcommand_mut("messages")
.expect("messages subcommand");
let search = messages
.find_subcommand_mut("search")
.expect("messages search subcommand");
let mut help = Vec::new();
search.write_long_help(&mut help).unwrap();
let help = String::from_utf8(help).unwrap();
assert!(help.contains("--all-results"), "{help}");
assert!(help.contains("ranks every eligible match"), "{help}");
assert!(help.contains("Fuzzy offsets apply after"), "{help}");
assert!(help.contains("Fuzzy search is always bounded"), "{help}");
}
#[test]
fn planning_accepts_command_token_regex_filters() {
assert_parses([
"aise",
"planning",
"--commands",
"^/cmd-a",
"--commands",
"^/cmd-b$",
]);
assert_parses(["aise", "planning", "--command", "^/cmd-c$"]);
}
#[test]
fn analytics_commands_accept_exact_session_id_scope() {
let parsed = Cli::try_parse_from([
"aise",
"skills",
"corrections",
"--session-id",
"claude:abc",
])
.unwrap();
let Commands::Skills(command) = parsed.command else {
panic!("expected skills command")
};
assert_eq!(
command.into_execution().unwrap().args.session_id.as_deref(),
Some("claude:abc")
);
let rejected =
Cli::try_parse_from(["aise", "skills", "corrections", "--session", "abc"]).unwrap();
let Commands::Skills(command) = rejected.command else {
panic!("expected skills command")
};
assert!(command.into_execution().is_err());
for command in ["planning", "stats", "repeats"] {
assert_parses(["aise", command, "--session-id", "claude:abc"]);
assert_rejects(["aise", command, "--session", "abc"]);
}
}
#[test]
fn skills_accepts_a_direct_typed_definition_as_json() {
let document = r#"{"categories":[{"name":"accuracy","patterns":["\\bwrong\\b"]}]}"#;
let parsed = Cli::try_parse_from([
"aise",
"skills",
"corrections",
"--definition-json",
document,
])
.unwrap();
let Commands::Skills(command) = parsed.command else {
panic!("expected skills command")
};
let execution = command.into_execution().unwrap();
let definition = serde_json::from_str::<crate::skill_run::MessageClassificationDefinition>(
execution.args.definition_json.as_deref().unwrap(),
)
.unwrap();
assert_eq!(definition.categories[0].name, "accuracy");
assert_eq!(definition.categories[0].patterns, [r"\bwrong\b"]);
}
#[test]
fn repeats_command_parses() {
assert_parses(["aise", "repeats", "--role", "user"]);
assert_parses(["aise", "repeats", "magic values", "--role", "user"]);
assert_parses(["aise", "repeats", "magic|config", "--regex"]);
assert_parses([
"aise",
"repeats",
"--min-matches",
"3",
"--phrase-min-words",
"2",
"--phrase-max-words",
"4",
"--max-groups",
"20",
]);
assert_rejects(["aise", "repeats", "you forgot", "--similarity"]);
assert_rejects(["aise", "repeats", "you forgot", "--groups"]);
assert_rejects(["aise", "repeats", "--context", "-1"]);
assert_rejects(["aise", "similar", "--type", "user"]);
}
#[test]
fn migration_commands_require_explicit_portable_paths() {
assert_parses([
"aise",
"migrate",
"database",
"--source",
"old/index.db",
"--destination",
"new/index.db",
"--receipt",
"new/migration.json",
"--pages-per-step",
"64",
"--pause-ms",
"1",
]);
assert_parses([
"aise",
"migrate",
"verify",
"--receipt",
"new/migration.json",
]);
assert_parses([
"aise",
"migrate",
"recover",
"--receipt",
"new/migration.json",
]);
assert_rejects(["aise", "migrate", "database"]);
}
#[test]
fn export_keeps_direct_single_session_and_adds_filtered_bundle_mode() {
assert_parses(["aise", "export", "claude:abc"]);
assert_parses([
"aise",
"export",
"--provider",
"codex",
"--since",
"7d",
"--limit",
"0",
"--output-dir",
"history",
]);
assert_rejects([
"aise",
"export",
"claude:abc",
"--output",
"one.md",
"--output-dir",
"many",
]);
}
}