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, format_eval_import_report, format_local_report,
resolve_no_rerank_for_mode, CliAdapter, CliSearchOptions,
};
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, CollectionInfo, FileEntry, GetRequest, KboltError,
LocalAction, LocalReport, Locator, ModelStatus, MultiGetRequest, 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 engine = Engine::new_with_recovery_notice(None, cli_recovery_notice_sink())?;
let mut adapter = CliAdapter::new(engine);
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())?;
match schedule.command {
ScheduleCommand::Add(add) => {
let request = schedule_add_request(add)?;
if wants_json {
let response = adapter.engine.add_schedule(request)?;
emit_structured_output(&response)?;
} else {
let line = adapter.schedule_add(request)?;
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 {
let locator = Locator::parse(&get.identifier);
let document = adapter.engine.get_document(GetRequest {
locator,
space: cli.space.clone(),
offset: get.offset,
limit: get.limit,
})?;
emit_structured_output(&document)?;
} else {
let line =
adapter.get(cli.space.as_deref(), &get.identifier, get.offset, get.limit)?;
print_text(&line);
}
}
Command::MultiGet(get) => {
if wants_json {
let locators = get
.locators
.iter()
.map(|item| Locator::parse(item))
.collect();
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(&report));
}
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 => 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(&report));
}
if !local_command_succeeds(&report) {
std::process::exit(1);
}
Ok(true)
}
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(&report));
}
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)?;
}
WatchCommand::Disable => {
let response = watch::service::disable(None)?;
emit_watch_response(cli.format, Some("watch disabled"), &response)?;
}
WatchCommand::Status => {
let response = watch::service::status(None)?;
emit_watch_response(cli.format, None, &response)?;
}
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),
Core(CoreError),
}
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::Core(err) => write!(f, "{err}"),
}
}
}
impl RunError {
fn exit_code(&self) -> i32 {
match self {
Self::Clap(err) => err.exit_code(),
Self::Core(_) => 1,
}
}
}
#[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>) -> std::result::Result<(), RunError> {
if space.is_none() {
return Ok(());
}
Err(CoreError::Domain(KboltError::InvalidInput(
"schedule commands do not use the top-level --space flag; use schedule --space instead"
.to_string(),
))
.into())
}
fn ensure_eval_uses_local_scope(space: Option<&str>) -> std::result::Result<(), RunError> {
if space.is_none() {
return Ok(());
}
Err(CoreError::Domain(KboltError::InvalidInput(
"eval commands do not use the top-level --space flag; set scope inside the eval manifest"
.to_string(),
))
.into())
}
fn ensure_watch_uses_local_scope(space: Option<&str>) -> std::result::Result<(), RunError> {
if space.is_none() {
return Ok(());
}
Err(CoreError::Domain(KboltError::InvalidInput(
"watch commands keep all configured collections fresh and do not use the top-level --space flag"
.to_string(),
))
.into())
}
fn schedule_add_request(
args: ScheduleAddArgs,
) -> std::result::Result<AddScheduleRequest, RunError> {
Ok(AddScheduleRequest {
trigger: schedule_trigger(&args)?,
scope: schedule_scope(args.space, args.collections),
})
}
fn schedule_remove_request(
args: ScheduleRemoveArgs,
) -> std::result::Result<RemoveScheduleRequest, RunError> {
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)?,
});
}
let time = args.at.clone().ok_or_else(|| {
RunError::from(CoreError::Domain(KboltError::InvalidInput(
"schedule trigger requires --every or --at".to_string(),
)))
})?;
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, RunError> {
let normalized = raw.trim().to_ascii_lowercase();
if normalized.len() < 2 {
return Err(invalid_schedule_interval(raw));
}
let (value, unit) = normalized.split_at(normalized.len() - 1);
let value = value
.parse::<u32>()
.map_err(|_| invalid_schedule_interval(raw))?;
let unit = match unit {
"m" => ScheduleIntervalUnit::Minutes,
"h" => ScheduleIntervalUnit::Hours,
_ => return Err(invalid_schedule_interval(raw)),
};
Ok(ScheduleInterval { value, unit })
}
fn invalid_schedule_interval(raw: &str) -> RunError {
CoreError::Domain(KboltError::InvalidInput(format!(
"invalid schedule interval '{raw}': use <minutes>m or <hours>h"
)))
.into()
}
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 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,
) -> std::result::Result<(), RunError> {
if format == OutputFormat::Json {
emit_structured_output(response)?;
return Ok(());
}
let rendered = format_watch_status(response);
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(())
}
fn format_watch_status(response: &WatchStatusResponse) -> String {
let mut lines = Vec::new();
let service = &response.service;
let service_line = if service.enabled && service.running {
format!("watch: running ({})", watch_backend_label(service.backend))
} else if service.enabled {
format!(
"watch: enabled, not running ({})",
watch_backend_label(service.backend)
)
} else if service.running {
format!(
"watch: running, not enabled ({})",
watch_backend_label(service.backend)
)
} else {
"watch: disabled".to_string()
};
lines.push(service_line);
if let Some(pid) = service.pid {
lines.push(format!("pid: {pid}"));
}
if let Some(issue) = service.issue.as_deref() {
lines.push(format!("issue: {issue}"));
}
if let Some(runtime) = response.runtime.as_ref() {
lines.push(format!(
"runtime: {} / {} / {}",
watch_mode_label(runtime.mode),
watch_state_label(runtime.state),
watch_health_label(runtime.health)
));
lines.push(format!(
"collections: {} watched, {} dirty",
runtime.watched_collections, runtime.dirty_collections
));
if runtime.semantic_pending_collections > 0
|| runtime.semantic_unavailable_collections > 0
|| !runtime.semantic_blocked_spaces.is_empty()
{
lines.push(format!(
"semantic: {} pending, {} unavailable, {} blocked spaces",
runtime.semantic_pending_collections,
runtime.semantic_unavailable_collections,
runtime.semantic_blocked_spaces.len()
));
}
if let Some(summary) = runtime.last_keyword_refresh.as_ref() {
lines.push(format!(
"last keyword refresh: {}/{} ({} changed, {}ms)",
summary.space, summary.collection, summary.changed_docs, summary.elapsed_ms
));
}
if let Some(summary) = runtime.last_semantic_refresh.as_ref() {
lines.push(format!(
"last semantic refresh: {}/{} ({} embedded, {}ms)",
summary.space, summary.collection, summary.embedded_chunks, summary.elapsed_ms
));
}
for block in &runtime.semantic_blocked_spaces {
lines.push(format!(
"blocked: space {} needs repair; run `{}`",
block.space, block.fix
));
}
if let Some(error) = runtime.last_error.as_deref() {
lines.push(format!("last error: {error}"));
}
} else if service.enabled {
lines.push("runtime: no state written yet".to_string());
}
lines.push(format!("logs: {}", response.log_file.display()));
lines.join("\n")
}
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 rendered = render_error_output(format, err);
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) -> String {
if let RunError::Clap(clap_err) = err {
if matches!(
clap_err.kind(),
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
) {
return clap_err.to_string();
}
}
match format {
OutputFormat::Cli => err.to_string(),
OutputFormat::Json => json!({
"ok": false,
"error": {
"kind": json_error_kind(err),
"message": err.to_string(),
}
})
.to_string(),
}
}
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 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::Core(CoreError::Domain(KboltError::SpaceNotFound { .. }))
| RunError::Core(CoreError::Domain(KboltError::CollectionNotFound { .. }))
| RunError::Core(CoreError::Domain(KboltError::DocumentNotFound { .. }))
| RunError::Core(CoreError::Domain(KboltError::FileNotFound(_))) => "not_found",
RunError::Core(CoreError::Domain(KboltError::SpaceAlreadyExists { .. }))
| RunError::Core(CoreError::Domain(KboltError::CollectionAlreadyExists { .. })) => {
"already_exists"
}
RunError::Core(CoreError::Domain(KboltError::AmbiguousSpace { .. })) => "ambiguous_space",
RunError::Core(CoreError::Domain(KboltError::NoActiveSpace))
| RunError::Core(CoreError::Domain(KboltError::InvalidInput(_)))
| RunError::Core(CoreError::Domain(KboltError::SpaceDenseRepairRequired { .. }))
| RunError::Core(CoreError::Domain(KboltError::InvalidPath(_))) => "invalid_input",
RunError::Core(CoreError::Domain(KboltError::ModelNotAvailable { .. })) => {
"model_not_available"
}
RunError::Core(CoreError::Domain(KboltError::ModelDownload(_))) => "model_download",
RunError::Core(CoreError::Domain(KboltError::Inference(_))) => "inference",
RunError::Core(CoreError::Domain(KboltError::Config(_))) => "config",
RunError::Core(CoreError::Domain(KboltError::Database(_)))
| RunError::Core(CoreError::Domain(KboltError::Tantivy(_)))
| RunError::Core(CoreError::Domain(KboltError::USearch(_)))
| RunError::Core(CoreError::Domain(KboltError::FileDeleted(_)))
| RunError::Core(CoreError::Domain(KboltError::Internal(_)))
| RunError::Core(CoreError::Domain(KboltError::Io(_)))
| RunError::Core(CoreError::Sqlite(_))
| RunError::Core(CoreError::TomlDe(_))
| RunError::Core(CoreError::TomlSer(_))
| RunError::Core(CoreError::Json(_))
| RunError::Core(CoreError::Tantivy(_))
| RunError::Core(CoreError::Io(_))
| RunError::Core(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_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, 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_show_activity_indicator, should_show_first_run_inference_hint,
should_show_no_subcommand_first_run_hint, stdout_stderr_are_tty, update_activity_label,
with_collection_add_model_missing_guidance, with_update_model_missing_guidance,
DefaultSpaceJsonResponse, IgnoreShowJsonResponse, RunError, INTERNAL_SCHEDULE_RUN_COMMAND,
INTERNAL_WATCH_RUN_COMMAND,
};
use kbolt_cli::args::{
Cli, Command, OutputFormat, ScheduleAddArgs, 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, WatchRuntimeState,
WatchRuntimeStatus, WatchServiceStatus, 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("logs: /tmp/kbolt/watch.log"));
}
#[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 (systemd-user)"));
assert!(rendered.contains("runtime: native / idle / ok"));
assert!(rendered.contains("collections: 2 watched, 1 dirty"));
assert!(rendered.contains("semantic: 1 pending, 0 unavailable, 0 blocked spaces"));
}
#[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,
}
);
let err = parse_schedule_interval("7d").expect_err("reject invalid interval unit");
assert!(
err.to_string().contains("invalid schedule interval"),
"unexpected error: {err}"
);
}
#[test]
fn ensure_schedule_uses_local_scope_rejects_top_level_space() {
let err = ensure_schedule_uses_local_scope(Some("work"))
.expect_err("top-level space should fail");
assert!(
err.to_string()
.contains("schedule commands do not use the top-level --space flag"),
"unexpected error: {err}"
);
ensure_schedule_uses_local_scope(None).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);
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);
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_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 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();
}
}