use std::ffi::OsString;
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use clap::Parser;
use kbolt_cli::args::{
Cli, CollectionCommand, Command, EvalCommand, EvalImportCommand, IgnoreCommand, LocalCommand,
LocalFeature, ModelsCommand, OutputFormat, ScheduleAddArgs, ScheduleCommand, ScheduleDayArg,
ScheduleRemoveArgs, SetupCommand, SpaceCommand, WatchCommand,
};
use kbolt_cli::{
format_doctor_report_color, format_eval_import_report_color, format_local_report_color,
paint_cli, resolve_no_rerank_for_mode, CliAdapter, CliGetOptions, CliSearchOptions, CliStyle,
};
use kbolt_core::config;
use kbolt_core::doctor;
use kbolt_core::engine::Engine;
use kbolt_core::error::CoreError;
use kbolt_core::eval_import;
use kbolt_core::local;
use kbolt_core::watch;
use kbolt_mcp::stdio;
use kbolt_mcp::McpAdapter;
use kbolt_types::{
ActiveSpace, AddScheduleRequest, ChunkLocator, CollectionInfo, FileEntry, GetChunkRequest,
GetRequest, KboltError, LocalAction, LocalReport, Locator, ModelStatus, MultiGetRequest,
ReadLocator, RemoveScheduleRequest, RemoveScheduleSelector, ScheduleInterval,
ScheduleIntervalUnit, ScheduleScope, ScheduleTrigger, ScheduleWeekday, SearchMode,
SearchRequest, SpaceInfo, UpdateOptions, WatchBackend, WatchHealth, WatchMode,
WatchRuntimeState, WatchStatusResponse,
};
use serde::Serialize;
use serde_json::json;
fn main() {
let argv = std::env::args_os().collect::<Vec<_>>();
let output_format = requested_output_format_from_args(&argv);
if let Err(err) = run(argv.clone()) {
maybe_print_no_subcommand_first_run_hint(&argv, output_format, &err);
emit_error(output_format, &err);
std::process::exit(err.exit_code());
}
}
fn run(argv: Vec<OsString>) -> std::result::Result<(), RunError> {
if parse_internal_watch_run(&argv)? {
watch::runner::run_service()?;
return Ok(());
}
if let Some(schedule_id) = parse_internal_schedule_run(&argv)? {
let engine = Engine::new(None)?;
engine.run_schedule(&schedule_id)?;
return Ok(());
}
let cli = Cli::try_parse_from(argv)?;
ensure_supported_output_format(cli.format, &cli.command)?;
if handle_doctor(&cli)? {
return Ok(());
}
if handle_local_commands(&cli)? {
return Ok(());
}
if handle_eval_import(&cli)? {
return Ok(());
}
if handle_watch_commands(&cli)? {
return Ok(());
}
maybe_print_first_run_inference_hint(&cli.command, cli.format);
if matches!(cli.command, Command::Mcp) {
let engine = Engine::new(None)?;
let adapter = McpAdapter::new(engine);
stdio::run_stdio(&adapter)?;
return Ok(());
}
let output_format = cli.format;
let wants_json = output_format == OutputFormat::Json;
let color = should_colorize_human_output(output_format, stdout_is_tty());
let engine = Engine::new_with_recovery_notice(None, cli_recovery_notice_sink())?;
let mut adapter = CliAdapter::new(engine).with_color(color);
let print_text = |line: &str| emit_text_output(output_format, line);
let print_message = |line: &str| emit_message_output(output_format, line);
let show_activity = should_show_activity_indicator(output_format, stdout_stderr_are_tty());
match cli.command {
Command::Doctor => unreachable!("doctor command handled before engine setup"),
Command::Setup(_) => unreachable!("setup command handled before engine setup"),
Command::Local(_) => unreachable!("local command handled before engine setup"),
Command::Space(space) => match space.command {
SpaceCommand::Add {
name,
description,
strict,
dirs,
} => {
let line = adapter.space_add(&name, description.as_deref(), strict, &dirs)?;
print_message(&line);
}
SpaceCommand::Describe { name, text } => {
let line = adapter.space_describe(&name, &text)?;
print_message(&line);
}
SpaceCommand::Rename { old, new } => {
let line = adapter.space_rename(&old, &new)?;
print_message(&line);
}
SpaceCommand::Remove { name } => {
let line = adapter.space_remove(&name)?;
print_message(&line);
}
SpaceCommand::Current => {
if wants_json {
let active_space = adapter.engine.current_space(cli.space.as_deref())?;
emit_structured_output(&ActiveSpaceJsonResponse { active_space })?;
} else {
let line = adapter.space_current(cli.space.as_deref())?;
print_text(&line);
}
}
SpaceCommand::Default { name } => {
if let Some(space_name) = name.as_deref() {
let line = adapter.space_default(Some(space_name))?;
print_message(&line);
} else if wants_json {
emit_structured_output(&DefaultSpaceJsonResponse {
default_space: adapter.engine.config().default_space.clone(),
})?;
} else {
let line = adapter.space_default(None)?;
print_text(&line);
}
}
SpaceCommand::List => {
if wants_json {
let spaces = adapter.engine.list_spaces()?;
emit_structured_output(&SpacesJsonResponse { spaces })?;
} else {
let line = adapter.space_list()?;
print_text(&line);
}
}
SpaceCommand::Info { name } => {
if wants_json {
let space = adapter.engine.space_info(&name)?;
emit_structured_output(&space)?;
} else {
let line = adapter.space_info(&name)?;
print_text(&line);
}
}
},
Command::Collection(collection) => match collection.command {
CollectionCommand::Add {
path,
name,
description,
extensions,
no_index,
} => {
let run_collection_add = || {
adapter.collection_add(
cli.space.as_deref(),
&path,
name.as_deref(),
description.as_deref(),
extensions.as_deref(),
no_index,
)
};
let line = run_with_activity_indicator(
show_activity && !no_index,
collection_add_activity_label(no_index),
|| run_collection_add().map_err(with_collection_add_model_missing_guidance),
)?;
print_message(&line);
}
CollectionCommand::List => {
if wants_json {
let collections = adapter.engine.list_collections(cli.space.as_deref())?;
emit_structured_output(&CollectionsJsonResponse { collections })?;
} else {
let line = adapter.collection_list(cli.space.as_deref())?;
print_text(&line);
}
}
CollectionCommand::Info { name } => {
if wants_json {
let collection = adapter
.engine
.collection_info(cli.space.as_deref(), &name)?;
emit_structured_output(&collection)?;
} else {
let line = adapter.collection_info(cli.space.as_deref(), &name)?;
print_text(&line);
}
}
CollectionCommand::Describe { name, text } => {
let line = adapter.collection_describe(cli.space.as_deref(), &name, &text)?;
print_message(&line);
}
CollectionCommand::Rename { old, new } => {
let line = adapter.collection_rename(cli.space.as_deref(), &old, &new)?;
print_message(&line);
}
CollectionCommand::Remove { name } => {
let line = adapter.collection_remove(cli.space.as_deref(), &name)?;
print_message(&line);
}
},
Command::Ignore(ignore) => match ignore.command {
IgnoreCommand::Show { collection } => {
if wants_json {
let (space, content) = adapter
.engine
.read_collection_ignore(cli.space.as_deref(), &collection)?;
emit_structured_output(&IgnoreShowJsonResponse {
space,
collection,
patterns: ignore_patterns_to_lines(content),
})?;
} else {
let line = adapter.ignore_show(cli.space.as_deref(), &collection)?;
print_text(&line);
}
}
IgnoreCommand::Add {
collection,
pattern,
} => {
let line = adapter.ignore_add(cli.space.as_deref(), &collection, &pattern)?;
print_message(&line);
}
IgnoreCommand::Remove {
collection,
pattern,
} => {
let line = adapter.ignore_remove(cli.space.as_deref(), &collection, &pattern)?;
print_message(&line);
}
IgnoreCommand::List => {
if wants_json {
let entries = adapter
.engine
.list_collection_ignores(cli.space.as_deref())?;
let ignores = entries
.into_iter()
.map(|entry| IgnoreListEntryJson {
space: entry.space,
collection: entry.collection,
pattern_count: entry.pattern_count,
})
.collect();
emit_structured_output(&IgnoreListJsonResponse { ignores })?;
} else {
let line = adapter.ignore_list(cli.space.as_deref())?;
print_text(&line);
}
}
IgnoreCommand::Edit { collection } => {
let line = adapter.ignore_edit(cli.space.as_deref(), &collection)?;
print_message(&line);
}
},
Command::Models(models) => match models.command {
ModelsCommand::List => {
if wants_json {
let models = adapter.engine.model_status()?;
emit_structured_output(&ModelsJsonResponse { models })?;
} else {
let line = adapter.models_list()?;
print_text(&line);
}
}
},
Command::Eval(eval) => {
ensure_eval_uses_local_scope(cli.space.as_deref())?;
match eval.command {
EvalCommand::Run(args) => {
if wants_json {
let report = adapter.engine.run_eval(args.file.as_deref())?;
emit_structured_output(&report)?;
} else {
let line = adapter.eval_run(args.file.as_deref())?;
print_text(&line);
}
}
EvalCommand::Import(_) => unreachable!("eval import handled before engine setup"),
}
}
Command::Schedule(schedule) => {
ensure_schedule_uses_local_scope(cli.space.as_deref(), &schedule.command)?;
match schedule.command {
ScheduleCommand::Add(add) => {
let recovery = schedule_add_next_command(&add, Some("15m"), None);
let request = schedule_add_request(add)?;
if wants_json {
let response = with_cli_context(
adapter.engine.add_schedule(request),
CliErrorContext::for_schedule_add(recovery),
)?;
emit_structured_output(&response)?;
} else {
let line = with_cli_context(
adapter.schedule_add(request),
CliErrorContext::for_schedule_add(recovery),
)?;
print_message(&line);
}
}
ScheduleCommand::Status => {
if wants_json {
let response = adapter.engine.schedule_status()?;
emit_structured_output(&response)?;
} else {
let line = adapter.schedule_status()?;
print_text(&line);
}
}
ScheduleCommand::Remove(remove) => {
let request = schedule_remove_request(remove)?;
if wants_json {
let response = adapter.engine.remove_schedule(request)?;
emit_structured_output(&response)?;
} else {
let line = adapter.schedule_remove(request)?;
print_message(&line);
}
}
}
}
Command::Watch(_) => unreachable!("watch command handled before engine setup"),
Command::Mcp => unreachable!("mcp command handled before adapter setup"),
Command::Search(search) => {
let mode = if search.deep {
SearchMode::Deep
} else if search.keyword {
SearchMode::Keyword
} else if search.semantic {
SearchMode::Semantic
} else {
SearchMode::Auto
};
let effective_no_rerank =
resolve_no_rerank_for_mode(mode.clone(), search.rerank, search.no_rerank);
if wants_json {
let response = adapter.engine.search(SearchRequest {
query: search.query.clone(),
mode,
space: cli.space.clone(),
collections: search.collections.clone(),
limit: search.limit,
min_score: search.min_score,
no_rerank: effective_no_rerank,
debug: search.debug,
})?;
emit_structured_output(&response)?;
} else {
let run_search = |deep: bool, keyword: bool, semantic: bool| {
adapter.search(CliSearchOptions {
space: cli.space.as_deref(),
query: &search.query,
collections: &search.collections,
limit: search.limit,
min_score: search.min_score,
deep,
keyword,
semantic,
rerank: search.rerank,
no_rerank: search.no_rerank,
debug: search.debug,
})
};
let line = run_search(search.deep, search.keyword, search.semantic)?;
print_text(&line);
}
}
Command::Update(update) => {
let update_options = |no_embed: bool| UpdateOptions {
space: cli.space.clone(),
collections: update.collections.clone(),
no_embed,
dry_run: update.dry_run,
verbose: update.verbose,
};
if wants_json {
match adapter.engine.update(update_options(update.no_embed)) {
Ok(report) => emit_structured_output(&report)?,
Err(err) => return Err(with_update_model_missing_guidance(err).into()),
}
} else {
let run_update = |no_embed: bool| {
adapter.update(
cli.space.as_deref(),
&update.collections,
no_embed,
update.dry_run,
update.verbose,
)
};
let line = run_with_activity_indicator(
show_activity,
update_activity_label(update.no_embed, update.dry_run),
|| run_update(update.no_embed).map_err(with_update_model_missing_guidance),
)?;
print_message(&line);
}
}
Command::Status => {
if wants_json {
let status = adapter.engine.status(cli.space.as_deref())?;
emit_structured_output(&status)?;
} else {
let line = adapter.status(cli.space.as_deref())?;
print_text(&line);
}
}
Command::Ls(ls) => {
if wants_json {
let mut files = adapter.engine.list_files(
cli.space.as_deref(),
&ls.collection,
ls.prefix.as_deref(),
)?;
if !ls.all {
files.retain(|file| file.active);
}
emit_structured_output(&FilesJsonResponse { files })?;
} else {
let line = adapter.ls(
cli.space.as_deref(),
&ls.collection,
ls.prefix.as_deref(),
ls.all,
)?;
print_text(&line);
}
}
Command::Get(get) => {
if wants_json {
if let Some(locator) =
ChunkLocator::parse(&get.identifier).map_err(CoreError::from)?
{
let chunk = with_cli_context(
adapter.engine.get_chunk(GetChunkRequest {
locator,
space: cli.space.clone(),
offset: get.offset,
limit: get.limit,
}),
CliErrorContext::for_space(cli.space.as_deref()),
)?;
emit_structured_output(&chunk)?;
} else {
let document = with_cli_context(
adapter.engine.get_document(GetRequest {
locator: Locator::parse(&get.identifier),
space: cli.space.clone(),
offset: get.offset,
limit: get.limit,
}),
CliErrorContext::for_space(cli.space.as_deref()),
)?;
emit_structured_output(&document)?;
}
} else {
let line = with_cli_context(
adapter.get(CliGetOptions {
space: cli.space.as_deref(),
identifier: &get.identifier,
offset: get.offset,
limit: get.limit,
}),
CliErrorContext::for_space(cli.space.as_deref()),
)?;
print_text(&line);
}
}
Command::MultiGet(get) => {
if wants_json {
let locators = get
.locators
.iter()
.map(|item| ReadLocator::parse(item))
.collect::<kbolt_types::Result<Vec<_>>>()
.map_err(CoreError::from)?;
let response = adapter.engine.multi_get(MultiGetRequest {
locators,
space: cli.space.clone(),
max_files: get.max_files,
max_bytes: get.max_bytes,
})?;
emit_structured_output(&response)?;
} else {
let line = adapter.multi_get(
cli.space.as_deref(),
&get.locators,
get.max_files,
get.max_bytes,
)?;
print_text(&line);
}
}
}
Ok(())
}
fn handle_doctor(cli: &Cli) -> std::result::Result<bool, RunError> {
if !matches!(cli.command, Command::Doctor) {
return Ok(false);
}
let report = doctor::run(None);
if cli.format == OutputFormat::Json {
emit_structured_output(&report)?;
} else {
emit_text_output(
cli.format,
&format_doctor_report_color(
&report,
should_colorize_human_output(cli.format, stdout_is_tty()),
),
);
}
if !report.ready {
std::process::exit(1);
}
Ok(true)
}
fn local_command_succeeds(report: &LocalReport) -> bool {
match report.action {
LocalAction::Stop => true,
_ => report.ready,
}
}
fn handle_local_commands(cli: &Cli) -> std::result::Result<bool, RunError> {
let report = match &cli.command {
Command::Setup(setup) => match setup.command {
SetupCommand::Local => {
if should_show_setup_progress(cli.format, stderr_is_tty()) {
let color = should_colorize_human_output(cli.format, stderr_is_tty());
Some(local::setup_local_with_progress(None, |step| {
eprintln!("{}", format_setup_progress_line(step, color));
})?)
} else {
Some(local::setup_local(None)?)
}
}
},
Command::Local(local_args) => match local_args.command {
LocalCommand::Status => Some(local::local_status(None)?),
LocalCommand::Start => Some(local::start_local(None)?),
LocalCommand::Stop => Some(local::stop_local(None)?),
LocalCommand::Enable { feature } => match feature {
LocalFeature::Deep => Some(local::enable_deep(None)?),
},
},
_ => None,
};
let Some(report) = report else {
return Ok(false);
};
if cli.format == OutputFormat::Json {
emit_structured_output(&report)?;
} else {
emit_text_output(
cli.format,
&format_local_report_color(
&report,
should_colorize_human_output(cli.format, stdout_is_tty()),
),
);
}
if !local_command_succeeds(&report) {
std::process::exit(1);
}
Ok(true)
}
fn should_show_setup_progress(format: OutputFormat, stderr_tty: bool) -> bool {
format == OutputFormat::Cli && stderr_tty
}
fn format_setup_progress_line(step: &str, color: bool) -> String {
format!("{} {step}", paint_cli(color, CliStyle::Chrome, "-"))
}
fn handle_eval_import(cli: &Cli) -> std::result::Result<bool, RunError> {
let Command::Eval(eval) = &cli.command else {
return Ok(false);
};
let EvalCommand::Import(import) = &eval.command else {
return Ok(false);
};
ensure_eval_uses_local_scope(cli.space.as_deref())?;
let report = match &import.dataset {
EvalImportCommand::Beir(args) => eval_import::import_beir(
&args.dataset,
&args.source,
&args.output,
args.collection.as_deref(),
)?,
};
if cli.format == OutputFormat::Json {
emit_structured_output(&report)?;
} else {
emit_text_output(
cli.format,
&format_eval_import_report_color(
&report,
should_colorize_human_output(cli.format, stdout_is_tty()),
),
);
}
Ok(true)
}
fn handle_watch_commands(cli: &Cli) -> std::result::Result<bool, RunError> {
let Command::Watch(args) = &cli.command else {
return Ok(false);
};
ensure_watch_uses_local_scope(cli.space.as_deref())?;
if args.foreground {
if args.command.is_some() {
return Err(CoreError::Domain(KboltError::InvalidInput(
"`kbolt watch --foreground` cannot be combined with watch subcommands".to_string(),
))
.into());
}
if cli.format == OutputFormat::Json {
return Err(CoreError::Domain(KboltError::InvalidInput(
"--format json is not supported for `kbolt watch --foreground`".to_string(),
))
.into());
}
watch::runner::run_foreground()?;
return Ok(true);
}
match args.command.as_ref().unwrap_or(&WatchCommand::Status) {
WatchCommand::Enable => {
let response = watch::service::enable(None)?;
emit_watch_response(
cli.format,
Some("watch enabled"),
&response,
should_colorize_human_output(cli.format, stdout_is_tty()),
)?;
}
WatchCommand::Disable => {
let response = watch::service::disable(None)?;
emit_watch_response(
cli.format,
Some("watch disabled"),
&response,
should_colorize_human_output(cli.format, stdout_is_tty()),
)?;
}
WatchCommand::Status => {
let response = watch::service::status(None)?;
emit_watch_response(
cli.format,
None,
&response,
should_colorize_human_output(cli.format, stdout_is_tty()),
)?;
}
WatchCommand::Logs { lines } => {
let status = watch::service::status(None)?;
let content = watch::service::logs(None, *lines)?;
if cli.format == OutputFormat::Json {
let lines = content.lines().map(ToString::to_string).collect::<Vec<_>>();
emit_structured_output(&json!({
"log_file": status.log_file,
"lines": lines,
}))?;
} else if content.is_empty() {
emit_text_output(cli.format, "no watcher logs found");
} else {
emit_text_output(cli.format, &content);
}
}
}
Ok(true)
}
#[derive(Debug)]
enum RunError {
Clap(clap::Error),
Usage(CliUsageError),
Core(CoreError),
Contextual {
err: CoreError,
context: CliErrorContext,
},
}
#[derive(Debug)]
enum CliUsageError {
ScheduleStatusTopLevelSpace,
ScheduleAddTopLevelSpace { next_command: String },
ScheduleRemoveTopLevelSpace { next_command: String },
EvalTopLevelSpace,
WatchTopLevelSpace,
ScheduleCollectionMissingSpace { next_command: String },
InvalidScheduleInterval { raw: String, next_command: String },
MissingScheduleTrigger { next_command: String },
}
#[derive(Debug, Clone, Default)]
struct CliErrorContext {
space: Option<String>,
schedule_add_next: Option<String>,
}
impl CliErrorContext {
fn for_space(space: Option<&str>) -> Self {
Self {
space: space.map(ToString::to_string),
schedule_add_next: None,
}
}
fn for_schedule_add(next_command: String) -> Self {
Self {
space: None,
schedule_add_next: Some(next_command),
}
}
}
impl From<clap::Error> for RunError {
fn from(value: clap::Error) -> Self {
Self::Clap(value)
}
}
impl From<CoreError> for RunError {
fn from(value: CoreError) -> Self {
Self::Core(value)
}
}
impl std::fmt::Display for RunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Clap(err) => write!(f, "{err}"),
Self::Usage(err) => write!(f, "{err}"),
Self::Core(err) => write!(f, "{err}"),
Self::Contextual { err, .. } => write!(f, "{err}"),
}
}
}
impl std::fmt::Display for CliUsageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ScheduleStatusTopLevelSpace => {
write!(f, "schedule status does not use the top-level --space flag")
}
Self::ScheduleAddTopLevelSpace { .. } => {
write!(f, "schedule add does not use the top-level --space flag")
}
Self::ScheduleRemoveTopLevelSpace { .. } => {
write!(f, "schedule remove does not use the top-level --space flag")
}
Self::EvalTopLevelSpace => write!(
f,
"eval commands do not use the top-level --space flag; set scope inside the eval manifest"
),
Self::WatchTopLevelSpace => write!(
f,
"watch commands keep all configured collections fresh and do not use the top-level --space flag"
),
Self::ScheduleCollectionMissingSpace { .. } => {
write!(f, "schedule collections require --space")
}
Self::InvalidScheduleInterval { raw, .. } => write!(
f,
"invalid schedule interval '{raw}': use <minutes>m or <hours>h"
),
Self::MissingScheduleTrigger { .. } => {
write!(f, "schedule trigger requires --every or --at")
}
}
}
}
impl RunError {
fn exit_code(&self) -> i32 {
match self {
Self::Clap(err) => err.exit_code(),
Self::Usage(_) | Self::Core(_) | Self::Contextual { .. } => 1,
}
}
}
fn with_cli_context<T>(
result: kbolt_core::Result<T>,
context: CliErrorContext,
) -> std::result::Result<T, RunError> {
result.map_err(|err| RunError::Contextual { err, context })
}
#[derive(Debug, Serialize, PartialEq)]
struct SpacesJsonResponse {
spaces: Vec<SpaceInfo>,
}
#[derive(Debug, Serialize, PartialEq)]
struct ActiveSpaceJsonResponse {
active_space: Option<ActiveSpace>,
}
#[derive(Debug, Serialize, PartialEq)]
struct DefaultSpaceJsonResponse {
default_space: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
struct CollectionsJsonResponse {
collections: Vec<CollectionInfo>,
}
#[derive(Debug, Serialize, PartialEq)]
struct FilesJsonResponse {
files: Vec<FileEntry>,
}
#[derive(Debug, Serialize, PartialEq)]
struct IgnoreShowJsonResponse {
space: String,
collection: String,
patterns: Vec<String>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct IgnoreListEntryJson {
space: String,
collection: String,
pattern_count: usize,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct IgnoreListJsonResponse {
ignores: Vec<IgnoreListEntryJson>,
}
#[derive(Debug, Serialize, PartialEq)]
struct ModelsJsonResponse {
models: ModelStatus,
}
const INTERNAL_SCHEDULE_RUN_COMMAND: &str = "__schedule-run";
const INTERNAL_WATCH_RUN_COMMAND: &str = "__watch-run";
fn ensure_schedule_uses_local_scope(
space: Option<&str>,
command: &ScheduleCommand,
) -> std::result::Result<(), RunError> {
let Some(space) = space else {
return Ok(());
};
let err = match command {
ScheduleCommand::Status => CliUsageError::ScheduleStatusTopLevelSpace,
ScheduleCommand::Add(args) => CliUsageError::ScheduleAddTopLevelSpace {
next_command: schedule_add_next_command(args, None, Some(space.trim())),
},
ScheduleCommand::Remove(args) => CliUsageError::ScheduleRemoveTopLevelSpace {
next_command: schedule_remove_next_command(args, Some(space.trim())),
},
};
Err(RunError::Usage(err))
}
fn ensure_eval_uses_local_scope(space: Option<&str>) -> std::result::Result<(), RunError> {
if space.is_none() {
return Ok(());
}
Err(RunError::Usage(CliUsageError::EvalTopLevelSpace))
}
fn ensure_watch_uses_local_scope(space: Option<&str>) -> std::result::Result<(), RunError> {
if space.is_none() {
return Ok(());
}
Err(RunError::Usage(CliUsageError::WatchTopLevelSpace))
}
fn schedule_add_request(
args: ScheduleAddArgs,
) -> std::result::Result<AddScheduleRequest, RunError> {
if args.space.is_none() && !args.collections.is_empty() {
return Err(RunError::Usage(
CliUsageError::ScheduleCollectionMissingSpace {
next_command: schedule_add_next_command_with_space_placeholder(&args, None),
},
));
}
Ok(AddScheduleRequest {
trigger: schedule_trigger(&args)?,
scope: schedule_scope(args.space, args.collections),
})
}
fn schedule_remove_request(
args: ScheduleRemoveArgs,
) -> std::result::Result<RemoveScheduleRequest, RunError> {
if args.space.is_none() && !args.collections.is_empty() {
return Err(RunError::Usage(
CliUsageError::ScheduleCollectionMissingSpace {
next_command: schedule_remove_next_command_with_space_placeholder(&args),
},
));
}
let selector = if args.all {
RemoveScheduleSelector::All
} else if let Some(id) = args.id {
RemoveScheduleSelector::Id { id }
} else {
RemoveScheduleSelector::Scope {
scope: schedule_scope(args.space, args.collections),
}
};
Ok(RemoveScheduleRequest { selector })
}
fn schedule_trigger(args: &ScheduleAddArgs) -> std::result::Result<ScheduleTrigger, RunError> {
if let Some(interval) = args.every.as_deref() {
return Ok(ScheduleTrigger::Every {
interval: parse_schedule_interval(interval).map_err(|_| {
invalid_schedule_interval(
interval,
schedule_add_next_command(args, Some("15m"), None),
)
})?,
});
}
let time = args.at.clone().ok_or_else(|| {
RunError::Usage(CliUsageError::MissingScheduleTrigger {
next_command: schedule_add_next_command(args, Some("15m"), None),
})
})?;
if args.on.is_empty() {
return Ok(ScheduleTrigger::Daily { time });
}
Ok(ScheduleTrigger::Weekly {
weekdays: args.on.iter().copied().map(schedule_weekday).collect(),
time,
})
}
fn schedule_scope(space: Option<String>, collections: Vec<String>) -> ScheduleScope {
match space {
Some(space) if collections.is_empty() => ScheduleScope::Space { space },
Some(space) => ScheduleScope::Collections { space, collections },
None => ScheduleScope::All,
}
}
fn parse_schedule_interval(raw: &str) -> std::result::Result<ScheduleInterval, ()> {
let normalized = raw.trim().to_ascii_lowercase();
if normalized.len() < 2 {
return Err(());
}
let (value, unit) = normalized.split_at(normalized.len() - 1);
let value = value.parse::<u32>().map_err(|_| ())?;
let unit = match unit {
"m" => ScheduleIntervalUnit::Minutes,
"h" => ScheduleIntervalUnit::Hours,
_ => return Err(()),
};
Ok(ScheduleInterval { value, unit })
}
fn invalid_schedule_interval(raw: &str, next_command: String) -> RunError {
RunError::Usage(CliUsageError::InvalidScheduleInterval {
raw: raw.to_string(),
next_command,
})
}
fn schedule_weekday(day: ScheduleDayArg) -> ScheduleWeekday {
match day {
ScheduleDayArg::Mon => ScheduleWeekday::Mon,
ScheduleDayArg::Tue => ScheduleWeekday::Tue,
ScheduleDayArg::Wed => ScheduleWeekday::Wed,
ScheduleDayArg::Thu => ScheduleWeekday::Thu,
ScheduleDayArg::Fri => ScheduleWeekday::Fri,
ScheduleDayArg::Sat => ScheduleWeekday::Sat,
ScheduleDayArg::Sun => ScheduleWeekday::Sun,
}
}
fn schedule_add_next_command(
args: &ScheduleAddArgs,
every_override: Option<&str>,
fallback_space: Option<&str>,
) -> String {
let space_arg = args
.space
.as_deref()
.or(fallback_space)
.map(shell_quote_owned);
schedule_add_next_command_with_space_arg(args, every_override, space_arg)
}
fn schedule_add_next_command_with_space_placeholder(
args: &ScheduleAddArgs,
every_override: Option<&str>,
) -> String {
schedule_add_next_command_with_space_arg(args, every_override, Some("<space>".to_string()))
}
fn schedule_add_next_command_with_space_arg(
args: &ScheduleAddArgs,
every_override: Option<&str>,
space_arg: Option<String>,
) -> String {
let mut parts = vec![
"kbolt".to_string(),
"schedule".to_string(),
"add".to_string(),
];
if let Some(every) = every_override.or(args.every.as_deref()) {
parts.push("--every".to_string());
parts.push(shell_quote_owned(every));
} else if let Some(at) = args.at.as_deref() {
parts.push("--at".to_string());
parts.push(shell_quote_owned(at));
if !args.on.is_empty() {
parts.push("--on".to_string());
parts.push(
args.on
.iter()
.map(|day| schedule_day_arg_name(*day))
.collect::<Vec<_>>()
.join(","),
);
}
} else {
parts.push("--every".to_string());
parts.push("15m".to_string());
}
if let Some(space) = space_arg {
parts.push("--space".to_string());
parts.push(space);
}
for collection in &args.collections {
parts.push("--collection".to_string());
parts.push(shell_quote_owned(collection));
}
parts.join(" ")
}
fn schedule_remove_next_command(args: &ScheduleRemoveArgs, fallback_space: Option<&str>) -> String {
let space_arg = args
.space
.as_deref()
.or(fallback_space)
.map(shell_quote_owned);
schedule_remove_next_command_with_space_arg(args, space_arg)
}
fn schedule_remove_next_command_with_space_placeholder(args: &ScheduleRemoveArgs) -> String {
schedule_remove_next_command_with_space_arg(args, Some("<space>".to_string()))
}
fn schedule_remove_next_command_with_space_arg(
args: &ScheduleRemoveArgs,
space_arg: Option<String>,
) -> String {
let mut parts = vec![
"kbolt".to_string(),
"schedule".to_string(),
"remove".to_string(),
];
if let Some(id) = args.id.as_deref() {
parts.push(shell_quote_owned(id));
} else if args.all {
parts.push("--all".to_string());
} else if let Some(space) = space_arg {
parts.push("--space".to_string());
parts.push(space);
for collection in &args.collections {
parts.push("--collection".to_string());
parts.push(shell_quote_owned(collection));
}
}
parts.join(" ")
}
fn schedule_day_arg_name(day: ScheduleDayArg) -> &'static str {
match day {
ScheduleDayArg::Mon => "mon",
ScheduleDayArg::Tue => "tue",
ScheduleDayArg::Wed => "wed",
ScheduleDayArg::Thu => "thu",
ScheduleDayArg::Fri => "fri",
ScheduleDayArg::Sat => "sat",
ScheduleDayArg::Sun => "sun",
}
}
fn shell_quote_owned(value: &str) -> String {
shell_words::quote(value).into_owned()
}
fn is_model_not_available_error(err: &CoreError) -> bool {
matches!(err, CoreError::Domain(KboltError::ModelNotAvailable { .. }))
}
fn parse_internal_schedule_run(args: &[OsString]) -> std::result::Result<Option<String>, RunError> {
let Some(command) = args.get(1).and_then(|arg| arg.to_str()) else {
return Ok(None);
};
if command != INTERNAL_SCHEDULE_RUN_COMMAND {
return Ok(None);
}
if args.len() != 3 {
return Err(CoreError::Domain(KboltError::InvalidInput(format!(
"internal schedule runner usage: kbolt {INTERNAL_SCHEDULE_RUN_COMMAND} <id>"
)))
.into());
}
let Some(raw_id) = args.get(2).and_then(|arg| arg.to_str()) else {
return Err(CoreError::Domain(KboltError::InvalidInput(
"schedule id must be valid utf-8".to_string(),
))
.into());
};
let schedule_id = raw_id.trim();
if schedule_id.is_empty() {
return Err(CoreError::Domain(KboltError::InvalidInput(
"schedule id must not be empty".to_string(),
))
.into());
}
Ok(Some(schedule_id.to_string()))
}
fn parse_internal_watch_run(args: &[OsString]) -> std::result::Result<bool, RunError> {
let Some(command) = args.get(1).and_then(|arg| arg.to_str()) else {
return Ok(false);
};
if command != INTERNAL_WATCH_RUN_COMMAND {
return Ok(false);
}
if args.len() != 2 {
return Err(CoreError::Domain(KboltError::InvalidInput(format!(
"internal watch runner usage: kbolt {INTERNAL_WATCH_RUN_COMMAND}"
)))
.into());
}
Ok(true)
}
fn requested_output_format_from_args(args: &[OsString]) -> OutputFormat {
let mut args_iter = args.iter().skip(1);
while let Some(arg) = args_iter.next() {
let Some(raw) = arg.to_str() else {
continue;
};
if let Some(value) = raw.strip_prefix("--format=") {
if value.eq_ignore_ascii_case("json") {
return OutputFormat::Json;
}
}
if raw == "--format" || raw == "-f" {
if args_iter
.next()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("json"))
{
return OutputFormat::Json;
}
}
}
OutputFormat::Cli
}
fn ensure_supported_output_format(
format: OutputFormat,
command: &Command,
) -> std::result::Result<(), RunError> {
if format == OutputFormat::Json && matches!(command, Command::Mcp) {
return Err(CoreError::Domain(KboltError::InvalidInput(
"--format json is not supported for the mcp command".to_string(),
))
.into());
}
Ok(())
}
fn emit_text_output(format: OutputFormat, line: &str) {
println!("{}", render_text_output(format, line));
}
fn emit_message_output(format: OutputFormat, line: &str) {
println!("{}", render_message_output(format, line));
}
fn emit_structured_output<T: Serialize>(value: &T) -> std::result::Result<(), RunError> {
println!("{}", render_structured_output(value)?);
Ok(())
}
fn emit_watch_response(
format: OutputFormat,
prefix: Option<&str>,
response: &WatchStatusResponse,
color: bool,
) -> std::result::Result<(), RunError> {
if format == OutputFormat::Json {
emit_structured_output(response)?;
return Ok(());
}
let rendered = format_watch_status_color(response, color);
let output = match prefix {
Some(prefix) if rendered.is_empty() => prefix.to_string(),
Some(prefix) => format!("{prefix}\n{rendered}"),
None => rendered,
};
emit_text_output(format, &output);
Ok(())
}
#[cfg(test)]
fn format_watch_status(response: &WatchStatusResponse) -> String {
format_watch_status_color(response, false)
}
fn format_watch_status_color(response: &WatchStatusResponse, color: bool) -> String {
let mut lines = Vec::new();
let service = &response.service;
let service_line = if service.enabled && service.running {
"watch: running".to_string()
} else if service.enabled {
"watch: enabled, not running".to_string()
} else if service.running {
"watch: running, not enabled".to_string()
} else {
"watch: disabled".to_string()
};
lines.push(service_line);
push_cli_section_color(&mut lines, "service", color);
push_cli_bullet_color(
&mut lines,
format!("backend: {}", watch_backend_label(service.backend)),
color,
);
if let Some(pid) = service.pid {
push_cli_bullet_color(&mut lines, format!("pid: {pid}"), color);
}
if let Some(issue) = service.issue.as_deref() {
push_cli_warning_bullet_color(&mut lines, format!("issue: {issue}"), color);
}
if let Some(runtime) = response.runtime.as_ref() {
push_cli_section_color(&mut lines, "runtime", color);
push_cli_bullet_color(
&mut lines,
format!("mode: {}", watch_mode_label(runtime.mode)),
color,
);
let state = format!("state: {}", watch_state_label(runtime.state));
if runtime.state == WatchRuntimeState::BackingOff {
push_cli_warning_bullet_color(&mut lines, state, color);
} else {
push_cli_bullet_color(&mut lines, state, color);
}
let health = format!("health: {}", watch_health_label(runtime.health));
match runtime.health {
WatchHealth::Ok => push_cli_bullet_color(&mut lines, health, color),
WatchHealth::Warning => push_cli_warning_bullet_color(&mut lines, health, color),
WatchHealth::Error => push_cli_error_bullet_color(&mut lines, health, color),
}
push_cli_section_color(&mut lines, "collections", color);
push_cli_bullet_color(
&mut lines,
format!("{} watched", runtime.watched_collections),
color,
);
push_cli_bullet_color(
&mut lines,
format!("{} dirty", runtime.dirty_collections),
color,
);
if runtime.semantic_pending_collections > 0
|| runtime.semantic_unavailable_collections > 0
|| !runtime.semantic_blocked_spaces.is_empty()
{
push_cli_section_color(&mut lines, "semantic", color);
push_cli_bullet_color(
&mut lines,
format!("{} pending", runtime.semantic_pending_collections),
color,
);
let unavailable = format!("{} unavailable", runtime.semantic_unavailable_collections);
if runtime.semantic_unavailable_collections > 0 {
push_cli_warning_bullet_color(&mut lines, unavailable, color);
} else {
push_cli_bullet_color(&mut lines, unavailable, color);
}
let blocked = format!("{} blocked spaces", runtime.semantic_blocked_spaces.len());
if runtime.semantic_blocked_spaces.is_empty() {
push_cli_bullet_color(&mut lines, blocked, color);
} else {
push_cli_warning_bullet_color(&mut lines, blocked, color);
}
}
let mut has_refresh_section = false;
if let Some(summary) = runtime.last_keyword_refresh.as_ref() {
push_cli_section_color(&mut lines, "last refresh", color);
has_refresh_section = true;
push_cli_bullet_color(
&mut lines,
format!(
"keyword {}/{}: {} changed in {}ms",
summary.space, summary.collection, summary.changed_docs, summary.elapsed_ms
),
color,
);
}
if let Some(summary) = runtime.last_semantic_refresh.as_ref() {
if !has_refresh_section {
push_cli_section_color(&mut lines, "last refresh", color);
}
push_cli_bullet_color(
&mut lines,
format!(
"semantic {}/{}: {} embedded in {}ms",
summary.space, summary.collection, summary.embedded_chunks, summary.elapsed_ms
),
color,
);
}
if !runtime.semantic_blocked_spaces.is_empty() {
push_cli_section_color(&mut lines, "blocked", color);
}
for block in &runtime.semantic_blocked_spaces {
push_cli_warning_bullet_color(
&mut lines,
format!("space {} needs repair; run `{}`", block.space, block.fix),
color,
);
}
if let Some(error) = runtime.last_error.as_deref() {
push_cli_section_color(&mut lines, "errors", color);
push_cli_error_bullet_color(&mut lines, error, color);
}
} else if service.enabled {
push_cli_section_color(&mut lines, "runtime", color);
push_cli_warning_bullet_color(&mut lines, "no state written yet", color);
}
push_cli_section_color(&mut lines, "paths", color);
push_cli_identity_field_color(&mut lines, "logs", response.log_file.display(), color);
push_cli_identity_field_color(&mut lines, "state", response.state_file.display(), color);
lines.join("\n")
}
fn push_cli_section_color(lines: &mut Vec<String>, label: &str, color: bool) {
if !lines.is_empty() {
lines.push(String::new());
}
lines.push(paint_cli(color, CliStyle::Section, format!("{label}:")));
}
fn push_cli_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
lines.push(format!(
"{} {value}",
paint_cli(color, CliStyle::Chrome, "-")
));
}
fn push_cli_warning_bullet_color(
lines: &mut Vec<String>,
value: impl std::fmt::Display,
color: bool,
) {
push_cli_bullet_color(
lines,
paint_cli(color, CliStyle::Warning, value.to_string()),
color,
);
}
fn push_cli_error_bullet_color(
lines: &mut Vec<String>,
value: impl std::fmt::Display,
color: bool,
) {
push_cli_bullet_color(
lines,
paint_cli(color, CliStyle::Error, value.to_string()),
color,
);
}
fn push_cli_identity_field_color(
lines: &mut Vec<String>,
label: &str,
value: impl std::fmt::Display,
color: bool,
) {
push_cli_bullet_color(
lines,
format!(
"{} {}",
paint_cli(color, CliStyle::Label, format!("{label}:")),
paint_cli(color, CliStyle::Identity, value.to_string())
),
color,
);
}
fn watch_backend_label(value: WatchBackend) -> &'static str {
match value {
WatchBackend::Launchd => "launchd",
WatchBackend::SystemdUser => "systemd-user",
WatchBackend::Unsupported => "unsupported",
}
}
fn watch_mode_label(value: WatchMode) -> &'static str {
match value {
WatchMode::Native => "native",
WatchMode::Polling => "polling",
WatchMode::Foreground => "foreground",
WatchMode::Disabled => "disabled",
}
}
fn watch_health_label(value: WatchHealth) -> &'static str {
match value {
WatchHealth::Ok => "ok",
WatchHealth::Warning => "warning",
WatchHealth::Error => "error",
}
}
fn watch_state_label(value: WatchRuntimeState) -> &'static str {
match value {
WatchRuntimeState::Starting => "starting",
WatchRuntimeState::Idle => "idle",
WatchRuntimeState::RefreshingKeyword => "refreshing keyword",
WatchRuntimeState::RefreshingSemantic => "refreshing semantic",
WatchRuntimeState::Checking => "checking",
WatchRuntimeState::BackingOff => "backing off",
WatchRuntimeState::Stopping => "stopping",
}
}
fn emit_error(format: OutputFormat, err: &RunError) {
let color = should_colorize_human_output(format, stderr_is_tty());
let rendered = render_error_output(format, err, color);
if err.exit_code() == 0 {
println!("{rendered}");
} else {
eprintln!("{rendered}");
}
}
fn cli_recovery_notice_sink() -> Option<kbolt_core::RecoveryNoticeSink> {
Some(Arc::new(|line| eprintln!("{line}")))
}
fn run_with_activity_indicator<T, E>(
enabled: bool,
label: &'static str,
action: impl FnOnce() -> std::result::Result<T, E>,
) -> std::result::Result<T, E> {
if !enabled {
return action();
}
let stop = Arc::new(AtomicBool::new(false));
let printed = Arc::new(AtomicBool::new(false));
let stop_signal = Arc::clone(&stop);
let printed_signal = Arc::clone(&printed);
let label = label.to_string();
let handle = thread::spawn(move || {
let started = Instant::now();
while !stop_signal.load(Ordering::Relaxed) {
eprint!(
"\r{}",
render_activity_status_line(&label, started.elapsed())
);
let _ = std::io::stderr().flush();
printed_signal.store(true, Ordering::Relaxed);
thread::sleep(Duration::from_millis(250));
}
});
let result = action();
stop.store(true, Ordering::Relaxed);
let _ = handle.join();
if printed.load(Ordering::Relaxed) {
eprintln!();
}
result
}
fn render_text_output(format: OutputFormat, line: &str) -> String {
match format {
OutputFormat::Cli | OutputFormat::Json => line.to_string(),
}
}
fn render_message_output(format: OutputFormat, line: &str) -> String {
match format {
OutputFormat::Cli => line.to_string(),
OutputFormat::Json => json!({
"ok": true,
"message": line,
})
.to_string(),
}
}
fn render_structured_output<T: Serialize>(value: &T) -> std::result::Result<String, RunError> {
serde_json::to_string(value)
.map_err(CoreError::from)
.map_err(RunError::from)
}
fn render_error_output(format: OutputFormat, err: &RunError, color: bool) -> String {
if let RunError::Clap(clap_err) = err {
if matches!(
clap_err.kind(),
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
| clap::error::ErrorKind::DisplayVersion
) {
return clap_err.to_string();
}
}
match format {
OutputFormat::Cli => render_cli_error_output(err, color),
OutputFormat::Json => json!({
"ok": false,
"error": {
"kind": json_error_kind(err),
"message": err.to_string(),
}
})
.to_string(),
}
}
fn render_cli_error_output(err: &RunError, color: bool) -> String {
if let Some(block) = structured_cli_error(err) {
return render_cli_error_block(&block, color);
}
let message = err.to_string();
if let Some(rest) = message.strip_prefix("error:") {
return format!("{}{}", paint_cli(color, CliStyle::Error, "error:"), rest);
}
format!("{} {message}", paint_cli(color, CliStyle::Error, "error:"))
}
#[derive(Debug, PartialEq, Eq)]
struct CliErrorBlock {
title: String,
fields: Vec<CliErrorField>,
}
#[derive(Debug, PartialEq, Eq)]
struct CliErrorField {
label: &'static str,
value: String,
style: CliStyle,
}
impl CliErrorBlock {
fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
fields: Vec::new(),
}
}
fn field(mut self, label: &'static str, value: impl Into<String>, style: CliStyle) -> Self {
let value = value.into();
if !value.trim().is_empty() {
self.fields.push(CliErrorField {
label,
value,
style,
});
}
self
}
fn reason(self, value: impl Into<String>) -> Self {
self.field("reason", value, CliStyle::Label)
}
fn target(self, value: impl Into<String>) -> Self {
self.field("target", value, CliStyle::Identity)
}
fn next(self, value: impl Into<String>) -> Self {
self.field("next", value, CliStyle::Action)
}
}
fn render_cli_error_block(block: &CliErrorBlock, color: bool) -> String {
let mut lines = vec![format!(
"{} {}",
paint_cli(color, CliStyle::Error, "error:"),
block.title
)];
for field in &block.fields {
lines.push(format!(
"{} {}",
paint_cli(color, CliStyle::Label, format!("{}:", field.label)),
paint_cli(color, field.style, &field.value)
));
}
lines.join("\n")
}
fn core_error_with_context(err: &RunError) -> Option<(&CoreError, Option<&CliErrorContext>)> {
match err {
RunError::Core(err) => Some((err, None)),
RunError::Contextual { err, context } => Some((err, Some(context))),
RunError::Clap(_) | RunError::Usage(_) => None,
}
}
fn structured_cli_error(err: &RunError) -> Option<CliErrorBlock> {
if let RunError::Usage(err) = err {
return Some(structured_cli_usage_error(err));
}
let (CoreError::Domain(err), context) = core_error_with_context(err)? else {
return None;
};
match err {
KboltError::DocumentNotFound { path } => Some(document_not_found_error(path, context)),
KboltError::ChunkNotFound { locator } => Some(chunk_not_found_error(locator, context)),
KboltError::CollectionNotFound { name } => Some(
CliErrorBlock::new("collection not found")
.target(name)
.next("kbolt collection list"),
),
KboltError::SpaceNotFound { name } => Some(
CliErrorBlock::new("space not found")
.target(name)
.next("kbolt space list"),
),
KboltError::NoActiveSpace => Some(
CliErrorBlock::new("no active space")
.reason("choose a space with --space, KBOLT_SPACE, or a default space")
.next("kbolt space list"),
),
KboltError::FileNotFound(path) => Some(
CliErrorBlock::new("file not found")
.target(path.display().to_string())
.next("check the path and run the command again"),
),
KboltError::EvalFileNotFound(path) => Some(
CliErrorBlock::new("eval file not found")
.target(path.display().to_string())
.next("pass --file or create eval.toml"),
),
KboltError::ModelNotAvailable { name } => Some(
CliErrorBlock::new("model is not available")
.target(name)
.reason("the configured provider is not ready")
.next("kbolt models list"),
),
KboltError::ScheduleNotFound { id } => Some(
CliErrorBlock::new("schedule not found")
.target(id)
.next("kbolt schedule status"),
),
KboltError::ScheduleScopeNotFound => Some(
CliErrorBlock::new("schedule not found")
.reason("no schedule matches that scope")
.next("kbolt schedule status"),
),
KboltError::ScheduleScopeAmbiguous { ids } => Some(
CliErrorBlock::new("schedule scope matched multiple schedules")
.target(ids.join(", "))
.reason("remove by schedule id to avoid deleting the wrong schedule")
.next("kbolt schedule status"),
),
KboltError::InvalidScheduleInterval { reason } => Some(
CliErrorBlock::new("invalid schedule interval")
.reason(reason)
.next(
context
.and_then(|context| context.schedule_add_next.as_deref())
.unwrap_or("kbolt schedule add --every 15m"),
),
),
KboltError::SemanticUnavailable { reason } => Some(
CliErrorBlock::new("semantic search is unavailable")
.reason(reason)
.next("kbolt setup local"),
),
KboltError::DeepUnavailable { reason } => Some(
CliErrorBlock::new("deep search is unavailable")
.reason(reason)
.next("kbolt local enable deep"),
),
KboltError::Config(_) => None,
KboltError::InvalidInput(message) => structured_invalid_input_error(message),
_ => None,
}
}
fn structured_cli_usage_error(err: &CliUsageError) -> CliErrorBlock {
match err {
CliUsageError::ScheduleStatusTopLevelSpace => {
CliErrorBlock::new("schedule status does not use top-level --space")
.reason("schedule status reports all schedules")
.next("kbolt schedule status")
}
CliUsageError::ScheduleAddTopLevelSpace { next_command } => {
CliErrorBlock::new("schedule commands do not use top-level --space")
.reason("schedule scope belongs to the schedule command options")
.next(next_command)
}
CliUsageError::ScheduleRemoveTopLevelSpace { next_command } => {
CliErrorBlock::new("schedule commands do not use top-level --space")
.reason("schedule scope belongs to the schedule command options")
.next(next_command)
}
CliUsageError::EvalTopLevelSpace => {
CliErrorBlock::new("eval commands do not use top-level --space")
.reason("eval scope belongs inside the eval manifest")
.next("edit eval.toml, then run kbolt eval run")
}
CliUsageError::WatchTopLevelSpace => {
CliErrorBlock::new("watch does not use top-level --space")
.reason("watch keeps all configured collections fresh")
.next("kbolt watch status")
}
CliUsageError::ScheduleCollectionMissingSpace { next_command } => {
CliErrorBlock::new("schedule collection scope is missing --space")
.reason("collection-scoped schedules need a schedule-level --space")
.next(next_command)
}
CliUsageError::InvalidScheduleInterval { raw, next_command } => {
CliErrorBlock::new("invalid schedule interval")
.target(raw)
.reason("use <minutes>m or <hours>h")
.next(next_command)
}
CliUsageError::MissingScheduleTrigger { next_command } => {
CliErrorBlock::new("schedule trigger is missing")
.reason("choose an interval trigger or a wall-clock trigger")
.next(next_command)
}
}
}
fn document_not_found_error(path: &str, context: Option<&CliErrorContext>) -> CliErrorBlock {
let mut block = CliErrorBlock::new("document not found").target(path);
if let Some((collection, prefix)) = document_locator_collection_prefix(path) {
let mut command = kbolt_command("ls", context.and_then(|context| context.space.as_deref()));
command.push(' ');
command.push_str(&shell_words::quote(collection));
if let Some(prefix) = prefix {
command.push(' ');
command.push_str(&shell_words::quote(prefix));
}
block = block.next(command);
} else {
block = block.next(kbolt_command(
"search <terms>",
context.and_then(|context| context.space.as_deref()),
));
}
block
}
fn chunk_not_found_error(locator: &str, context: Option<&CliErrorContext>) -> CliErrorBlock {
let docid = locator.split_once('@').map_or(locator, |(docid, _)| docid);
let docid = if docid.starts_with('#') {
docid.to_string()
} else {
format!("#{docid}")
};
CliErrorBlock::new("chunk not found")
.target(locator)
.next(format!(
"{} {}",
kbolt_command("get", context.and_then(|context| context.space.as_deref())),
shell_words::quote(&docid)
))
}
fn kbolt_command(command: &str, space: Option<&str>) -> String {
let mut rendered = "kbolt".to_string();
if let Some(space) = space {
rendered.push_str(" --space ");
rendered.push_str(&shell_words::quote(space));
}
rendered.push(' ');
rendered.push_str(command);
rendered
}
fn document_locator_collection_prefix(path: &str) -> Option<(&str, Option<&str>)> {
let (collection, rest) = path.split_once('/')?;
let collection = collection.trim();
if collection.is_empty() {
return None;
}
let prefix = rest.rsplit_once('/').map(|(prefix, _)| prefix.trim());
let prefix = prefix.filter(|prefix| !prefix.is_empty());
Some((collection, prefix))
}
fn structured_invalid_input_error(message: &str) -> Option<CliErrorBlock> {
match message {
"path locator must be '<collection>/<path>'" => Some(
CliErrorBlock::new("invalid document locator")
.reason("path locators must include the collection name")
.next("kbolt get <collection>/<path>"),
),
"path locator must be relative" => Some(
CliErrorBlock::new("invalid document locator")
.reason("path locators must be relative to a collection")
.next("kbolt get <collection>/<path>"),
),
"path locator must not traverse directories" => Some(
CliErrorBlock::new("invalid document locator")
.reason("path locators cannot contain '..'")
.next("kbolt get <collection>/<path>"),
),
"query cannot be empty" => Some(
CliErrorBlock::new("query is empty")
.reason("search needs non-empty terms")
.next("kbolt search <terms>"),
),
_ => None,
}
}
fn render_activity_status_line(label: &str, elapsed: Duration) -> String {
format!("{label}... {}s", elapsed.as_secs())
}
fn should_show_activity_indicator(format: OutputFormat, is_tty: bool) -> bool {
format == OutputFormat::Cli && is_tty
}
fn should_colorize_human_output(format: OutputFormat, is_tty: bool) -> bool {
should_colorize_human_output_with_no_color(
format,
is_tty,
std::env::var_os("NO_COLOR").is_some(),
)
}
fn should_colorize_human_output_with_no_color(
format: OutputFormat,
is_tty: bool,
no_color: bool,
) -> bool {
format == OutputFormat::Cli && is_tty && !no_color
}
fn collection_add_activity_label(no_index: bool) -> &'static str {
if no_index {
"adding collection"
} else {
"adding and indexing collection"
}
}
fn update_activity_label(no_embed: bool, dry_run: bool) -> &'static str {
if dry_run {
"scanning update"
} else if no_embed {
"updating keyword index"
} else {
"updating index"
}
}
fn ignore_patterns_to_lines(content: Option<String>) -> Vec<String> {
content
.into_iter()
.flat_map(|value| value.lines().map(ToString::to_string).collect::<Vec<_>>())
.collect()
}
fn json_error_kind(err: &RunError) -> &'static str {
match err {
RunError::Clap(_) => "invalid_input",
RunError::Usage(_) => "invalid_input",
RunError::Core(err) | RunError::Contextual { err, .. } => json_core_error_kind(err),
}
}
fn json_core_error_kind(err: &CoreError) -> &'static str {
match err {
CoreError::Domain(KboltError::SpaceNotFound { .. })
| CoreError::Domain(KboltError::CollectionNotFound { .. })
| CoreError::Domain(KboltError::DocumentNotFound { .. })
| CoreError::Domain(KboltError::ChunkNotFound { .. })
| CoreError::Domain(KboltError::EvalFileNotFound(_))
| CoreError::Domain(KboltError::ScheduleNotFound { .. })
| CoreError::Domain(KboltError::ScheduleScopeNotFound)
| CoreError::Domain(KboltError::FileNotFound(_)) => "not_found",
CoreError::Domain(KboltError::SpaceAlreadyExists { .. })
| CoreError::Domain(KboltError::CollectionAlreadyExists { .. }) => "already_exists",
CoreError::Domain(KboltError::AmbiguousSpace { .. }) => "ambiguous_space",
CoreError::Domain(KboltError::NoActiveSpace)
| CoreError::Domain(KboltError::InvalidInput(_))
| CoreError::Domain(KboltError::InvalidScheduleInterval { .. })
| CoreError::Domain(KboltError::ScheduleScopeAmbiguous { .. })
| CoreError::Domain(KboltError::SemanticUnavailable { .. })
| CoreError::Domain(KboltError::DeepUnavailable { .. })
| CoreError::Domain(KboltError::SpaceDenseRepairRequired { .. })
| CoreError::Domain(KboltError::InvalidPath(_)) => "invalid_input",
CoreError::Domain(KboltError::ModelNotAvailable { .. }) => "model_not_available",
CoreError::Domain(KboltError::ModelDownload(_)) => "model_download",
CoreError::Domain(KboltError::Inference(_)) => "inference",
CoreError::Domain(KboltError::Config(_)) => "config",
CoreError::Domain(KboltError::Database(_))
| CoreError::Domain(KboltError::Tantivy(_))
| CoreError::Domain(KboltError::USearch(_))
| CoreError::Domain(KboltError::FileDeleted(_))
| CoreError::Domain(KboltError::Internal(_))
| CoreError::Domain(KboltError::Io(_))
| CoreError::Sqlite(_)
| CoreError::TomlDe(_)
| CoreError::TomlSer(_)
| CoreError::Json(_)
| CoreError::Tantivy(_)
| CoreError::Io(_)
| CoreError::Internal(_) => "internal",
}
}
fn maybe_print_first_run_inference_hint(command: &Command, format: OutputFormat) {
let config_file = match config::default_config_file_path() {
Ok(path) => path,
Err(_) => return,
};
if should_show_first_run_inference_hint(
command,
format,
stdin_stdout_are_tty(),
config_file.exists(),
) {
eprintln!(
"hint: run `kbolt setup local` for the default local path, or configure [providers] and [roles.*] in {} manually. keyword search works without provider bindings.",
config_file.display()
);
}
}
fn maybe_print_no_subcommand_first_run_hint(
args: &[OsString],
format: OutputFormat,
err: &RunError,
) {
let config_file = match config::default_config_file_path() {
Ok(path) => path,
Err(_) => return,
};
if should_show_no_subcommand_first_run_hint(
args,
format,
err,
stdin_stdout_are_tty(),
config_file.exists(),
) {
println!("kbolt is not configured yet. Run `kbolt setup local` to get started.");
println!();
}
}
fn should_show_no_subcommand_first_run_hint(
args: &[OsString],
format: OutputFormat,
err: &RunError,
is_tty: bool,
config_exists: bool,
) -> bool {
matches!(
err,
RunError::Clap(clap_err)
if matches!(
clap_err.kind(),
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
)
) && format == OutputFormat::Cli
&& is_tty
&& !config_exists
&& invocation_has_no_subcommand(args)
}
fn invocation_has_no_subcommand(args: &[OsString]) -> bool {
let mut args_iter = args.iter().skip(1);
while let Some(arg) = args_iter.next() {
let Some(raw) = arg.to_str() else {
return false;
};
match raw {
"-s" | "--space" | "-f" | "--format" => {
let Some(next) = args_iter.next() else {
return false;
};
if next.to_str().is_none() {
return false;
}
}
value if value.starts_with("--space=") || value.starts_with("--format=") => {}
"-h" | "--help" | "-V" | "--version" => return false,
_ => return false,
}
}
true
}
fn should_show_first_run_inference_hint(
command: &Command,
format: OutputFormat,
is_tty: bool,
config_exists: bool,
) -> bool {
!matches!(command, Command::Mcp | Command::Watch(_))
&& format == OutputFormat::Cli
&& is_tty
&& !config_exists
}
fn stdin_stdout_are_tty() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
fn stdout_is_tty() -> bool {
use std::io::IsTerminal;
std::io::stdout().is_terminal()
}
fn stderr_is_tty() -> bool {
use std::io::IsTerminal;
std::io::stderr().is_terminal()
}
fn stdout_stderr_are_tty() -> bool {
use std::io::IsTerminal;
std::io::stdout().is_terminal() && std::io::stderr().is_terminal()
}
fn with_update_model_missing_guidance(err: CoreError) -> CoreError {
if is_model_not_available_error(&err) {
return CoreError::Domain(KboltError::InvalidInput(format!(
"{err}. run `kbolt setup local`, configure [roles.embedder] in index.toml, or re-run with `--no-embed`"
)));
}
err
}
fn with_collection_add_model_missing_guidance(err: CoreError) -> CoreError {
if is_model_not_available_error(&err) {
return CoreError::Domain(KboltError::InvalidInput(format!(
"{err}. run `kbolt setup local`, configure [roles.embedder] in index.toml, or re-run with `--no-index`"
)));
}
err
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::time::Duration;
use clap::CommandFactory;
use serde_json::json;
use super::{
collection_add_activity_label, ensure_eval_uses_local_scope,
ensure_schedule_uses_local_scope, ensure_supported_output_format,
ensure_watch_uses_local_scope, format_watch_status, format_watch_status_color,
invocation_has_no_subcommand, is_model_not_available_error, local_command_succeeds,
parse_internal_schedule_run, parse_internal_watch_run, parse_schedule_interval,
render_activity_status_line, render_error_output, render_message_output,
render_structured_output, requested_output_format_from_args, schedule_add_request,
schedule_remove_request, should_colorize_human_output_with_no_color,
should_show_activity_indicator, should_show_first_run_inference_hint,
should_show_no_subcommand_first_run_hint, should_show_setup_progress,
stdout_stderr_are_tty, update_activity_label, with_collection_add_model_missing_guidance,
with_update_model_missing_guidance, CliErrorContext, DefaultSpaceJsonResponse,
IgnoreShowJsonResponse, RunError, INTERNAL_SCHEDULE_RUN_COMMAND,
INTERNAL_WATCH_RUN_COMMAND,
};
use kbolt_cli::args::{
Cli, Command, OutputFormat, ScheduleAddArgs, ScheduleCommand, ScheduleDayArg,
ScheduleRemoveArgs,
};
use kbolt_core::error::CoreError;
use kbolt_types::{
FileError, KboltError, LocalAction, LocalReport, RemoveScheduleSelector, ScheduleInterval,
ScheduleIntervalUnit, ScheduleScope, ScheduleTrigger, ScheduleWeekday, UpdateDecision,
UpdateDecisionKind, UpdateReport, WatchBackend, WatchHealth, WatchMode,
WatchRefreshSummary, WatchRuntimeState, WatchRuntimeStatus, WatchServiceStatus,
WatchSpaceBlock, WatchStatusResponse,
};
use std::path::PathBuf;
#[test]
fn eval_rejects_top_level_space_flag() {
let err = ensure_eval_uses_local_scope(Some("work")).expect_err("space should fail");
assert!(
err.to_string()
.contains("eval commands do not use the top-level --space flag"),
"unexpected error: {err}"
);
ensure_eval_uses_local_scope(None).expect("no top-level scope");
}
#[test]
fn parse_internal_schedule_run_recognizes_hidden_runner_command() {
let parsed = parse_internal_schedule_run(&[
OsString::from("kbolt"),
OsString::from(INTERNAL_SCHEDULE_RUN_COMMAND),
OsString::from("s2"),
])
.expect("parse internal runner");
assert_eq!(parsed.as_deref(), Some("s2"));
}
#[test]
fn parse_internal_schedule_run_ignores_normal_cli_invocations() {
let parsed =
parse_internal_schedule_run(&[OsString::from("kbolt"), OsString::from("status")])
.expect("parse normal cli");
assert_eq!(parsed, None);
}
#[test]
fn parse_internal_schedule_run_rejects_missing_or_empty_ids() {
let missing = parse_internal_schedule_run(&[
OsString::from("kbolt"),
OsString::from(INTERNAL_SCHEDULE_RUN_COMMAND),
])
.expect_err("missing id should fail");
assert!(missing
.to_string()
.contains("internal schedule runner usage"));
let empty = parse_internal_schedule_run(&[
OsString::from("kbolt"),
OsString::from(INTERNAL_SCHEDULE_RUN_COMMAND),
OsString::from(" "),
])
.expect_err("empty id should fail");
assert!(empty.to_string().contains("schedule id must not be empty"));
}
#[test]
fn parse_internal_watch_run_recognizes_hidden_runner_command() {
let parsed = parse_internal_watch_run(&[
OsString::from("kbolt"),
OsString::from(INTERNAL_WATCH_RUN_COMMAND),
])
.expect("parse internal watch runner");
assert!(parsed);
}
#[test]
fn parse_internal_watch_run_ignores_normal_cli_invocations() {
let parsed = parse_internal_watch_run(&[OsString::from("kbolt"), OsString::from("watch")])
.expect("parse normal cli");
assert!(!parsed);
}
#[test]
fn parse_internal_watch_run_rejects_extra_args() {
let err = parse_internal_watch_run(&[
OsString::from("kbolt"),
OsString::from(INTERNAL_WATCH_RUN_COMMAND),
OsString::from("extra"),
])
.expect_err("extra args should fail");
assert!(err.to_string().contains("internal watch runner usage"));
}
#[test]
fn ensure_watch_uses_local_scope_rejects_top_level_space() {
let err =
ensure_watch_uses_local_scope(Some("work")).expect_err("top-level space should fail");
assert!(
err.to_string()
.contains("watch commands keep all configured collections fresh"),
"unexpected error: {err}"
);
ensure_watch_uses_local_scope(None).expect("no top-level scope");
}
#[test]
fn format_watch_status_renders_disabled_state() {
let response = WatchStatusResponse {
service: WatchServiceStatus {
enabled: false,
running: false,
backend: WatchBackend::Launchd,
pid: None,
issue: None,
},
runtime: None,
log_file: PathBuf::from("/tmp/kbolt/watch.log"),
state_file: PathBuf::from("/tmp/kbolt/state.json"),
};
let rendered = format_watch_status(&response);
assert!(rendered.contains("watch: disabled"));
assert!(rendered.contains("service:\n- backend: launchd"));
assert!(rendered.contains("paths:\n- logs: /tmp/kbolt/watch.log"));
assert!(rendered.contains("- state: /tmp/kbolt/state.json"));
}
#[test]
fn color_watch_status_uses_shared_cli_palette() {
let response = WatchStatusResponse {
service: WatchServiceStatus {
enabled: false,
running: false,
backend: WatchBackend::Launchd,
pid: None,
issue: None,
},
runtime: None,
log_file: PathBuf::from("/tmp/kbolt/watch.log"),
state_file: PathBuf::from("/tmp/kbolt/state.json"),
};
let rendered = format_watch_status_color(&response, true);
assert!(rendered.contains("\x1b[1;36mservice:\x1b[0m"));
assert!(rendered.contains("\x1b[90m-\x1b[0m backend: launchd"));
assert!(rendered.contains("\x1b[1;36mpaths:\x1b[0m"));
assert!(rendered.contains("logs: \x1b[34m/tmp/kbolt/watch.log\x1b[0m"));
}
#[test]
fn format_watch_status_renders_runtime_summary() {
let response = WatchStatusResponse {
service: WatchServiceStatus {
enabled: true,
running: true,
backend: WatchBackend::SystemdUser,
pid: Some(123),
issue: None,
},
runtime: Some(WatchRuntimeStatus {
mode: WatchMode::Native,
health: WatchHealth::Ok,
state: WatchRuntimeState::Idle,
pid: 123,
started_at: "2026-04-25T00:00:00Z".to_string(),
updated_at: "2026-04-25T00:00:01Z".to_string(),
watched_collections: 2,
dirty_collections: 1,
semantic_pending_collections: 1,
semantic_unavailable_collections: 0,
semantic_blocked_spaces: Vec::new(),
collections: Vec::new(),
last_keyword_refresh: None,
last_semantic_refresh: None,
last_safety_scan: None,
last_catalog_refresh: None,
last_error: None,
}),
log_file: PathBuf::from("/tmp/kbolt/watch.log"),
state_file: PathBuf::from("/tmp/kbolt/state.json"),
};
let rendered = format_watch_status(&response);
assert!(rendered.contains("watch: running"));
assert!(rendered.contains("service:\n- backend: systemd-user\n- pid: 123"));
assert!(rendered.contains("runtime:\n- mode: native\n- state: idle\n- health: ok"));
assert!(rendered.contains("collections:\n- 2 watched\n- 1 dirty"));
assert!(rendered.contains("semantic:\n- 1 pending\n- 0 unavailable\n- 0 blocked spaces"));
let colored = format_watch_status_color(&response, true);
assert!(!colored.contains("\x1b[33m0 unavailable\x1b[0m"));
assert!(!colored.contains("\x1b[33m0 blocked spaces\x1b[0m"));
}
#[test]
fn format_watch_status_renders_enabled_service_without_runtime() {
let response = WatchStatusResponse {
service: WatchServiceStatus {
enabled: true,
running: false,
backend: WatchBackend::Launchd,
pid: None,
issue: Some("service is loaded but inactive".to_string()),
},
runtime: None,
log_file: PathBuf::from("/tmp/kbolt/watch.log"),
state_file: PathBuf::from("/tmp/kbolt/state.json"),
};
let rendered = format_watch_status(&response);
assert!(rendered.contains("watch: enabled, not running"));
assert!(rendered.contains("service:\n- backend: launchd"));
assert!(rendered.contains("- issue: service is loaded but inactive"));
assert!(rendered.contains("runtime:\n- no state written yet"));
assert!(rendered.contains("paths:\n- logs: /tmp/kbolt/watch.log"));
}
#[test]
fn format_watch_status_renders_refreshes_blockers_and_errors() {
let response = WatchStatusResponse {
service: WatchServiceStatus {
enabled: true,
running: true,
backend: WatchBackend::SystemdUser,
pid: Some(123),
issue: Some("restart recommended".to_string()),
},
runtime: Some(WatchRuntimeStatus {
mode: WatchMode::Polling,
health: WatchHealth::Warning,
state: WatchRuntimeState::BackingOff,
pid: 123,
started_at: "2026-04-25T00:00:00Z".to_string(),
updated_at: "2026-04-25T00:00:01Z".to_string(),
watched_collections: 2,
dirty_collections: 1,
semantic_pending_collections: 2,
semantic_unavailable_collections: 1,
semantic_blocked_spaces: vec![WatchSpaceBlock {
space: "work".to_string(),
reason: "generation drift".to_string(),
fix: "kbolt update --space work".to_string(),
set_at: "2026-04-25T00:00:01Z".to_string(),
backoff_until: "2026-04-25T00:05:01Z".to_string(),
}],
collections: Vec::new(),
last_keyword_refresh: Some(WatchRefreshSummary {
space: "work".to_string(),
collection: "notes".to_string(),
started_at: "2026-04-25T00:00:01Z".to_string(),
finished_at: "2026-04-25T00:00:02Z".to_string(),
elapsed_ms: 41,
scanned_docs: 5,
changed_docs: 2,
embedded_chunks: 0,
}),
last_semantic_refresh: Some(WatchRefreshSummary {
space: "work".to_string(),
collection: "notes".to_string(),
started_at: "2026-04-25T00:00:02Z".to_string(),
finished_at: "2026-04-25T00:00:03Z".to_string(),
elapsed_ms: 87,
scanned_docs: 0,
changed_docs: 0,
embedded_chunks: 12,
}),
last_safety_scan: None,
last_catalog_refresh: None,
last_error: Some("semantic refresh failed".to_string()),
}),
log_file: PathBuf::from("/tmp/kbolt/watch.log"),
state_file: PathBuf::from("/tmp/kbolt/state.json"),
};
let rendered = format_watch_status(&response);
assert!(rendered.contains("service:\n- backend: systemd-user\n- pid: 123"));
assert!(rendered.contains("- issue: restart recommended"));
assert!(
rendered.contains("runtime:\n- mode: polling\n- state: backing off\n- health: warning")
);
assert!(rendered.contains("semantic:\n- 2 pending\n- 1 unavailable\n- 1 blocked spaces"));
assert!(rendered.contains("last refresh:\n- keyword work/notes: 2 changed in 41ms"));
assert!(rendered.contains("- semantic work/notes: 12 embedded in 87ms"));
assert!(rendered
.contains("blocked:\n- space work needs repair; run `kbolt update --space work`"));
assert!(rendered.contains("errors:\n- semantic refresh failed"));
let colored = format_watch_status_color(&response, true);
assert!(colored.contains("\x1b[33missue: restart recommended\x1b[0m"));
assert!(colored.contains("\x1b[33mstate: backing off\x1b[0m"));
assert!(colored.contains("\x1b[33mhealth: warning\x1b[0m"));
assert!(colored.contains("\x1b[33m1 unavailable\x1b[0m"));
assert!(colored.contains("\x1b[33m1 blocked spaces\x1b[0m"));
assert!(colored
.contains("\x1b[33mspace work needs repair; run `kbolt update --space work`\x1b[0m"));
assert!(colored.contains("\x1b[31msemantic refresh failed\x1b[0m"));
}
#[test]
fn schedule_add_request_builds_weekly_collection_scope() {
let request = schedule_add_request(ScheduleAddArgs {
every: None,
at: Some("3pm".to_string()),
on: vec![ScheduleDayArg::Mon, ScheduleDayArg::Fri],
space: Some("work".to_string()),
collections: vec!["api".to_string(), "docs".to_string()],
})
.expect("build schedule request");
assert_eq!(
request.trigger,
ScheduleTrigger::Weekly {
weekdays: vec![ScheduleWeekday::Mon, ScheduleWeekday::Fri],
time: "3pm".to_string(),
}
);
assert_eq!(
request.scope,
ScheduleScope::Collections {
space: "work".to_string(),
collections: vec!["api".to_string(), "docs".to_string()],
}
);
}
#[test]
fn schedule_remove_request_prefers_explicit_selectors() {
let by_id = schedule_remove_request(ScheduleRemoveArgs {
id: Some("s2".to_string()),
all: false,
space: None,
collections: vec![],
})
.expect("build id removal");
assert_eq!(
by_id.selector,
RemoveScheduleSelector::Id {
id: "s2".to_string()
}
);
let by_scope = schedule_remove_request(ScheduleRemoveArgs {
id: None,
all: false,
space: Some("work".to_string()),
collections: vec!["api".to_string()],
})
.expect("build scoped removal");
assert_eq!(
by_scope.selector,
RemoveScheduleSelector::Scope {
scope: ScheduleScope::Collections {
space: "work".to_string(),
collections: vec!["api".to_string()],
}
}
);
}
#[test]
fn parse_schedule_interval_accepts_minutes_and_hours() {
assert_eq!(
parse_schedule_interval("30m").expect("parse minutes"),
ScheduleInterval {
value: 30,
unit: ScheduleIntervalUnit::Minutes,
}
);
assert_eq!(
parse_schedule_interval("2h").expect("parse hours"),
ScheduleInterval {
value: 2,
unit: ScheduleIntervalUnit::Hours,
}
);
parse_schedule_interval("7d").expect_err("reject invalid interval unit");
}
#[test]
fn ensure_schedule_uses_local_scope_rejects_top_level_space() {
let err = ensure_schedule_uses_local_scope(Some("work"), &ScheduleCommand::Status)
.expect_err("top-level space should fail");
assert!(
err.to_string()
.contains("schedule status does not use the top-level --space flag"),
"unexpected error: {err}"
);
ensure_schedule_uses_local_scope(None, &ScheduleCommand::Status)
.expect("no top-level scope");
}
#[test]
fn requested_output_format_parses_json_flags() {
let separate = requested_output_format_from_args(&[
OsString::from("kbolt"),
OsString::from("--format"),
OsString::from("json"),
OsString::from("status"),
]);
assert_eq!(separate, OutputFormat::Json);
let inline = requested_output_format_from_args(&[
OsString::from("kbolt"),
OsString::from("--format=json"),
OsString::from("status"),
]);
assert_eq!(inline, OutputFormat::Json);
let cli =
requested_output_format_from_args(&[OsString::from("kbolt"), OsString::from("status")]);
assert_eq!(cli, OutputFormat::Cli);
}
#[test]
fn render_message_output_wraps_json_success_envelope() {
let rendered = render_message_output(OutputFormat::Json, "space added: work");
let value: serde_json::Value =
serde_json::from_str(&rendered).expect("rendered output should be valid json");
assert_eq!(
value,
json!({
"ok": true,
"message": "space added: work",
})
);
}
#[test]
fn render_error_output_wraps_json_error_envelope() {
let err = RunError::Core(CoreError::Domain(KboltError::InvalidInput(
"bad input".to_string(),
)));
let rendered = render_error_output(OutputFormat::Json, &err, false);
let value: serde_json::Value =
serde_json::from_str(&rendered).expect("rendered output should be valid json");
assert_eq!(
value,
json!({
"ok": false,
"error": {
"kind": "invalid_input",
"message": "invalid input: bad input",
}
})
);
}
#[test]
fn render_error_output_preserves_clap_help_text() {
let err =
RunError::Clap(Cli::command().error(clap::error::ErrorKind::DisplayHelp, "help text"));
let rendered = render_error_output(OutputFormat::Json, &err, false);
assert!(
rendered.contains("help text"),
"unexpected output: {rendered}"
);
assert!(
!rendered.trim_start().starts_with('{'),
"help output should not be wrapped as json: {rendered}"
);
}
#[test]
fn render_error_output_preserves_missing_subcommand_help_text() {
let err = RunError::Clap(Cli::command().error(
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
"",
));
let rendered = render_error_output(OutputFormat::Cli, &err, true);
assert!(
rendered.contains("Usage:"),
"expected clap help output: {rendered}"
);
assert!(
!rendered.starts_with("\x1b[31merror:\x1b[0m"),
"missing-subcommand help should not be rendered as a fatal error: {rendered}"
);
}
#[test]
fn render_error_output_labels_cli_errors() {
let err = RunError::Core(CoreError::Domain(KboltError::InvalidInput(
"bad input".to_string(),
)));
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(rendered, "error: invalid input: bad input");
}
#[test]
fn render_error_output_colors_cli_error_label() {
let err = RunError::Core(CoreError::Domain(KboltError::InvalidInput(
"bad input".to_string(),
)));
let rendered = render_error_output(OutputFormat::Cli, &err, true);
assert_eq!(rendered, "\x1b[31merror:\x1b[0m invalid input: bad input");
}
#[test]
fn render_error_output_structures_semantic_recovery() {
let err = RunError::Core(CoreError::Domain(KboltError::SemanticUnavailable {
reason: "no embedder role is configured".to_string(),
}));
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: semantic search is unavailable\nreason: no embedder role is configured\nnext: kbolt setup local"
);
}
#[test]
fn render_error_output_structures_missing_document_recovery() {
let err = RunError::Core(CoreError::Domain(KboltError::DocumentNotFound {
path: "docs/guides/missing.md".to_string(),
}));
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: document not found\ntarget: docs/guides/missing.md\nnext: kbolt ls docs guides"
);
}
#[test]
fn render_error_output_preserves_scope_in_missing_document_recovery() {
let err = RunError::Contextual {
err: CoreError::Domain(KboltError::DocumentNotFound {
path: "docs/guides/missing.md".to_string(),
}),
context: CliErrorContext::for_space(Some("uiux")),
};
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: document not found\ntarget: docs/guides/missing.md\nnext: kbolt --space uiux ls docs guides"
);
}
#[test]
fn render_error_output_structures_missing_chunk_recovery_without_double_hash() {
let err = RunError::Contextual {
err: CoreError::Domain(KboltError::ChunkNotFound {
locator: "#cf51ca@999".to_string(),
}),
context: CliErrorContext::for_space(Some("uiux")),
};
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: chunk not found\ntarget: #cf51ca@999\nnext: kbolt --space uiux get '#cf51ca'"
);
}
#[test]
fn render_error_output_structures_schedule_scope_recovery() {
let err = ensure_schedule_uses_local_scope(Some("uiux"), &ScheduleCommand::Status)
.expect_err("top-level scope should fail");
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: schedule status does not use top-level --space\nreason: schedule status reports all schedules\nnext: kbolt schedule status"
);
}
#[test]
fn render_error_output_structures_schedule_add_scope_recovery() {
let command = ScheduleCommand::Add(ScheduleAddArgs {
every: None,
at: Some("3pm".to_string()),
on: vec![ScheduleDayArg::Mon, ScheduleDayArg::Fri],
space: None,
collections: vec!["docs".to_string()],
});
let err = ensure_schedule_uses_local_scope(Some("uiux"), &command)
.expect_err("top-level scope should fail");
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: schedule commands do not use top-level --space\nreason: schedule scope belongs to the schedule command options\nnext: kbolt schedule add --at 3pm --on mon,fri --space uiux --collection docs"
);
}
#[test]
fn render_error_output_structures_schedule_collection_without_space() {
let err = schedule_add_request(ScheduleAddArgs {
every: None,
at: Some("3pm".to_string()),
on: Vec::new(),
space: None,
collections: vec!["docs".to_string()],
})
.expect_err("collection scope without schedule space should fail");
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: schedule collection scope is missing --space\nreason: collection-scoped schedules need a schedule-level --space\nnext: kbolt schedule add --at 3pm --space <space> --collection docs"
);
}
#[test]
fn render_error_output_preserves_schedule_remove_id_recovery() {
let command = ScheduleCommand::Remove(ScheduleRemoveArgs {
id: Some("s1".to_string()),
all: false,
space: None,
collections: Vec::new(),
});
let err = ensure_schedule_uses_local_scope(Some("uiux"), &command)
.expect_err("top-level scope should fail");
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: schedule commands do not use top-level --space\nreason: schedule scope belongs to the schedule command options\nnext: kbolt schedule remove s1"
);
}
#[test]
fn render_error_output_preserves_schedule_remove_collection_recovery() {
let command = ScheduleCommand::Remove(ScheduleRemoveArgs {
id: None,
all: false,
space: None,
collections: vec!["docs".to_string()],
});
let err = ensure_schedule_uses_local_scope(Some("uiux"), &command)
.expect_err("top-level scope should fail");
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: schedule commands do not use top-level --space\nreason: schedule scope belongs to the schedule command options\nnext: kbolt schedule remove --space uiux --collection docs"
);
}
#[test]
fn render_error_output_structures_invalid_schedule_interval() {
let err = schedule_add_request(ScheduleAddArgs {
every: Some("7d".to_string()),
at: None,
on: Vec::new(),
space: Some("uiux".to_string()),
collections: Vec::new(),
})
.expect_err("invalid interval should fail");
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: invalid schedule interval\ntarget: 7d\nreason: use <minutes>m or <hours>h\nnext: kbolt schedule add --every 15m --space uiux"
);
}
#[test]
fn render_error_output_preserves_schedule_scope_for_core_interval_error() {
let err = RunError::Contextual {
err: CoreError::Domain(KboltError::InvalidScheduleInterval {
reason: "must be at least 5 minutes".to_string(),
}),
context: CliErrorContext::for_schedule_add(
"kbolt schedule add --every 15m --space uiux".to_string(),
),
};
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert_eq!(
rendered,
"error: invalid schedule interval\nreason: must be at least 5 minutes\nnext: kbolt schedule add --every 15m --space uiux"
);
}
#[test]
fn render_error_output_marks_schedule_not_found_as_json_not_found() {
let err = RunError::Core(CoreError::Domain(KboltError::ScheduleNotFound {
id: "s1".to_string(),
}));
let rendered = render_error_output(OutputFormat::Json, &err, false);
let value: serde_json::Value =
serde_json::from_str(&rendered).expect("rendered output should be valid json");
assert_eq!(value["error"]["kind"], "not_found");
}
#[test]
fn render_error_output_colors_structured_recovery_fields() {
let err = RunError::Core(CoreError::Domain(KboltError::ModelNotAvailable {
name: "reranker".to_string(),
}));
let rendered = render_error_output(OutputFormat::Cli, &err, true);
assert_eq!(
rendered,
"\x1b[31merror:\x1b[0m model is not available\ntarget: \x1b[34mreranker\x1b[0m\nreason: the configured provider is not ready\nnext: \x1b[32mkbolt models list\x1b[0m"
);
}
#[test]
fn render_error_output_leaves_clap_parse_errors_unstructured() {
let err = RunError::Clap(Cli::command().error(
clap::error::ErrorKind::UnknownArgument,
"unexpected argument",
));
let rendered = render_error_output(OutputFormat::Cli, &err, false);
assert!(
rendered.contains("unexpected argument"),
"unexpected output: {rendered}"
);
assert!(
!rendered.contains("\nnext:"),
"clap parse errors are a separate UX pass: {rendered}"
);
}
#[test]
fn render_structured_output_serializes_object_payloads() {
let rendered = render_structured_output(&DefaultSpaceJsonResponse {
default_space: Some("work".to_string()),
})
.expect("structured output should serialize");
let value: serde_json::Value =
serde_json::from_str(&rendered).expect("rendered output should be valid json");
assert_eq!(
value,
json!({
"default_space": "work",
})
);
}
#[test]
fn local_stop_is_success_even_when_services_end_stopped() {
let report = LocalReport {
action: LocalAction::Stop,
config_file: PathBuf::from("/tmp/index.toml"),
cache_dir: PathBuf::from("/tmp/cache"),
llama_server_path: None,
ready: false,
notes: Vec::new(),
services: Vec::new(),
};
assert!(local_command_succeeds(&report));
}
#[test]
fn local_status_requires_ready_state_for_success() {
let report = LocalReport {
action: LocalAction::Status,
config_file: PathBuf::from("/tmp/index.toml"),
cache_dir: PathBuf::from("/tmp/cache"),
llama_server_path: None,
ready: false,
notes: Vec::new(),
services: Vec::new(),
};
assert!(!local_command_succeeds(&report));
}
#[test]
fn ignore_show_json_response_preserves_pattern_lines() {
let rendered = render_structured_output(&IgnoreShowJsonResponse {
space: "work".to_string(),
collection: "api".to_string(),
patterns: vec!["dist/".to_string(), "# comment".to_string()],
})
.expect("structured output should serialize");
let value: serde_json::Value =
serde_json::from_str(&rendered).expect("rendered output should be valid json");
assert_eq!(
value,
json!({
"space": "work",
"collection": "api",
"patterns": ["dist/", "# comment"],
})
);
}
#[test]
fn render_structured_output_serializes_update_reports_with_decisions() {
let rendered = render_structured_output(&UpdateReport {
scanned_docs: 2,
skipped_mtime_docs: 0,
skipped_hash_docs: 0,
added_docs: 1,
updated_docs: 0,
failed_docs: 0,
deactivated_docs: 0,
reactivated_docs: 0,
reaped_docs: 0,
embedded_chunks: 0,
decisions: vec![UpdateDecision {
space: "work".to_string(),
collection: "api".to_string(),
path: "src/lib.rs".to_string(),
kind: UpdateDecisionKind::New,
detail: None,
}],
errors: vec![FileError {
path: "/tmp/work-api/src/lib.rs".to_string(),
error: "read failed".to_string(),
}],
elapsed_ms: 12,
})
.expect("structured output should serialize");
let value: serde_json::Value =
serde_json::from_str(&rendered).expect("rendered output should be valid json");
assert_eq!(
value,
json!({
"scanned_docs": 2,
"skipped_mtime_docs": 0,
"skipped_hash_docs": 0,
"added_docs": 1,
"updated_docs": 0,
"failed_docs": 0,
"deactivated_docs": 0,
"reactivated_docs": 0,
"reaped_docs": 0,
"embedded_chunks": 0,
"decisions": [
{
"space": "work",
"collection": "api",
"path": "src/lib.rs",
"kind": "New",
"detail": null
}
],
"errors": [
{
"path": "/tmp/work-api/src/lib.rs",
"error": "read failed"
}
],
"elapsed_ms": 12
})
);
}
#[test]
fn model_not_available_error_detection_is_specific() {
let missing = CoreError::Domain(KboltError::ModelNotAvailable {
name: "test-model".to_string(),
});
assert!(is_model_not_available_error(&missing));
let other = CoreError::Domain(KboltError::InvalidInput("bad input".to_string()));
assert!(!is_model_not_available_error(&other));
}
#[test]
fn update_model_missing_guidance_adds_no_embed_hint() {
let missing = CoreError::Domain(KboltError::ModelNotAvailable {
name: "test-model".to_string(),
});
let rewritten = with_update_model_missing_guidance(missing);
let message = rewritten.to_string();
assert!(message.contains("[roles.embedder]"));
assert!(message.contains("--no-embed"));
let unchanged = CoreError::Domain(KboltError::InvalidInput("bad input".to_string()));
let rewritten_other = with_update_model_missing_guidance(unchanged);
assert_eq!(rewritten_other.to_string(), "invalid input: bad input");
}
#[test]
fn collection_add_model_missing_guidance_adds_no_index_hint() {
let missing = CoreError::Domain(KboltError::ModelNotAvailable {
name: "test-model".to_string(),
});
let rewritten = with_collection_add_model_missing_guidance(missing);
let message = rewritten.to_string();
assert!(message.contains("[roles.embedder]"));
assert!(message.contains("--no-index"));
let unchanged = CoreError::Domain(KboltError::InvalidInput("bad input".to_string()));
let rewritten_other = with_collection_add_model_missing_guidance(unchanged);
assert_eq!(rewritten_other.to_string(), "invalid input: bad input");
}
#[test]
fn first_run_inference_hint_visibility_respects_context() {
assert!(should_show_first_run_inference_hint(
&Command::Status,
OutputFormat::Cli,
true,
false
));
assert!(!should_show_first_run_inference_hint(
&Command::Status,
OutputFormat::Cli,
true,
true
));
assert!(!should_show_first_run_inference_hint(
&Command::Status,
OutputFormat::Cli,
false,
false
));
assert!(!should_show_first_run_inference_hint(
&Command::Mcp,
OutputFormat::Cli,
true,
false
));
assert!(!should_show_first_run_inference_hint(
&Command::Status,
OutputFormat::Json,
true,
false
));
}
#[test]
fn no_subcommand_hint_visibility_respects_context() {
let help = RunError::Clap(Cli::command().error(
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
"",
));
assert!(should_show_no_subcommand_first_run_hint(
&[OsString::from("kbolt")],
OutputFormat::Cli,
&help,
true,
false
));
assert!(!should_show_no_subcommand_first_run_hint(
&[OsString::from("kbolt"), OsString::from("--help")],
OutputFormat::Cli,
&help,
true,
false
));
assert!(!should_show_no_subcommand_first_run_hint(
&[OsString::from("kbolt"), OsString::from("status")],
OutputFormat::Cli,
&help,
true,
false
));
assert!(!should_show_no_subcommand_first_run_hint(
&[OsString::from("kbolt")],
OutputFormat::Json,
&help,
true,
false
));
assert!(!should_show_no_subcommand_first_run_hint(
&[OsString::from("kbolt")],
OutputFormat::Cli,
&help,
true,
true
));
}
#[test]
fn invocation_has_no_subcommand_only_for_global_flags() {
assert!(invocation_has_no_subcommand(&[OsString::from("kbolt")]));
assert!(invocation_has_no_subcommand(&[
OsString::from("kbolt"),
OsString::from("--format"),
OsString::from("cli"),
]));
assert!(invocation_has_no_subcommand(&[
OsString::from("kbolt"),
OsString::from("--space=work"),
]));
assert!(!invocation_has_no_subcommand(&[
OsString::from("kbolt"),
OsString::from("--help"),
]));
assert!(!invocation_has_no_subcommand(&[
OsString::from("kbolt"),
OsString::from("status"),
]));
}
#[test]
fn output_format_validation_rejects_json_for_mcp() {
let err = ensure_supported_output_format(OutputFormat::Json, &Command::Mcp)
.expect_err("json output should not be supported for mcp");
assert!(
err.to_string()
.contains("--format json is not supported for the mcp command"),
"unexpected error: {err}"
);
ensure_supported_output_format(OutputFormat::Cli, &Command::Mcp)
.expect("cli output should remain valid for mcp");
}
#[test]
fn activity_indicator_visibility_requires_cli_tty() {
assert!(should_show_activity_indicator(OutputFormat::Cli, true));
assert!(!should_show_activity_indicator(OutputFormat::Cli, false));
assert!(!should_show_activity_indicator(OutputFormat::Json, true));
}
#[test]
fn setup_progress_visibility_requires_cli_stderr_tty() {
assert!(should_show_setup_progress(OutputFormat::Cli, true));
assert!(!should_show_setup_progress(OutputFormat::Cli, false));
assert!(!should_show_setup_progress(OutputFormat::Json, true));
}
#[test]
fn color_visibility_requires_cli_tty_without_no_color() {
assert!(should_colorize_human_output_with_no_color(
OutputFormat::Cli,
true,
false
));
assert!(!should_colorize_human_output_with_no_color(
OutputFormat::Cli,
false,
false
));
assert!(!should_colorize_human_output_with_no_color(
OutputFormat::Json,
true,
false
));
assert!(!should_colorize_human_output_with_no_color(
OutputFormat::Cli,
true,
true
));
}
#[test]
fn activity_labels_match_update_modes() {
assert_eq!(
collection_add_activity_label(false),
"adding and indexing collection"
);
assert_eq!(collection_add_activity_label(true), "adding collection");
assert_eq!(update_activity_label(false, false), "updating index");
assert_eq!(update_activity_label(true, false), "updating keyword index");
assert_eq!(update_activity_label(false, true), "scanning update");
}
#[test]
fn activity_status_line_includes_elapsed_seconds() {
let line = render_activity_status_line("updating index", Duration::from_secs(7));
assert_eq!(line, "updating index... 7s");
}
#[test]
fn stdout_stderr_tty_helper_is_boolean() {
let _ = stdout_stderr_are_tty();
}
}