pub mod args;
use std::collections::HashMap;
use std::path::Path;
use kbolt_core::engine::Engine;
use kbolt_core::Result;
use kbolt_types::{
ActiveSpaceSource, AddCollectionRequest, AddCollectionResult, AddScheduleRequest, ChunkLocator,
ChunkResponse, CollectionInfo, DoctorCheck, DoctorCheckStatus, DoctorReport, DoctorSetupStatus,
DocumentResponse, EvalImportReport, EvalRunReport, FileEntry, GetChunkRequest, GetRequest,
InitialIndexingBlock, InitialIndexingOutcome, KboltError, LocalAction, LocalReport, Locator,
ModelInfo, MultiGetItem, MultiGetItemKind, MultiGetRequest, MultiGetResponse, OmitReason,
ReadLocator, RemoveScheduleRequest, RemoveScheduleSelector, ScheduleAddResponse,
ScheduleBackend, ScheduleInterval, ScheduleIntervalUnit, ScheduleRunResult, ScheduleScope,
ScheduleState, ScheduleStatusResponse, ScheduleTrigger, ScheduleWeekday, SearchEmptyReason,
SearchMode, SearchPipeline, SearchPipelineNotice, SearchPipelineStep,
SearchPipelineUnavailableReason, SearchRequest, StatusResponse, UpdateDecision,
UpdateDecisionKind, UpdateOptions, UpdateReport,
};
pub struct CliAdapter {
pub engine: Engine,
color: bool,
}
pub struct CliSearchOptions<'a> {
pub space: Option<&'a str>,
pub query: &'a str,
pub collections: &'a [String],
pub limit: usize,
pub min_score: f32,
pub deep: bool,
pub keyword: bool,
pub semantic: bool,
pub rerank: bool,
pub no_rerank: bool,
pub debug: bool,
}
pub struct CliGetOptions<'a> {
pub space: Option<&'a str>,
pub identifier: &'a str,
pub offset: Option<usize>,
pub limit: Option<usize>,
}
struct GroupedSearchResult<'a> {
primary: &'a kbolt_types::SearchResult,
additional_matches: Vec<&'a kbolt_types::SearchResult>,
}
const ADDITIONAL_MATCHES_SHOWN: usize = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CliStyle {
Title,
Label,
Chrome,
Section,
Identity,
Action,
Warning,
Error,
}
impl CliStyle {
#[cfg(test)]
const ALL: [Self; 8] = [
Self::Title,
Self::Label,
Self::Chrome,
Self::Section,
Self::Identity,
Self::Action,
Self::Warning,
Self::Error,
];
}
impl CliAdapter {
pub fn new(engine: Engine) -> Self {
Self {
engine,
color: false,
}
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn space_add(
&mut self,
name: &str,
description: Option<&str>,
strict: bool,
dirs: &[std::path::PathBuf],
) -> Result<String> {
let color = self.color;
if strict {
use std::collections::HashSet;
let mut validation_errors = Vec::new();
let mut derived_names = HashSet::new();
for dir in dirs {
if !dir.is_absolute() || !dir.is_dir() {
validation_errors.push(format!("- {} -> invalid path", dir.display()));
continue;
}
let collection_name = dir.file_name().and_then(|item| item.to_str());
match collection_name {
Some(name) => {
if !derived_names.insert(name.to_string()) {
validation_errors.push(format!(
"- {} -> duplicate derived collection name '{name}'",
dir.display()
));
}
}
None => validation_errors.push(format!(
"- {} -> cannot derive collection name from path",
dir.display()
)),
}
}
if !validation_errors.is_empty() {
let mut lines = Vec::new();
lines.push("strict mode aborted: one or more directories are invalid".to_string());
lines.extend(validation_errors);
return Err(kbolt_types::KboltError::InvalidInput(lines.join("\n")).into());
}
}
let added = self.engine.add_space(name, description)?;
if dirs.is_empty() {
let mut lines = vec![format_space_add_response(&added.name, color)];
if let Some(description) = added
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
push_field_color(&mut lines, "description", description, color);
}
return Ok(lines.join("\n"));
}
let mut successes = Vec::new();
let mut failures = Vec::new();
for dir in dirs {
let collection_name = dir
.file_name()
.and_then(|item| item.to_str())
.map(ToString::to_string);
let result = self.engine.add_collection(AddCollectionRequest {
path: dir.clone(),
space: Some(name.to_string()),
name: collection_name,
description: None,
extensions: None,
no_index: true,
});
match result {
Ok(info) => successes.push(format!(
"{} -> {}/{}",
dir.display(),
info.collection.space,
info.collection.name
)),
Err(err) => {
if strict {
let rollback_result = self.engine.remove_space(name);
return match rollback_result {
Ok(()) => Err(err),
Err(rollback_err) => Err(kbolt_types::KboltError::Internal(format!(
"strict mode rollback failed: add error: {err}; rollback error: {rollback_err}"
))
.into()),
};
}
failures.push(format!("{} -> {}", dir.display(), err));
}
}
}
let mut lines = Vec::new();
lines.push(format_space_add_response(&added.name, color));
if let Some(description) = added
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
push_field_color(&mut lines, "description", description, color);
}
push_section_color(&mut lines, "collections", color);
push_bullet_color(&mut lines, format!("{} registered", successes.len()), color);
for success in successes {
push_bullet_color(&mut lines, success, color);
}
if !failures.is_empty() {
push_section_color(&mut lines, "failed", color);
push_bullet_color(
&mut lines,
format!("{} collection(s)", failures.len()),
color,
);
for failure in failures {
push_bullet_color(&mut lines, failure, color);
}
}
push_section_color(&mut lines, "indexing", color);
push_warning_bullet_color(&mut lines, "skipped (collections registered only)", color);
push_section_color(&mut lines, "next", color);
push_action_bullet_color(
&mut lines,
format!("kbolt --space {} update", shell_quote_arg(&added.name)),
color,
);
Ok(lines.join("\n"))
}
pub fn space_describe(&self, name: &str, text: &str) -> Result<String> {
self.engine.describe_space(name, text)?;
Ok(format_space_describe_response(name, self.color))
}
pub fn space_rename(&mut self, old: &str, new: &str) -> Result<String> {
self.engine.rename_space(old, new)?;
Ok(format_space_rename_response(old, new, self.color))
}
pub fn space_remove(&mut self, name: &str) -> Result<String> {
self.engine.remove_space(name)?;
if name == "default" {
return Ok(format_space_remove_default_response(self.color));
}
Ok(format_space_remove_response(name, self.color))
}
pub fn space_default(&mut self, name: Option<&str>) -> Result<String> {
if let Some(space_name) = name {
let updated = self.engine.set_default_space(Some(space_name))?;
let value = updated.unwrap_or_default();
return Ok(format_space_default_response(Some(&value), self.color));
}
let current = self.engine.config().default_space.as_deref();
Ok(format_space_default_response(current, self.color))
}
pub fn space_current(&self, explicit: Option<&str>) -> Result<String> {
let active = self.engine.current_space(explicit)?;
let output = match active {
Some(active) => {
let source = match active.source {
ActiveSpaceSource::Flag => "flag",
ActiveSpaceSource::EnvVar => "env",
ActiveSpaceSource::ConfigDefault => "default",
};
format_space_current_response(Some((&active.name, source)), self.color)
}
None => format_space_current_response(None, self.color),
};
Ok(output)
}
pub fn space_list(&self) -> Result<String> {
let spaces = self.engine.list_spaces()?;
let mut lines = Vec::with_capacity(spaces.len() + 1);
push_section_color(&mut lines, "spaces", self.color);
if spaces.is_empty() {
push_bullet_color(&mut lines, "none", self.color);
return Ok(lines.join("\n"));
}
for space in spaces {
let description = space.description.unwrap_or_default();
push_bullet_color(
&mut lines,
format!(
"{}: {} collection(s), {} document(s), {} chunk(s)",
space.name, space.collection_count, space.document_count, space.chunk_count
),
self.color,
);
if !description.is_empty() {
push_field_color(&mut lines, "description", description, self.color);
}
}
Ok(lines.join("\n"))
}
pub fn space_info(&self, name: &str) -> Result<String> {
let space = self.engine.space_info(name)?;
let mut lines = vec![format!("space: {}", space.name)];
if let Some(description) = space
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
push_field_color(&mut lines, "description", description, self.color);
}
push_section_color(&mut lines, "contents", self.color);
push_bullet_color(
&mut lines,
format!("{} collection(s)", space.collection_count),
self.color,
);
push_bullet_color(
&mut lines,
format!("{} document(s)", space.document_count),
self.color,
);
push_bullet_color(
&mut lines,
format!("{} chunk(s)", space.chunk_count),
self.color,
);
push_section_color(&mut lines, "timestamps", self.color);
push_bullet_color(&mut lines, format!("created {}", space.created), self.color);
Ok(lines.join("\n"))
}
pub fn collection_list(&self, space: Option<&str>) -> Result<String> {
let collections = self.engine.list_collections(space)?;
let mut lines = Vec::with_capacity(collections.len() + 1);
push_section_color(&mut lines, "collections", self.color);
if collections.is_empty() {
push_bullet_color(&mut lines, "none", self.color);
return Ok(lines.join("\n"));
}
for collection in collections {
push_bullet_color(
&mut lines,
format!(
"{}/{} ({})",
collection.space,
collection.name,
collection.path.display()
),
self.color,
);
}
Ok(lines.join("\n"))
}
pub fn collection_add(
&self,
space: Option<&str>,
path: &std::path::Path,
name: Option<&str>,
description: Option<&str>,
extensions: Option<&[String]>,
no_index: bool,
) -> Result<String> {
let added = self.engine.add_collection(AddCollectionRequest {
path: path.to_path_buf(),
space: space.map(ToString::to_string),
name: name.map(ToString::to_string),
description: description.map(ToString::to_string),
extensions: extensions.map(|items| items.to_vec()),
no_index,
})?;
Ok(format_collection_add_result_color(&added, self.color))
}
pub fn collection_info(&self, space: Option<&str>, name: &str) -> Result<String> {
let collection = self.engine.collection_info(space, name)?;
Ok(format_collection_info_color(&collection, self.color))
}
pub fn collection_describe(
&self,
space: Option<&str>,
name: &str,
text: &str,
) -> Result<String> {
self.engine.describe_collection(space, name, text)?;
Ok(format_collection_describe_response(name, self.color))
}
pub fn collection_rename(&self, space: Option<&str>, old: &str, new: &str) -> Result<String> {
self.engine.rename_collection(space, old, new)?;
Ok(format_collection_rename_response(old, new, self.color))
}
pub fn collection_remove(&self, space: Option<&str>, name: &str) -> Result<String> {
let collection = self.engine.collection_info(space, name)?;
self.engine.remove_collection(space, name)?;
Ok(format_collection_remove_response(
&collection.space,
name,
self.color,
))
}
pub fn ignore_show(&self, space: Option<&str>, collection: &str) -> Result<String> {
let (resolved_space, content) = self.engine.read_collection_ignore(space, collection)?;
let mut lines = vec![format!("ignore: {resolved_space}/{collection}")];
push_section_color(&mut lines, "contents", self.color);
if let Some(content) = content {
append_ignore_content_lines(&mut lines, &content);
} else {
push_bullet_color(&mut lines, "none", self.color);
}
Ok(lines.join("\n"))
}
pub fn ignore_add(
&self,
space: Option<&str>,
collection: &str,
pattern: &str,
) -> Result<String> {
let (resolved_space, normalized_pattern) = self
.engine
.add_collection_ignore_pattern(space, collection, pattern)?;
let mut lines = vec![format!("ignore updated: {resolved_space}/{collection}")];
push_section_color(&mut lines, "added", self.color);
push_bullet_color(&mut lines, normalized_pattern, self.color);
Ok(lines.join("\n"))
}
pub fn ignore_remove(
&self,
space: Option<&str>,
collection: &str,
pattern: &str,
) -> Result<String> {
let (resolved_space, removed_count) = self
.engine
.remove_collection_ignore_pattern(space, collection, pattern)?;
if removed_count == 0 {
let mut lines = vec![format!("ignore unchanged: {resolved_space}/{collection}")];
push_section_color(&mut lines, "missing", self.color);
push_bullet_color(&mut lines, pattern, self.color);
return Ok(lines.join("\n"));
}
let mut lines = vec![format!("ignore updated: {resolved_space}/{collection}")];
push_section_color(&mut lines, "removed", self.color);
push_bullet_color(&mut lines, pattern, self.color);
push_section_color(&mut lines, "matches", self.color);
push_bullet_color(&mut lines, removed_count, self.color);
Ok(lines.join("\n"))
}
pub fn ignore_list(&self, space: Option<&str>) -> Result<String> {
let entries = self.engine.list_collection_ignores(space)?;
let mut lines = Vec::new();
push_section_color(&mut lines, "ignore files", self.color);
if entries.is_empty() {
push_bullet_color(&mut lines, "none", self.color);
return Ok(lines.join("\n"));
}
for entry in entries {
push_bullet_color(
&mut lines,
format!(
"{}/{}: {} pattern(s)",
entry.space, entry.collection, entry.pattern_count
),
self.color,
);
}
Ok(lines.join("\n"))
}
pub fn ignore_edit(&self, space: Option<&str>, collection: &str) -> Result<String> {
let (resolved_space, path) = self
.engine
.prepare_collection_ignore_edit(space, collection)?;
let editor_command = resolve_editor_command()?;
let mut process = std::process::Command::new(&editor_command[0]);
if editor_command.len() > 1 {
process.args(&editor_command[1..]);
}
process.arg(&path);
let status = process.status().map_err(|err| {
KboltError::Internal(format!(
"failed to launch editor '{}': {err}",
editor_command[0]
))
})?;
if !status.success() {
return Err(
KboltError::Internal(format!("editor exited with status: {status}")).into(),
);
}
let mut lines = vec![format!("ignore updated: {resolved_space}/{collection}")];
push_field_color(&mut lines, "path", path.display(), self.color);
Ok(lines.join("\n"))
}
pub fn models_list(&self) -> Result<String> {
let status = self.engine.model_status()?;
Ok(format_models_list_color(&status, self.color))
}
pub fn eval_run(&self, eval_file: Option<&Path>) -> Result<String> {
let report = self.engine.run_eval(eval_file)?;
Ok(format_eval_run_report_color(&report, self.color))
}
pub fn search(&self, options: CliSearchOptions<'_>) -> Result<String> {
let CliSearchOptions {
space,
query,
collections,
limit,
min_score,
deep,
keyword,
semantic,
rerank,
no_rerank,
debug,
} = options;
let mode_flags = deep as u8 + keyword as u8 + semantic as u8;
if mode_flags > 1 {
return Err(KboltError::InvalidInput(
"only one of --deep, --keyword, or --semantic can be set".to_string(),
)
.into());
}
let mode = if deep {
SearchMode::Deep
} else if keyword {
SearchMode::Keyword
} else if semantic {
SearchMode::Semantic
} else {
SearchMode::Auto
};
let effective_no_rerank = resolve_no_rerank_for_mode(mode.clone(), rerank, no_rerank);
let engine_limit = if debug {
limit
} else {
limit.saturating_mul(2)
};
let response = self.engine.search(SearchRequest {
query: query.to_string(),
mode,
space: space.map(ToString::to_string),
collections: collections.to_vec(),
limit: engine_limit,
min_score,
no_rerank: effective_no_rerank,
debug,
})?;
let color = self.color && !debug;
let mut lines = Vec::new();
if debug {
push_section(&mut lines, "debug");
push_field(&mut lines, "query", &response.query);
push_field(
&mut lines,
"mode",
format!(
"{} -> {}",
format_search_mode(&response.requested_mode),
format_search_mode(&response.effective_mode)
),
);
push_field(
&mut lines,
"pipeline",
format_search_pipeline(&response.pipeline),
);
push_field(&mut lines, "result count", response.results.len());
for notice in &response.pipeline.notices {
push_field(&mut lines, "note", format_search_pipeline_notice(notice));
}
}
if debug {
push_section(&mut lines, "results");
if response.results.is_empty() {
push_bullet(&mut lines, "none");
}
for item in &response.results {
push_bullet(&mut lines, &item.docid);
push_field(&mut lines, "score", format!("{:.3}", item.score));
push_field(
&mut lines,
"path",
format_search_result_path(&item.space, &item.path),
);
push_field(&mut lines, "chunk", shell_quote_arg(&item.chunk.locator));
if let Some(heading) = &item.heading {
push_field(&mut lines, "heading", heading);
}
lines.push(" snippet:".to_string());
let snippet = truncate_snippet(&item.text, 4);
for snippet_line in snippet.lines() {
lines.push(format!(" {snippet_line}"));
}
if let Some(signals) = &item.signals {
lines.push(" signals:".to_string());
lines.push(format!(
" bm25: {}",
format_optional_search_signal(signals.bm25)
));
lines.push(format!(
" dense: {}",
format_optional_search_signal(signals.dense)
));
lines.push(format!(
" fusion: {}",
format_search_signal(signals.fusion)
));
lines.push(format!(
" reranker: {}",
format_optional_search_signal(signals.reranker)
));
}
}
} else {
let grouped_results = group_search_results(&response.results, limit);
lines.push(paint_title(
color,
format!(
"{} document{}",
grouped_results.len(),
if grouped_results.len() == 1 { "" } else { "s" }
),
));
lines.push(format_field(color, "query", &response.query));
lines.push(format_field(
color,
"mode",
format_search_mode(&response.effective_mode),
));
for (index, item) in grouped_results.iter().enumerate() {
let locator = self.search_chunk_locator(item.primary)?;
let read_command = format_chunk_read_command(&item.primary.space, &locator);
lines.push(String::new());
lines.push(format!(
"{}. {} {}",
index + 1,
paint_title(color, &item.primary.title),
paint_identity(color, &locator)
));
lines.push(format!(
" {} {}",
paint_label(color, "path:"),
paint_identity(
color,
format_search_result_path(&item.primary.space, &item.primary.path),
)
));
if let Some(heading) = &item.primary.heading {
lines.push(format!(" {} {heading}", paint_label(color, "section:")));
}
lines.push(format!(
" {} {}",
paint_label(color, "read:"),
paint_action(color, read_command)
));
lines.push(String::new());
let snippet = truncate_snippet(&item.primary.text, 4);
for snippet_line in snippet.lines() {
lines.push(format!(" {snippet_line}"));
}
if !item.additional_matches.is_empty() {
lines.push(String::new());
lines.push(format!(
" {} {} more chunk{}",
paint_label(color, "also found:"),
item.additional_matches.len(),
if item.additional_matches.len() == 1 {
""
} else {
"s"
}
));
for matched in item
.additional_matches
.iter()
.take(ADDITIONAL_MATCHES_SHOWN)
{
lines.push(format!(
" {} {}",
paint_chrome(color, "-"),
format_additional_match_label(matched)
));
}
let hidden = item
.additional_matches
.len()
.saturating_sub(ADDITIONAL_MATCHES_SHOWN);
if hidden > 0 {
lines.push(format!(
" {} +{} more chunk{}",
paint_chrome(color, "-"),
hidden,
if hidden == 1 { "" } else { "s" }
));
}
}
}
}
if response.results.is_empty() {
if let Some(hint) =
self.empty_index_hint(space, collections, response.empty_reason.as_ref())
{
lines.push(String::new());
lines.push(hint);
}
}
if let Some(hint) = response.staleness_hint {
lines.push(String::new());
if !debug {
for notice in &response.pipeline.notices {
lines.push(format_normal_search_pipeline_notice_color(
notice,
&response.effective_mode,
color,
));
}
}
lines.push(hint);
}
if debug {
push_section(&mut lines, "elapsed");
push_bullet(&mut lines, format!("{}ms", response.elapsed_ms));
}
Ok(lines.join("\n"))
}
fn search_chunk_locator(&self, result: &kbolt_types::SearchResult) -> Result<String> {
self.compact_chunk_locator(
&result.chunk.locator,
&result.space,
&result.docid,
result.chunk.ordinal,
)
}
fn chunk_response_locator(&self, chunk: &ChunkResponse) -> Result<String> {
self.compact_chunk_locator(
&chunk.locator,
&chunk.space,
&chunk.docid,
chunk.chunk_ordinal,
)
}
fn compact_chunk_locator(
&self,
full_locator: &str,
space: &str,
fallback_docid: &str,
chunk_ordinal: usize,
) -> Result<String> {
let Some(full_docid) = full_docid_from_chunk_locator(full_locator) else {
return Ok(format_compact_chunk_locator(fallback_docid, chunk_ordinal));
};
let min_len = fallback_docid.trim_start_matches('#').len();
let docid = self
.engine
.shortest_unambiguous_docid_prefix(full_docid, space, min_len)?;
Ok(format_compact_chunk_locator(&docid, chunk_ordinal))
}
fn empty_index_hint(
&self,
space: Option<&str>,
requested: &[String],
empty_reason: Option<&SearchEmptyReason>,
) -> Option<String> {
let all = self.engine.list_collections(space).ok()?;
let scoped: Vec<_> = if requested.is_empty() {
all
} else {
all.into_iter()
.filter(|c| requested.iter().any(|r| r == &c.name))
.collect()
};
if scoped.is_empty() {
let space_arg = shell_quote_arg(space.unwrap_or("default"));
let mut lines = vec!["no collections registered yet".to_string()];
push_section_color(&mut lines, "next", self.color);
push_action_bullet_color(
&mut lines,
format!("kbolt --space {space_arg} collection add /path/to/docs"),
self.color,
);
return Some(lines.join("\n"));
}
if matches!(empty_reason, Some(SearchEmptyReason::UnindexedScope)) {
let mut lines = vec!["nothing indexed for this scope".to_string()];
push_section_color(&mut lines, "next", self.color);
for command in empty_scope_update_commands(space, requested, &scoped) {
push_action_bullet_color(&mut lines, command, self.color);
}
return Some(lines.join("\n"));
}
None
}
pub fn update(
&self,
space: Option<&str>,
collections: &[String],
no_embed: bool,
dry_run: bool,
verbose: bool,
) -> Result<String> {
let report = self.engine.update(UpdateOptions {
space: space.map(ToString::to_string),
collections: collections.to_vec(),
no_embed,
dry_run,
verbose,
})?;
Ok(format_update_report_color(
&report, verbose, no_embed, self.color,
))
}
pub fn schedule_add(&self, req: AddScheduleRequest) -> Result<String> {
let response = self.engine.add_schedule(req)?;
Ok(format_schedule_add_response_color(&response, self.color))
}
pub fn schedule_status(&self) -> Result<String> {
let response = self.engine.schedule_status()?;
Ok(format_schedule_status_response_color(&response, self.color))
}
pub fn schedule_remove(&self, req: RemoveScheduleRequest) -> Result<String> {
let selector = req.selector.clone();
let response = self.engine.remove_schedule(req)?;
Ok(format_schedule_remove_response_color(
&selector, &response, self.color,
))
}
pub fn status(&self, space: Option<&str>) -> Result<String> {
let status = self.engine.status(space)?;
let active_space = active_space_name_for_status(&self.engine, space);
Ok(format_status_response_color(
&status,
active_space.as_deref(),
self.color,
))
}
pub fn ls(
&self,
space: Option<&str>,
collection: &str,
prefix: Option<&str>,
all: bool,
) -> Result<String> {
let mut files = self.engine.list_files(space, collection, prefix)?;
if !all {
files.retain(|file| file.active);
}
Ok(format_file_list_color(&files, all, self.color))
}
pub fn get(&self, options: CliGetOptions<'_>) -> Result<String> {
let CliGetOptions {
space,
identifier,
offset,
limit,
} = options;
let color = self.color;
if let Some(locator) = ChunkLocator::parse(identifier)? {
let chunk = self.engine.get_chunk(GetChunkRequest {
locator,
space: space.map(ToString::to_string),
offset,
limit,
})?;
let display_locator = self.chunk_response_locator(&chunk)?;
return Ok(format_chunk_response(&chunk, &display_locator, color));
}
let document = self.engine.get_document(GetRequest {
locator: Locator::parse(identifier),
space: space.map(ToString::to_string),
offset,
limit,
})?;
Ok(format_document_response(&document, color))
}
pub fn multi_get(
&self,
space: Option<&str>,
locators: &[String],
max_files: usize,
max_bytes: usize,
) -> Result<String> {
let locators = locators
.iter()
.map(|item| ReadLocator::parse(item))
.collect::<kbolt_types::Result<Vec<_>>>()?;
let response = self.engine.multi_get(MultiGetRequest {
locators,
space: space.map(ToString::to_string),
max_files,
max_bytes,
})?;
let chunk_display_locators = self.multi_get_chunk_display_locators(&response)?;
Ok(format_multi_get_response_color_with_chunk_locators(
&response,
self.color,
&chunk_display_locators,
))
}
fn multi_get_chunk_display_locators(
&self,
response: &MultiGetResponse,
) -> Result<HashMap<String, String>> {
let mut locators = HashMap::new();
for item in &response.items {
let MultiGetItem::Chunk(chunk) = item else {
continue;
};
locators.insert(chunk.locator.clone(), self.chunk_response_locator(chunk)?);
}
for omitted in &response.omitted {
if omitted.kind != MultiGetItemKind::Chunk {
continue;
}
let Some(locator) = ChunkLocator::parse(&omitted.locator)? else {
continue;
};
locators.insert(
omitted.locator.clone(),
self.compact_chunk_locator(
&omitted.locator,
&omitted.space,
&omitted.docid,
locator.chunk_ordinal,
)?,
);
}
Ok(locators)
}
}
fn empty_scope_update_commands(
space: Option<&str>,
requested: &[String],
scoped: &[CollectionInfo],
) -> Vec<String> {
if requested.is_empty() {
return match space {
Some(space) => vec![format!("kbolt --space {} update", shell_quote_arg(space))],
None => vec!["kbolt update".to_string()],
};
}
let mut grouped: Vec<(&str, Vec<&str>)> = Vec::new();
for collection in scoped {
if let Some((_, names)) = grouped
.iter_mut()
.find(|(space_name, _)| *space_name == collection.space.as_str())
{
names.push(collection.name.as_str());
} else {
grouped.push((collection.space.as_str(), vec![collection.name.as_str()]));
}
}
grouped
.into_iter()
.map(|(space_name, collections)| {
let mut command = format!("kbolt --space {} update", shell_quote_arg(space_name));
for collection in collections {
command.push_str(" --collection ");
command.push_str(&shell_quote_arg(collection));
}
command
})
.collect()
}
pub fn format_doctor_report(report: &DoctorReport) -> String {
format_doctor_report_color(report, false)
}
pub fn format_doctor_report_color(report: &DoctorReport, color: bool) -> String {
let mut lines = Vec::new();
let failures: Vec<_> = report
.checks
.iter()
.filter(|c| c.status == DoctorCheckStatus::Fail)
.collect();
let warnings: Vec<_> = report
.checks
.iter()
.filter(|c| c.status == DoctorCheckStatus::Warn)
.collect();
let expected_unindexed_warnings: Vec<_> = warnings
.iter()
.filter(|check| is_expected_unindexed_storage_warning(check))
.collect();
match report.setup_status {
DoctorSetupStatus::ConfigMissing => {
lines.push("kbolt is not set up".to_string());
push_section_color(&mut lines, "next", color);
push_action_bullet_color(&mut lines, "kbolt setup local", color);
return lines.join("\n");
}
DoctorSetupStatus::ConfigInvalid => {
lines.push("kbolt configuration is invalid".to_string());
push_section_color(&mut lines, "failures", color);
for check in &failures {
push_error_bullet_color(&mut lines, &check.message, color);
if let Some(fix) = check.fix.as_deref() {
push_action_field_color(&mut lines, "fix", fix, color);
}
}
if let Some(path) = report.config_file.as_ref() {
push_section_color(&mut lines, "config", color);
push_identity_bullet_color(&mut lines, path.display(), color);
}
return lines.join("\n");
}
DoctorSetupStatus::NotConfigured => {
lines.push("kbolt is installed but no inference roles are configured".to_string());
push_section_color(&mut lines, "next", color);
push_action_bullet_color(&mut lines, "kbolt setup local", color);
return lines.join("\n");
}
DoctorSetupStatus::Configured => {}
}
if report.ready && failures.is_empty() {
lines.push("kbolt is ready".to_string());
} else {
lines.push("kbolt has issues".to_string());
}
let configured_roles: Vec<_> = report
.checks
.iter()
.filter(|c| c.id.ends_with(".bound") && c.status == DoctorCheckStatus::Pass)
.collect();
if !configured_roles.is_empty() {
push_section_color(&mut lines, "configured", color);
for check in &configured_roles {
let role = check.scope.strip_prefix("roles.").unwrap_or(&check.scope);
push_bullet_color(&mut lines, role, color);
}
}
let not_enabled: Vec<_> = report
.checks
.iter()
.filter(|c| c.id.ends_with(".bound") && c.status == DoctorCheckStatus::Warn)
.collect();
if !not_enabled.is_empty() {
push_section_color(&mut lines, "not enabled", color);
for check in ¬_enabled {
let role = check.scope.strip_prefix("roles.").unwrap_or(&check.scope);
push_warning_bullet_color(&mut lines, role, color);
}
}
if !failures.is_empty() {
push_section_color(&mut lines, "failures", color);
for check in &failures {
push_error_bullet_color(
&mut lines,
format!("{}: {}", check.id, check.message),
color,
);
if let Some(fix) = check.fix.as_deref() {
push_action_field_color(&mut lines, "fix", fix, color);
}
}
}
if !warnings.is_empty() && failures.is_empty() {
let other_warnings: Vec<_> = warnings
.iter()
.filter(|c| {
!c.id.ends_with(".bound")
&& !c.id.ends_with(".reachable")
&& !is_expected_unindexed_storage_warning(c)
})
.collect();
if !other_warnings.is_empty() {
push_section_color(&mut lines, "warnings", color);
for check in &other_warnings {
push_warning_bullet_color(
&mut lines,
format!("{}: {}", check.id, check.message),
color,
);
if let Some(fix) = check.fix.as_deref() {
push_action_field_color(&mut lines, "fix", fix, color);
}
}
}
}
if !expected_unindexed_warnings.is_empty() && failures.is_empty() {
push_section_color(&mut lines, "indexing", color);
push_warning_bullet_color(&mut lines, "no collections have been indexed yet", color);
push_section_color(&mut lines, "next", color);
push_action_bullet_color(&mut lines, "kbolt collection add /path/to/docs", color);
push_action_hint_color(
&mut lines,
"or, if collections are already registered",
"kbolt update",
color,
);
}
lines.join("\n")
}
fn is_expected_unindexed_storage_warning(check: &DoctorCheck) -> bool {
check.status == DoctorCheckStatus::Warn
&& matches!(
check.id.as_str(),
"storage.sqlite_readable" | "storage.search_indexes_readable"
)
}
pub fn format_local_report(report: &LocalReport) -> String {
format_local_report_color(report, false)
}
pub fn format_local_report_color(report: &LocalReport, color: bool) -> String {
let mut lines = Vec::new();
let action_label = match report.action {
LocalAction::Setup => "local setup complete",
LocalAction::Start => "local servers started",
LocalAction::Stop => "local servers stopped",
LocalAction::Status => "local servers",
LocalAction::EnableDeep => "deep search enabled",
};
if report.action == LocalAction::Stop || report.ready {
lines.push(action_label.to_string());
} else {
lines.push(format!("{action_label} (not ready)"));
}
if !report.notes.is_empty() {
push_section_color(&mut lines, "notes", color);
for note in &report.notes {
push_bullet_color(&mut lines, note, color);
}
}
let ready_services: Vec<_> = if report.action == LocalAction::Stop {
Vec::new()
} else {
report.services.iter().filter(|s| s.ready).collect()
};
let issue_services: Vec<_> = if report.action == LocalAction::Stop {
report
.services
.iter()
.filter(|s| s.configured && (s.running || s.ready))
.collect()
} else {
report
.services
.iter()
.filter(|s| s.configured && !s.ready)
.collect()
};
let unconfigured_services: Vec<_> = report.services.iter().filter(|s| !s.configured).collect();
if !ready_services.is_empty() {
push_section_color(&mut lines, "ready", color);
for service in &ready_services {
push_bullet_color(
&mut lines,
format!("{} ({})", service.name, service.model),
color,
);
}
}
if !issue_services.is_empty() {
push_section_color(&mut lines, "issues", color);
for service in &issue_services {
let issue = service.issue.as_deref().unwrap_or("not ready");
let detail = if report.action == LocalAction::Setup {
format!(
"{}: {issue} (log: {})",
service.name,
service.log_file.display()
)
} else {
format!("{}: {issue}", service.name)
};
push_warning_bullet_color(&mut lines, detail, color);
}
}
if !unconfigured_services.is_empty() && report.action != LocalAction::Stop {
push_section_color(&mut lines, "not configured", color);
for service in &unconfigured_services {
push_warning_bullet_color(&mut lines, &service.name, color);
}
}
push_section_color(&mut lines, "config", color);
push_identity_bullet_color(&mut lines, report.config_file.display(), color);
if report.action == LocalAction::Setup && report.ready {
push_section_color(&mut lines, "next", color);
push_action_bullet_color(&mut lines, "kbolt collection add /path/to/docs", color);
push_action_bullet_color(&mut lines, "kbolt doctor", color);
}
lines.join("\n")
}
fn format_field(color: bool, label: &str, value: &str) -> String {
format!("{} {value}", paint_label(color, format!("{label}:")))
}
fn push_section(lines: &mut Vec<String>, label: &str) {
push_section_color(lines, label, false);
}
fn push_section_color(lines: &mut Vec<String>, label: &str, color: bool) {
if !lines.is_empty() {
lines.push(String::new());
}
lines.push(paint_section(color, format!("{label}:")));
}
fn push_bullet(lines: &mut Vec<String>, value: impl std::fmt::Display) {
push_bullet_color(lines, value, false);
}
fn push_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
lines.push(format!("{} {value}", paint_chrome(color, "-")));
}
fn push_field(lines: &mut Vec<String>, label: &str, value: impl std::fmt::Display) {
push_field_color(lines, label, value, false);
}
fn push_field_color(
lines: &mut Vec<String>,
label: &str,
value: impl std::fmt::Display,
color: bool,
) {
lines.push(format!(
" {} {value}",
paint_label(color, format!("{label}:"))
));
}
fn append_ignore_content_lines(lines: &mut Vec<String>, content: &str) {
let mut added = false;
for pattern in content.lines().filter(|line| !line.trim().is_empty()) {
push_bullet(lines, pattern);
added = true;
}
if !added {
push_bullet(lines, "none");
}
}
fn paint(color: bool, style: CliStyle, text: impl AsRef<str>) -> String {
paint_cli(color, style, text)
}
pub fn paint_cli(color: bool, style: CliStyle, text: impl AsRef<str>) -> String {
let text = text.as_ref();
if !color || text.is_empty() {
return text.to_string();
}
match ansi_code(style) {
Some(code) => format!("\x1b[{code}m{text}\x1b[0m"),
None => text.to_string(),
}
}
fn paint_title(color: bool, text: impl AsRef<str>) -> String {
paint(color, CliStyle::Title, text)
}
fn paint_chrome(color: bool, text: impl AsRef<str>) -> String {
paint(color, CliStyle::Chrome, text)
}
fn paint_label(color: bool, text: impl AsRef<str>) -> String {
paint(color, CliStyle::Label, text)
}
fn paint_section(color: bool, text: impl AsRef<str>) -> String {
paint(color, CliStyle::Section, text)
}
fn paint_identity(color: bool, text: impl AsRef<str>) -> String {
paint(color, CliStyle::Identity, text)
}
fn paint_action(color: bool, text: impl AsRef<str>) -> String {
paint(color, CliStyle::Action, text)
}
fn paint_warning(color: bool, text: impl AsRef<str>) -> String {
paint(color, CliStyle::Warning, text)
}
fn paint_error(color: bool, text: impl AsRef<str>) -> String {
paint(color, CliStyle::Error, text)
}
fn push_action_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
push_bullet_color(lines, paint_action(color, value.to_string()), color);
}
fn push_action_hint_color(
lines: &mut Vec<String>,
label: &str,
command: impl std::fmt::Display,
color: bool,
) {
push_bullet_color(
lines,
format!("{label}: {}", paint_action(color, command.to_string())),
color,
);
}
fn push_warning_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
push_bullet_color(lines, paint_warning(color, value.to_string()), color);
}
fn push_warning_field_color(
lines: &mut Vec<String>,
label: &str,
value: impl std::fmt::Display,
color: bool,
) {
lines.push(format!(
" {} {}",
paint_label(color, format!("{label}:")),
paint_warning(color, value.to_string())
));
}
fn push_error_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
push_bullet_color(lines, paint_error(color, value.to_string()), color);
}
fn push_error_field_color(
lines: &mut Vec<String>,
label: &str,
value: impl std::fmt::Display,
color: bool,
) {
lines.push(format!(
" {} {}",
paint_label(color, format!("{label}:")),
paint_error(color, value.to_string())
));
}
fn push_identity_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
push_bullet_color(lines, paint_identity(color, value.to_string()), color);
}
fn push_action_field_color(
lines: &mut Vec<String>,
label: &str,
value: impl std::fmt::Display,
color: bool,
) {
lines.push(format!(
" {} {}",
paint_label(color, format!("{label}:")),
paint_action(color, value.to_string())
));
}
fn ansi_code(style: CliStyle) -> Option<&'static str> {
match style {
CliStyle::Title => Some("1"),
CliStyle::Label => None,
CliStyle::Chrome => Some("90"),
CliStyle::Section => Some("1;36"),
CliStyle::Identity => Some("34"),
CliStyle::Action => Some("32"),
CliStyle::Warning => Some("33"),
CliStyle::Error => Some("31"),
}
}
fn format_search_mode(mode: &SearchMode) -> &'static str {
match mode {
SearchMode::Auto => "auto",
SearchMode::Deep => "deep",
SearchMode::Keyword => "keyword",
SearchMode::Semantic => "semantic",
}
}
fn format_search_result_path(space: &str, path: &str) -> String {
format!("{space}/{path}")
}
fn format_space_describe_response(name: &str, color: bool) -> String {
format!("space description updated: {}", paint_identity(color, name))
}
fn format_space_add_response(name: &str, color: bool) -> String {
format!("space added: {}", paint_identity(color, name))
}
fn format_space_rename_response(old: &str, new: &str, color: bool) -> String {
format!(
"space renamed: {} -> {}",
paint_identity(color, old),
paint_identity(color, new)
)
}
fn format_space_remove_response(name: &str, color: bool) -> String {
format!("removed: space {}", paint_identity(color, name))
}
fn format_space_remove_default_response(color: bool) -> String {
format!(
"{}\n{} {}",
format_space_remove_response("default", color),
paint_label(color, "default space:"),
paint_warning(color, "cleared")
)
}
fn format_space_default_response(name: Option<&str>, color: bool) -> String {
match name {
Some(name) => format!("default space: {}", paint_identity(color, name)),
None => "default space: none".to_string(),
}
}
fn format_space_current_response(active: Option<(&str, &str)>, color: bool) -> String {
match active {
Some((name, source)) => format!(
"active space: {} ({})",
paint_identity(color, name),
paint_chrome(color, source)
),
None => "active space: none".to_string(),
}
}
fn group_search_results<'a>(
results: &'a [kbolt_types::SearchResult],
limit: usize,
) -> Vec<GroupedSearchResult<'a>> {
let mut grouped: Vec<GroupedSearchResult<'a>> = Vec::new();
let mut index_by_document: HashMap<(&str, &str), usize> = HashMap::new();
for result in results {
let document_key = (result.space.as_str(), result.path.as_str());
if let Some(index) = index_by_document.get(&document_key).copied() {
grouped[index].additional_matches.push(result);
continue;
}
if grouped.len() >= limit {
continue;
}
let next_index = grouped.len();
index_by_document.insert(document_key, next_index);
grouped.push(GroupedSearchResult {
primary: result,
additional_matches: Vec::new(),
});
}
grouped
}
fn format_additional_match_label(result: &kbolt_types::SearchResult) -> String {
if let Some(heading) = result
.heading
.as_deref()
.map(str::trim)
.filter(|heading| !heading.is_empty())
{
return format!("{heading} (chunk {})", result.chunk.ordinal);
}
format!("chunk {}", result.chunk.ordinal)
}
fn format_compact_chunk_locator(docid: &str, chunk_ordinal: usize) -> String {
format!("{}@{}", docid.trim(), chunk_ordinal)
}
fn full_docid_from_chunk_locator(locator: &str) -> Option<&str> {
locator
.trim()
.trim_start_matches('#')
.split_once('@')
.map(|(docid, _)| docid)
.filter(|docid| !docid.is_empty())
}
#[cfg(test)]
fn format_collection_info(collection: &CollectionInfo) -> String {
format_collection_info_color(collection, false)
}
fn format_collection_info_color(collection: &CollectionInfo, color: bool) -> String {
let mut lines = Vec::new();
lines.push(format!(
"collection: {}/{}",
collection.space, collection.name
));
push_field_color(&mut lines, "path", collection.path.display(), color);
if let Some(description) = collection.description.as_deref() {
if !description.is_empty() {
push_field_color(&mut lines, "description", description, color);
}
}
if let Some(extensions) = collection.extensions.as_ref() {
if !extensions.is_empty() {
push_field_color(&mut lines, "extensions", extensions.join(", "), color);
}
}
push_section_color(&mut lines, "documents", color);
push_bullet_color(
&mut lines,
format!(
"{} active / {} total",
collection.active_document_count, collection.document_count
),
color,
);
push_bullet_color(
&mut lines,
format!("{} chunks", collection.chunk_count),
color,
);
push_bullet_color(
&mut lines,
format!("{} embedded", collection.embedded_chunk_count),
color,
);
push_section_color(&mut lines, "timestamps", color);
push_bullet_color(&mut lines, format!("created {}", collection.created), color);
push_bullet_color(&mut lines, format!("updated {}", collection.updated), color);
lines.join("\n")
}
fn format_document_response(document: &DocumentResponse, color: bool) -> String {
let mut lines = Vec::new();
lines.push(format!(
"{} {}",
paint_label(color, "document:"),
paint_identity(
color,
format_search_result_path(&document.space, &document.path),
)
));
lines.push(format!(
"{} {}",
paint_label(color, "title:"),
paint_title(color, &document.title)
));
lines.push(format!(
"{} {}",
paint_label(color, "docid:"),
paint_identity(color, &document.docid)
));
if document.stale {
lines.push(format!(
"{} {}",
paint_label(color, "status:"),
paint_warning(color, "stale")
));
}
lines.push(String::new());
lines.push(document.content.clone());
lines.join("\n")
}
fn format_chunk_response(chunk: &ChunkResponse, display_locator: &str, color: bool) -> String {
let mut lines = Vec::new();
lines.push(format!(
"{} {}",
paint_title(color, &chunk.title),
paint_identity(color, display_locator)
));
lines.push(format!(
"{} {}",
paint_label(color, "path:"),
paint_identity(color, format_search_result_path(&chunk.space, &chunk.path))
));
if let Some(heading) = chunk.heading.as_deref() {
lines.push(format!("{} {heading}", paint_label(color, "section:")));
}
if chunk.stale {
lines.push(format!(
"{} {}",
paint_label(color, "status:"),
paint_warning(color, "stale")
));
}
lines.push(String::new());
lines.push(chunk.content.clone());
lines.join("\n")
}
#[cfg(test)]
fn format_multi_get_response(response: &MultiGetResponse) -> String {
format_multi_get_response_color(response, false)
}
#[cfg(test)]
fn format_multi_get_response_color(response: &MultiGetResponse, color: bool) -> String {
format_multi_get_response_color_with_chunk_locators(response, color, &HashMap::new())
}
fn format_multi_get_response_color_with_chunk_locators(
response: &MultiGetResponse,
color: bool,
chunk_display_locators: &HashMap<String, String>,
) -> String {
let mut lines = Vec::new();
push_section_color(&mut lines, "items", color);
if response.items.is_empty() {
push_bullet_color(&mut lines, "none returned", color);
if response.resolved_count > 0 {
push_bullet_color(
&mut lines,
format!("{} resolved", response.resolved_count),
color,
);
}
} else {
push_bullet_color(
&mut lines,
format!("{} returned", response.items.len()),
color,
);
if response.resolved_count > response.items.len() {
push_bullet_color(
&mut lines,
format!("{} resolved", response.resolved_count),
color,
);
}
for (index, item) in response.items.iter().enumerate() {
lines.push(String::new());
append_multi_get_item_lines(&mut lines, index + 1, item, color, chunk_display_locators);
lines.push(String::new());
}
}
if !response.omitted.is_empty() {
push_section_color(&mut lines, "omitted", color);
for omitted in &response.omitted {
push_warning_bullet_color(
&mut lines,
format_omitted_item(omitted, chunk_display_locators),
color,
);
}
}
if !response.warnings.is_empty() {
push_section_color(&mut lines, "warnings", color);
for warning in &response.warnings {
push_warning_bullet_color(&mut lines, warning, color);
}
}
lines.join("\n")
}
fn append_multi_get_item_lines(
lines: &mut Vec<String>,
index: usize,
item: &MultiGetItem,
color: bool,
chunk_display_locators: &HashMap<String, String>,
) {
match item {
MultiGetItem::Document(document) => {
lines.push(format!(
"{}. {}",
index,
format_search_result_path(&document.space, &document.path)
));
lines.push(format!(
" {} {}",
paint_label(color, "title:"),
document.title
));
lines.push(format!(
" {} {}",
paint_label(color, "docid:"),
paint_identity(color, &document.docid)
));
if document.stale {
lines.push(format!(
" {} {}",
paint_label(color, "status:"),
paint_warning(color, "stale")
));
}
append_plain_content_lines(lines, &document.content);
}
MultiGetItem::Chunk(chunk) => {
let display_locator = chunk_display_locators
.get(&chunk.locator)
.map(String::as_str)
.unwrap_or(&chunk.locator);
lines.push(format!(
"{}. {}",
index,
format_search_result_path(&chunk.space, &chunk.path)
));
lines.push(format!(
" {} {}",
paint_label(color, "title:"),
chunk.title
));
lines.push(format!(
" {} {}",
paint_label(color, "locator:"),
paint_identity(color, display_locator)
));
if let Some(heading) = chunk.heading.as_deref() {
lines.push(format!(" {} {}", paint_label(color, "section:"), heading));
}
if chunk.stale {
lines.push(format!(
" {} {}",
paint_label(color, "status:"),
paint_warning(color, "stale")
));
}
append_plain_content_lines(lines, &chunk.content);
}
}
}
fn append_plain_content_lines(lines: &mut Vec<String>, content: &str) {
lines.push(String::new());
for content_line in content.lines() {
lines.push(content_line.to_string());
}
if content.is_empty() {
lines.push(String::new());
}
}
fn format_omitted_item(
omitted: &kbolt_types::OmittedItem,
chunk_display_locators: &HashMap<String, String>,
) -> String {
match omitted.kind {
MultiGetItemKind::Document => format!(
"{} ({}, {})",
omitted.path,
format_bytes_human(omitted.size_bytes as u64),
format_omit_reason(&omitted.reason)
),
MultiGetItemKind::Chunk => {
let display_locator = chunk_display_locators
.get(&omitted.locator)
.map(String::as_str)
.unwrap_or(&omitted.locator);
format!(
"{} in {} ({}, {})",
display_locator,
omitted.path,
format_bytes_human(omitted.size_bytes as u64),
format_omit_reason(&omitted.reason)
)
}
}
}
#[cfg(test)]
fn format_models_list(status: &kbolt_types::ModelStatus) -> String {
format_models_list_color(status, false)
}
fn format_models_list_color(status: &kbolt_types::ModelStatus, color: bool) -> String {
let mut lines = Vec::new();
push_section_color(&mut lines, "models", color);
append_model_status_lines(&mut lines, "embedder", &status.embedder, color);
append_model_status_lines(&mut lines, "reranker", &status.reranker, color);
append_model_status_lines(&mut lines, "expander", &status.expander, color);
lines.join("\n")
}
#[cfg(test)]
fn format_status_response(status: &StatusResponse, active_space: Option<&str>) -> String {
format_status_response_color(status, active_space, false)
}
fn format_status_response_color(
status: &StatusResponse,
active_space: Option<&str>,
color: bool,
) -> String {
let mut lines = Vec::new();
push_section_color(&mut lines, "spaces", color);
if status.spaces.is_empty() {
push_bullet_color(&mut lines, "none", color);
} else {
for space in &status.spaces {
let active_suffix = if Some(space.name.as_str()) == active_space {
" (active)"
} else {
""
};
push_bullet_color(
&mut lines,
format!("{}{}", space.name, active_suffix),
color,
);
if let Some(description) = space.description.as_deref() {
if !description.is_empty() {
push_field_color(&mut lines, "description", description, color);
}
}
if let Some(last_updated) = space.last_updated.as_deref() {
push_field_color(&mut lines, "updated", last_updated, color);
}
if space.collections.is_empty() {
push_field_color(&mut lines, "collections", "none", color);
} else {
lines.push(format!(" {}", paint_section(color, "collections:")));
for collection in &space.collections {
lines.push(format!(
" {} {}",
paint_chrome(color, "-"),
collection.name
));
lines.push(format!(
" {} {}",
paint_label(color, "path:"),
collection.path.display()
));
lines.push(format!(
" {} {} active / {} total",
paint_label(color, "documents:"),
collection.active_documents,
collection.documents
));
lines.push(format!(
" {} {}",
paint_label(color, "chunks:"),
collection.chunks
));
lines.push(format!(
" {} {}",
paint_label(color, "embedded:"),
collection.embedded_chunks
));
lines.push(format!(
" {} {}",
paint_label(color, "updated:"),
collection.last_updated
));
}
}
}
}
push_section_color(&mut lines, "totals", color);
push_bullet_color(
&mut lines,
format!("documents: {}", status.total_documents),
color,
);
push_bullet_color(
&mut lines,
format!("chunks: {}", status.total_chunks),
color,
);
push_bullet_color(
&mut lines,
format!("embedded: {}", status.total_embedded),
color,
);
push_section_color(&mut lines, "storage", color);
push_bullet_color(
&mut lines,
format!(
"sqlite: {}",
format_bytes_human(status.disk_usage.sqlite_bytes)
),
color,
);
push_bullet_color(
&mut lines,
format!(
"tantivy: {}",
format_bytes_human(status.disk_usage.tantivy_bytes)
),
color,
);
push_bullet_color(
&mut lines,
format!(
"vectors: {}",
format_bytes_human(status.disk_usage.usearch_bytes)
),
color,
);
push_bullet_color(
&mut lines,
format!(
"models: {}",
format_bytes_human(status.disk_usage.models_bytes)
),
color,
);
push_bullet_color(
&mut lines,
format!(
"total: {}",
format_bytes_human(status.disk_usage.total_bytes)
),
color,
);
push_section_color(&mut lines, "models", color);
append_model_status_lines(&mut lines, "embedder", &status.models.embedder, color);
append_model_status_lines(&mut lines, "reranker", &status.models.reranker, color);
append_model_status_lines(&mut lines, "expander", &status.models.expander, color);
push_section_color(&mut lines, "paths", color);
push_bullet_color(
&mut lines,
format!("cache: {}", status.cache_dir.display()),
color,
);
push_bullet_color(
&mut lines,
format!("config: {}", status.config_dir.display()),
color,
);
lines.join("\n")
}
#[cfg(test)]
fn format_file_list(files: &[FileEntry], all: bool) -> String {
format_file_list_color(files, all, false)
}
fn format_file_list_color(files: &[FileEntry], all: bool, color: bool) -> String {
let mut lines = Vec::new();
push_section_color(&mut lines, "files", color);
if files.is_empty() {
push_bullet_color(&mut lines, "none", color);
return lines.join("\n");
}
for file in files {
push_bullet_color(&mut lines, format_file_entry_color(file, all, color), color);
}
lines.join("\n")
}
fn format_file_entry_color(file: &FileEntry, all: bool, color: bool) -> String {
let path = paint_identity(color, &file.path);
let status = if file.embedded {
paint_chrome(color, "embedded")
} else {
paint_warning(color, "not embedded")
};
if all && !file.active {
format!(
"{} ({}, {} chunk(s), {})",
path,
paint_warning(color, "inactive"),
file.chunk_count,
status
)
} else {
format!("{} ({} chunk(s), {})", path, file.chunk_count, status)
}
}
fn append_model_status_lines(lines: &mut Vec<String>, label: &str, info: &ModelInfo, color: bool) {
let mut summary = format!("{}: {}", label, format_model_state_label(info, color));
if let Some(model) = info.model.as_deref() {
summary.push_str(&format!(" ({model})"));
}
push_bullet_color(lines, summary, color);
if info.configured && !info.ready {
if let Some(issue) = info.issue.as_deref() {
push_warning_field_color(lines, "issue", issue, color);
}
}
}
fn format_model_state_label(info: &ModelInfo, color: bool) -> String {
let label = model_state_label(info);
if info.ready {
label.to_string()
} else {
paint_warning(color, label)
}
}
fn model_state_label(info: &ModelInfo) -> &'static str {
if !info.configured {
"not configured"
} else if info.ready {
"ready"
} else {
"not ready"
}
}
fn format_bytes_human(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
if bytes < 1024 {
return format!("{bytes} B");
}
let mut value = bytes as f64;
let mut unit_index = 0usize;
while value >= 1024.0 && unit_index < UNITS.len() - 1 {
value /= 1024.0;
unit_index += 1;
}
if value >= 10.0 {
format!("{value:.0} {}", UNITS[unit_index])
} else {
format!("{value:.1} {}", UNITS[unit_index])
}
}
fn format_omit_reason(reason: &OmitReason) -> &'static str {
match reason {
OmitReason::MaxFiles => "max files",
OmitReason::MaxBytes => "size limit",
}
}
fn active_space_name_for_status(engine: &Engine, explicit: Option<&str>) -> Option<String> {
if let Some(space_name) = explicit {
return Some(space_name.to_string());
}
if let Ok(space_name) = std::env::var("KBOLT_SPACE") {
let trimmed = space_name.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
engine.config().default_space.clone()
}
fn format_optional_search_signal(value: Option<f32>) -> String {
value
.map(format_search_signal)
.unwrap_or_else(|| "-".to_string())
}
fn format_search_signal(value: f32) -> String {
format!("{value:.2}")
}
fn format_search_pipeline(pipeline: &SearchPipeline) -> String {
let mut parts = Vec::new();
if pipeline.expansion {
parts.push("expansion");
}
if pipeline.keyword {
parts.push("keyword");
}
if pipeline.dense {
parts.push("dense");
}
if pipeline.rerank {
parts.push("rerank");
}
if parts.is_empty() {
"none".to_string()
} else {
parts.join(" + ")
}
}
fn format_search_pipeline_notice(notice: &SearchPipelineNotice) -> String {
let step = match notice.step {
SearchPipelineStep::Dense => "dense retrieval",
SearchPipelineStep::Rerank => "rerank",
};
let reason = match notice.reason {
SearchPipelineUnavailableReason::NotConfigured => "not configured",
SearchPipelineUnavailableReason::ModelNotAvailable => "required provider is not ready",
};
format!("{step} unavailable: {reason}")
}
fn format_normal_search_pipeline_notice(
notice: &SearchPipelineNotice,
effective_mode: &SearchMode,
) -> &'static str {
match notice.step {
SearchPipelineStep::Dense if effective_mode == &SearchMode::Keyword => {
"keyword only (dense unavailable)"
}
SearchPipelineStep::Dense => "dense unavailable",
SearchPipelineStep::Rerank => "rerank skipped",
}
}
fn format_normal_search_pipeline_notice_color(
notice: &SearchPipelineNotice,
effective_mode: &SearchMode,
color: bool,
) -> String {
paint_warning(
color,
format_normal_search_pipeline_notice(notice, effective_mode),
)
}
#[cfg(test)]
fn format_update_report(report: &UpdateReport, verbose: bool, no_embed: bool) -> String {
format_update_report_color(report, verbose, no_embed, false)
}
fn format_update_report_color(
report: &UpdateReport,
verbose: bool,
no_embed: bool,
color: bool,
) -> String {
let mut lines = Vec::new();
if verbose {
if !report.decisions.is_empty() {
push_section_color(&mut lines, "decisions", color);
for decision in &report.decisions {
push_bullet_color(&mut lines, format_update_decision(decision), color);
}
}
let unreported_errors = unreported_update_errors(report);
if !unreported_errors.is_empty() {
push_section_color(&mut lines, "errors", color);
for error in unreported_errors {
push_error_bullet_color(
&mut lines,
format!("{}: {}", error.path, error.error),
color,
);
}
}
}
if !lines.is_empty() {
lines.push(String::new());
}
lines.push("update complete".to_string());
append_update_summary_lines(&mut lines, report, no_embed, color);
if !report.errors.is_empty() && !verbose {
let truncated = append_update_error_lines_color(&mut lines, report, 3, color);
if truncated {
push_section_color(&mut lines, "next", color);
push_action_bullet_color(
&mut lines,
"run with --verbose for the full error list",
color,
);
}
}
lines.join("\n")
}
fn format_collection_describe_response(name: &str, color: bool) -> String {
format!(
"collection description updated: {}",
paint_identity(color, name)
)
}
fn format_collection_rename_response(old: &str, new: &str, color: bool) -> String {
format!(
"collection renamed: {} -> {}",
paint_identity(color, old),
paint_identity(color, new)
)
}
fn format_collection_remove_response(space: &str, name: &str, color: bool) -> String {
format!(
"removed: collection {}\n{} {}",
paint_identity(color, name),
paint_label(color, "space:"),
paint_identity(color, space)
)
}
#[cfg(test)]
fn format_collection_add_result(result: &AddCollectionResult) -> String {
format_collection_add_result_color(result, false)
}
fn format_collection_add_result_color(result: &AddCollectionResult, color: bool) -> String {
let collection = &result.collection;
let locator = format!("{}/{}", collection.space, collection.name);
match &result.initial_indexing {
InitialIndexingOutcome::Skipped => {
let mut lines = vec![format!("collection added: {locator}")];
push_section_color(&mut lines, "indexing", color);
push_warning_bullet_color(&mut lines, "skipped (--no-index)", color);
push_section_color(&mut lines, "next", color);
push_action_bullet_color(
&mut lines,
format!(
"kbolt --space {} update --collection {}",
shell_quote_arg(&collection.space),
shell_quote_arg(&collection.name),
),
color,
);
lines.join("\n")
}
InitialIndexingOutcome::Indexed(report) => {
format_collection_add_indexing_report(collection, &locator, report, color)
}
InitialIndexingOutcome::Blocked(block) => {
format_collection_add_block(collection, &locator, block, color)
}
}
}
fn format_collection_add_indexing_report(
collection: &kbolt_types::CollectionInfo,
locator: &str,
report: &UpdateReport,
color: bool,
) -> String {
let mut lines = Vec::new();
if report.failed_docs == 0 {
lines.push(format!("collection added and indexed: {locator}"));
} else {
lines.push(format!("collection added: {locator}"));
lines.push("initial indexing incomplete".to_string());
}
append_update_summary_lines(&mut lines, report, false, color);
let truncated = if !report.errors.is_empty() {
append_update_error_lines_color(&mut lines, report, 3, color)
} else {
false
};
if report.failed_docs > 0 || truncated {
push_section_color(&mut lines, "next", color);
let verbose_flag = if truncated { " --verbose" } else { "" };
push_action_bullet_color(
&mut lines,
format!(
"kbolt --space {} update{verbose_flag} --collection {}",
shell_quote_arg(&collection.space),
shell_quote_arg(&collection.name),
),
color,
);
}
lines.join("\n")
}
fn format_collection_add_block(
collection: &kbolt_types::CollectionInfo,
locator: &str,
block: &InitialIndexingBlock,
color: bool,
) -> String {
let mut lines = Vec::new();
lines.push(format!("collection added: {locator}"));
match block {
InitialIndexingBlock::SpaceDenseRepairRequired { space, reason } => {
lines.push(format!(
"indexing blocked by a dense integrity issue in space '{space}'"
));
push_field_color(&mut lines, "reason", reason, color);
push_section_color(&mut lines, "next", color);
push_action_bullet_color(
&mut lines,
format!("kbolt --space {} update", shell_quote_arg(space)),
color,
);
}
InitialIndexingBlock::ModelNotAvailable { name } => {
lines.push(format!("indexing blocked: model '{name}' is not available"));
push_section_color(&mut lines, "next", color);
push_action_bullet_color(&mut lines, "kbolt setup local", color);
push_bullet_color(
&mut lines,
"or configure [roles.embedder] in index.toml",
color,
);
push_action_hint_color(
&mut lines,
"then run",
format!(
"kbolt --space {} update --collection {}",
shell_quote_arg(&collection.space),
shell_quote_arg(&collection.name),
),
color,
);
}
}
lines.join("\n")
}
#[cfg(test)]
fn append_update_error_lines(lines: &mut Vec<String>, report: &UpdateReport, limit: usize) -> bool {
append_update_error_lines_color(lines, report, limit, false)
}
fn append_update_error_lines_color(
lines: &mut Vec<String>,
report: &UpdateReport,
limit: usize,
color: bool,
) -> bool {
push_section_color(lines, "errors", color);
for error in report.errors.iter().take(limit) {
push_error_bullet_color(lines, format!("{}: {}", error.path, error.error), color);
}
let truncated = report.errors.len() > limit;
if truncated {
push_error_bullet_color(
lines,
format!("{} more error(s)", report.errors.len() - limit),
color,
);
}
truncated
}
fn append_update_summary_lines(
lines: &mut Vec<String>,
report: &UpdateReport,
no_embed: bool,
color: bool,
) {
push_section_color(lines, "summary", color);
push_bullet_color(
lines,
format!("{} document(s) scanned", report.scanned_docs),
color,
);
let unchanged = report.skipped_mtime_docs + report.skipped_hash_docs;
if unchanged > 0 {
push_bullet_color(lines, format!("{} unchanged", unchanged), color);
}
if report.added_docs > 0 {
push_bullet_color(lines, format!("{} added", report.added_docs), color);
}
if report.updated_docs > 0 {
push_bullet_color(lines, format!("{} updated", report.updated_docs), color);
}
if report.failed_docs > 0 {
push_error_bullet_color(lines, format!("{} failed", report.failed_docs), color);
}
if report.deactivated_docs > 0 {
push_bullet_color(
lines,
format!("{} deactivated", report.deactivated_docs),
color,
);
}
if report.reactivated_docs > 0 {
push_bullet_color(
lines,
format!("{} reactivated", report.reactivated_docs),
color,
);
}
if report.reaped_docs > 0 {
push_bullet_color(lines, format!("{} reaped", report.reaped_docs), color);
}
if report.embedded_chunks > 0 {
push_bullet_color(
lines,
format!("{} chunk(s) embedded", report.embedded_chunks),
color,
);
}
if no_embed {
push_warning_bullet_color(lines, "embedding skipped (--no-embed)", color);
}
push_bullet_color(
lines,
format!("completed in {}", format_elapsed_ms(report.elapsed_ms)),
color,
);
}
fn format_elapsed_ms(elapsed_ms: u64) -> String {
if elapsed_ms < 1_000 {
format!("{elapsed_ms}ms")
} else if elapsed_ms < 60_000 {
format!("{:.1}s", elapsed_ms as f64 / 1_000.0)
} else {
let total_seconds = elapsed_ms as f64 / 1_000.0;
format!("{:.1}m", total_seconds / 60.0)
}
}
fn format_update_decision(decision: &UpdateDecision) -> String {
let locator = format!(
"{}/{}/{}",
decision.space, decision.collection, decision.path
);
match decision.detail.as_deref() {
Some(detail) => format!(
"{locator}: {} ({detail})",
format_update_decision_kind(&decision.kind)
),
None => format!("{locator}: {}", format_update_decision_kind(&decision.kind)),
}
}
fn format_update_decision_kind(kind: &UpdateDecisionKind) -> &'static str {
match kind {
UpdateDecisionKind::New => "new",
UpdateDecisionKind::Changed => "changed",
UpdateDecisionKind::SkippedMtime => "skipped_mtime",
UpdateDecisionKind::SkippedHash => "skipped_hash",
UpdateDecisionKind::Ignored => "ignored",
UpdateDecisionKind::Unsupported => "unsupported",
UpdateDecisionKind::ReadFailed => "read_failed",
UpdateDecisionKind::ExtractFailed => "extract_failed",
UpdateDecisionKind::Reactivated => "reactivated",
UpdateDecisionKind::Deactivated => "deactivated",
}
}
fn unreported_update_errors(report: &UpdateReport) -> Vec<&kbolt_types::FileError> {
report
.errors
.iter()
.filter(|error| {
!report.decisions.iter().any(|decision| {
matches!(
decision.kind,
UpdateDecisionKind::ReadFailed | UpdateDecisionKind::ExtractFailed
) && std::path::Path::new(&error.path)
.ends_with(std::path::Path::new(&decision.path))
})
})
.collect()
}
pub fn resolve_no_rerank_for_mode(mode: SearchMode, rerank: bool, no_rerank: bool) -> bool {
match mode {
SearchMode::Auto => !rerank,
SearchMode::Deep => no_rerank,
SearchMode::Keyword | SearchMode::Semantic => true,
}
}
fn truncate_snippet(text: &str, max_lines: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
if lines.len() <= max_lines {
return text.to_string();
}
let truncated: Vec<&str> = lines[..max_lines].to_vec();
let remaining = lines.len() - max_lines;
format!(
"{}\n(+{remaining} more line{})",
truncated.join("\n"),
if remaining == 1 { "" } else { "s" }
)
}
fn resolve_editor_command() -> Result<Vec<String>> {
let raw = std::env::var("VISUAL")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
std::env::var("EDITOR")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| "vi".to_string());
parse_editor_command(&raw)
}
fn parse_editor_command(raw: &str) -> Result<Vec<String>> {
let args = shell_words::split(raw).map_err(|err| {
KboltError::InvalidInput(format!("invalid editor command '{raw}': {err}"))
})?;
if args.is_empty() {
return Err(KboltError::InvalidInput("editor command cannot be empty".to_string()).into());
}
Ok(args)
}
#[cfg(test)]
fn format_schedule_add_response(response: &ScheduleAddResponse) -> String {
format_schedule_add_response_color(response, false)
}
fn format_schedule_add_response_color(response: &ScheduleAddResponse, color: bool) -> String {
let mut lines = Vec::new();
lines.push(format!("schedule added: {}", response.schedule.id));
push_section_color(&mut lines, "details", color);
push_bullet_color(
&mut lines,
format!(
"trigger: {}",
format_schedule_trigger(&response.schedule.trigger)
),
color,
);
push_bullet_color(
&mut lines,
format!("scope: {}", format_schedule_scope(&response.schedule.scope)),
color,
);
push_bullet_color(
&mut lines,
format!("backend: {}", format_schedule_backend(response.backend)),
color,
);
lines.join("\n")
}
#[cfg(test)]
fn format_schedule_status_response(response: &ScheduleStatusResponse) -> String {
format_schedule_status_response_color(response, false)
}
fn format_schedule_status_response_color(response: &ScheduleStatusResponse, color: bool) -> String {
let mut lines = Vec::new();
push_section_color(&mut lines, "schedules", color);
if response.schedules.is_empty() {
push_bullet_color(&mut lines, "none", color);
} else {
for entry in &response.schedules {
push_bullet_color(&mut lines, &entry.schedule.id, color);
push_field_color(
&mut lines,
"trigger",
format_schedule_trigger(&entry.schedule.trigger),
color,
);
push_field_color(
&mut lines,
"scope",
format_schedule_scope(&entry.schedule.scope),
color,
);
push_field_color(
&mut lines,
"backend",
format_schedule_backend(entry.backend),
color,
);
push_field_color(
&mut lines,
"state",
format_schedule_state_color(entry.state, color),
color,
);
push_field_color(
&mut lines,
"last started",
entry.run_state.last_started.as_deref().unwrap_or("never"),
color,
);
push_field_color(
&mut lines,
"last finished",
entry.run_state.last_finished.as_deref().unwrap_or("never"),
color,
);
push_field_color(
&mut lines,
"last result",
format_schedule_run_result_color(entry.run_state.last_result, color),
color,
);
if let Some(error) = entry.run_state.last_error.as_deref() {
push_error_field_color(&mut lines, "last error", error, color);
}
}
}
push_section_color(&mut lines, "orphans", color);
if response.orphans.is_empty() {
push_bullet_color(&mut lines, "none", color);
} else {
for orphan in &response.orphans {
push_warning_bullet_color(
&mut lines,
format!(
"{} ({})",
orphan.id,
format_schedule_backend(orphan.backend)
),
color,
);
}
}
lines.join("\n")
}
#[cfg(test)]
fn format_schedule_remove_response(
selector: &RemoveScheduleSelector,
response: &kbolt_types::ScheduleRemoveResponse,
) -> String {
format_schedule_remove_response_color(selector, response, false)
}
fn format_schedule_remove_response_color(
selector: &RemoveScheduleSelector,
response: &kbolt_types::ScheduleRemoveResponse,
color: bool,
) -> String {
let mut lines = Vec::with_capacity(3);
match selector {
RemoveScheduleSelector::Id { id } => {
let removed = response.removed_ids.first().map_or(id, |removed| removed);
if response.removed_ids.is_empty() {
lines.push("removed: no schedule".to_string());
lines.push(format!(
"{} {}",
paint_label(color, "schedule:"),
paint_identity(color, removed)
));
} else {
lines.push(format!(
"removed: schedule {}",
paint_identity(color, removed)
));
}
}
RemoveScheduleSelector::All => {
let summary = if response.removed_ids.is_empty() {
"removed: no schedules"
} else {
"removed: all schedules"
};
lines.push(summary.to_string());
lines.push(format!("{} all spaces", paint_label(color, "scope:")));
}
RemoveScheduleSelector::Scope { scope } => {
let summary = if response.removed_ids.is_empty() {
"removed: no schedules"
} else {
"removed: schedules"
};
lines.push(summary.to_string());
append_schedule_scope_lines(&mut lines, scope, color);
}
}
lines.join("\n")
}
fn append_schedule_scope_lines(lines: &mut Vec<String>, scope: &ScheduleScope, color: bool) {
match scope {
ScheduleScope::All => lines.push(format!("{} all spaces", paint_label(color, "scope:"))),
ScheduleScope::Space { space } => lines.push(format!(
"{} {}",
paint_label(color, "space:"),
paint_identity(color, space)
)),
ScheduleScope::Collections { space, collections } => {
lines.push(format!(
"{} {}",
paint_label(color, "space:"),
paint_identity(color, space)
));
let collection_names = collections
.iter()
.map(|collection| paint_identity(color, collection))
.collect::<Vec<_>>()
.join(", ");
lines.push(format!(
"{} {collection_names}",
paint_label(color, "collections:")
));
}
}
}
fn format_schedule_trigger(trigger: &ScheduleTrigger) -> String {
match trigger {
ScheduleTrigger::Every { interval } => format_schedule_interval(interval),
ScheduleTrigger::Daily { time } => format!("daily at {}", format_schedule_time(time)),
ScheduleTrigger::Weekly { weekdays, time } => format!(
"{} at {}",
format_schedule_weekdays(weekdays),
format_schedule_time(time)
),
}
}
fn format_schedule_interval(interval: &ScheduleInterval) -> String {
let suffix = match interval.unit {
ScheduleIntervalUnit::Minutes => "m",
ScheduleIntervalUnit::Hours => "h",
};
format!("every {}{suffix}", interval.value)
}
fn format_schedule_scope(scope: &ScheduleScope) -> String {
match scope {
ScheduleScope::All => "all spaces".to_string(),
ScheduleScope::Space { space } => format!("space {space}"),
ScheduleScope::Collections { space, collections } => collections
.iter()
.map(|collection| format!("{space}/{collection}"))
.collect::<Vec<_>>()
.join(", "),
}
}
fn format_schedule_backend(backend: ScheduleBackend) -> &'static str {
match backend {
ScheduleBackend::Launchd => "launchd",
ScheduleBackend::SystemdUser => "systemd-user",
}
}
fn format_schedule_state(state: ScheduleState) -> &'static str {
match state {
ScheduleState::Installed => "installed",
ScheduleState::Drifted => "drifted",
ScheduleState::TargetMissing => "target missing",
}
}
fn format_schedule_state_color(state: ScheduleState, color: bool) -> String {
let label = format_schedule_state(state);
match state {
ScheduleState::Installed => label.to_string(),
ScheduleState::Drifted | ScheduleState::TargetMissing => paint_warning(color, label),
}
}
fn format_schedule_run_result(result: Option<ScheduleRunResult>) -> &'static str {
match result {
Some(ScheduleRunResult::Success) => "success",
Some(ScheduleRunResult::SkippedLock) => "skipped lock",
Some(ScheduleRunResult::Failed) => "failed",
None => "never",
}
}
fn format_schedule_run_result_color(result: Option<ScheduleRunResult>, color: bool) -> String {
let label = format_schedule_run_result(result);
match result {
Some(ScheduleRunResult::SkippedLock) => paint_warning(color, label),
Some(ScheduleRunResult::Failed) => paint_error(color, label),
Some(ScheduleRunResult::Success) | None => label.to_string(),
}
}
fn format_schedule_weekdays(weekdays: &[ScheduleWeekday]) -> String {
weekdays
.iter()
.map(|weekday| match weekday {
ScheduleWeekday::Mon => "mon",
ScheduleWeekday::Tue => "tue",
ScheduleWeekday::Wed => "wed",
ScheduleWeekday::Thu => "thu",
ScheduleWeekday::Fri => "fri",
ScheduleWeekday::Sat => "sat",
ScheduleWeekday::Sun => "sun",
})
.collect::<Vec<_>>()
.join(", ")
}
fn format_schedule_time(time: &str) -> String {
let Some((hour, minute)) = time.split_once(':') else {
return time.to_string();
};
let Ok(mut hour) = hour.parse::<u32>() else {
return time.to_string();
};
let Ok(minute) = minute.parse::<u32>() else {
return time.to_string();
};
let meridiem = if hour >= 12 { "PM" } else { "AM" };
if hour == 0 {
hour = 12;
} else if hour > 12 {
hour -= 12;
}
format!("{hour}:{minute:02} {meridiem}")
}
#[cfg(test)]
fn format_eval_run_report(report: &EvalRunReport) -> String {
format_eval_run_report_color(report, false)
}
fn format_eval_run_report_color(report: &EvalRunReport, color: bool) -> String {
let mut lines = vec!["eval complete".to_string()];
push_section_color(&mut lines, "summary", color);
push_bullet_color(&mut lines, format!("{} case(s)", report.total_cases), color);
push_section_color(&mut lines, "modes", color);
for mode in &report.modes {
push_bullet_color(
&mut lines,
format_eval_mode_label(&mode.mode, mode.no_rerank),
color,
);
push_field_color(
&mut lines,
"nDCG@10",
format!("{:.3}", mode.ndcg_at_10),
color,
);
push_field_color(
&mut lines,
"Recall@10",
format!("{:.3}", mode.recall_at_10),
color,
);
push_field_color(
&mut lines,
"MRR@10",
format!("{:.3}", mode.mrr_at_10),
color,
);
push_field_color(
&mut lines,
"p50",
format!("{}ms", mode.latency_p50_ms),
color,
);
push_field_color(
&mut lines,
"p95",
format!("{}ms", mode.latency_p95_ms),
color,
);
}
if !report.failed_modes.is_empty() {
push_section_color(&mut lines, "failed", color);
for failure in &report.failed_modes {
push_error_bullet_color(
&mut lines,
format!(
"{}: {}",
format_eval_mode_label(&failure.mode, failure.no_rerank),
failure.error
),
color,
);
}
}
push_section_color(&mut lines, "attention", color);
let mut has_findings = false;
for mode in &report.modes {
for query in &mode.queries {
let perfect_recall = query.matched_paths.len() == relevant_judgment_count(query);
let perfect_rank = query.first_relevant_rank == Some(1);
if perfect_recall && perfect_rank {
continue;
}
has_findings = true;
push_bullet_color(
&mut lines,
format!(
"{}: {}",
format_eval_mode_label(&mode.mode, mode.no_rerank),
query.query
),
color,
);
push_field_color(
&mut lines,
"first relevant",
query
.first_relevant_rank
.map(|rank| rank.to_string())
.unwrap_or_else(|| "none".to_string()),
color,
);
push_field_color(
&mut lines,
"expected",
format_eval_judgments(&query.judgments),
color,
);
push_field_color(
&mut lines,
"returned",
if query.returned_paths.is_empty() {
"none".to_string()
} else {
query.returned_paths.join(", ")
},
color,
);
}
}
if !has_findings {
push_bullet_color(&mut lines, "none", color);
}
lines.join("\n")
}
pub fn format_eval_import_report(report: &EvalImportReport) -> String {
format_eval_import_report_color(report, false)
}
pub fn format_eval_import_report_color(report: &EvalImportReport, color: bool) -> String {
let mut lines = Vec::new();
lines.push(format!("benchmark imported: {}", report.dataset));
push_section_color(&mut lines, "paths", color);
push_bullet_color(
&mut lines,
format!("source: {}", paint_identity(color, &report.source)),
color,
);
push_bullet_color(
&mut lines,
format!("output: {}", paint_identity(color, &report.output_dir)),
color,
);
push_bullet_color(
&mut lines,
format!("corpus: {}", paint_identity(color, &report.corpus_dir)),
color,
);
push_bullet_color(
&mut lines,
format!("manifest: {}", paint_identity(color, &report.manifest_path)),
color,
);
push_section_color(&mut lines, "contents", color);
push_bullet_color(
&mut lines,
format!("{} document(s)", report.document_count),
color,
);
push_bullet_color(
&mut lines,
format!("{} query(s)", report.query_count),
color,
);
push_bullet_color(
&mut lines,
format!("{} judgment(s)", report.judgment_count),
color,
);
push_section_color(&mut lines, "next", color);
push_action_hint_color(
&mut lines,
"create the benchmark space if needed",
format!("kbolt space add {}", shell_quote_arg(&report.default_space)),
color,
);
push_action_hint_color(
&mut lines,
"register the corpus",
format!(
"kbolt --space {} collection add {} --name {} --no-index",
shell_quote_arg(&report.default_space),
shell_quote_arg(&report.corpus_dir),
shell_quote_arg(&report.collection),
),
color,
);
push_action_hint_color(
&mut lines,
"index it",
format!(
"kbolt --space {} update --collection {}",
shell_quote_arg(&report.default_space),
shell_quote_arg(&report.collection),
),
color,
);
push_action_hint_color(
&mut lines,
"run eval",
format!(
"kbolt eval run --file {}",
shell_quote_arg(&report.manifest_path)
),
color,
);
lines.join("\n")
}
fn relevant_judgment_count(query: &kbolt_types::EvalQueryReport) -> usize {
query
.judgments
.iter()
.filter(|judgment| judgment.relevance > 0)
.count()
}
fn format_eval_judgments(judgments: &[kbolt_types::EvalJudgment]) -> String {
judgments
.iter()
.map(|judgment| format!("{}(rel={})", judgment.path, judgment.relevance))
.collect::<Vec<_>>()
.join(", ")
}
fn format_eval_mode_label(mode: &SearchMode, no_rerank: bool) -> &'static str {
match (mode, no_rerank) {
(SearchMode::Keyword, _) => "keyword",
(SearchMode::Auto, true) => "auto",
(SearchMode::Auto, false) => "auto+rerank",
(SearchMode::Semantic, _) => "semantic",
(SearchMode::Deep, true) => "deep-norerank",
(SearchMode::Deep, false) => "deep",
}
}
fn shell_quote_arg(arg: &str) -> String {
let is_safe = !arg.is_empty()
&& arg.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '-' | '_' | '.' | '/' | ',' | ':' | '+' | '@' | '=')
});
if is_safe {
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', "'\\''"))
}
}
fn format_chunk_read_command(space: &str, locator: &str) -> String {
format!(
"kbolt --space {} get {}",
shell_quote_arg(space),
shell_quote_arg(locator)
)
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use std::{
fs,
path::{Path, PathBuf},
};
use tempfile::tempdir;
use super::{
active_space_name_for_status, ansi_code, append_update_error_lines,
format_additional_match_label, format_chunk_read_command, format_chunk_response,
format_collection_add_result, format_collection_add_result_color,
format_collection_describe_response, format_collection_info,
format_collection_remove_response, format_collection_rename_response,
format_compact_chunk_locator, format_doctor_report, format_document_response,
format_elapsed_ms, format_eval_import_report, format_eval_import_report_color,
format_eval_run_report, format_eval_run_report_color, format_file_list,
format_file_list_color, format_local_report, format_models_list, format_models_list_color,
format_multi_get_response, format_multi_get_response_color,
format_multi_get_response_color_with_chunk_locators, format_optional_search_signal,
format_schedule_add_response, format_schedule_remove_response,
format_schedule_remove_response_color, format_schedule_status_response,
format_schedule_status_response_color, format_search_result_path,
format_space_add_response, format_space_current_response, format_space_default_response,
format_space_describe_response, format_space_remove_default_response,
format_space_remove_response, format_space_rename_response, format_status_response,
format_update_report, format_update_report_color, group_search_results,
is_expected_unindexed_storage_warning, paint, paint_action, paint_error, paint_identity,
paint_warning, parse_editor_command, resolve_editor_command, resolve_no_rerank_for_mode,
shell_quote_arg, truncate_snippet, CliAdapter, CliGetOptions, CliSearchOptions, CliStyle,
};
use kbolt_core::engine::Engine;
use kbolt_types::{
AddCollectionRequest, AddCollectionResult, ChunkResponse, CollectionInfo, CollectionStatus,
DiskUsage, DoctorCheck, DoctorCheckStatus, DoctorReport, DoctorSetupStatus,
DocumentResponse, EvalImportReport, EvalJudgment, EvalModeReport, EvalQueryReport,
EvalRunReport, FileEntry, FileError, InitialIndexingBlock, InitialIndexingOutcome,
LocalAction, LocalReport, ModelInfo, MultiGetItem, MultiGetItemKind, MultiGetResponse,
OmitReason, OmittedItem, RemoveScheduleSelector, ScheduleAddResponse, ScheduleBackend,
ScheduleDefinition, ScheduleInterval, ScheduleIntervalUnit, ScheduleOrphan,
ScheduleRemoveResponse, ScheduleRunResult, ScheduleRunState, ScheduleScope, ScheduleState,
ScheduleStatusEntry, ScheduleStatusResponse, ScheduleTrigger, ScheduleWeekday,
SearchEmptyReason, SearchMode, SearchResult, SearchResultChunk, SpaceStatus,
StatusResponse, UpdateReport,
};
struct EnvRestore {
home: Option<OsString>,
config_home: Option<OsString>,
cache_home: Option<OsString>,
visual: Option<OsString>,
editor: Option<OsString>,
}
impl EnvRestore {
fn capture() -> Self {
Self {
home: std::env::var_os("HOME"),
config_home: std::env::var_os("XDG_CONFIG_HOME"),
cache_home: std::env::var_os("XDG_CACHE_HOME"),
visual: std::env::var_os("VISUAL"),
editor: std::env::var_os("EDITOR"),
}
}
}
impl Drop for EnvRestore {
fn drop(&mut self) {
match &self.home {
Some(path) => std::env::set_var("HOME", path),
None => std::env::remove_var("HOME"),
}
match &self.config_home {
Some(path) => std::env::set_var("XDG_CONFIG_HOME", path),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match &self.cache_home {
Some(path) => std::env::set_var("XDG_CACHE_HOME", path),
None => std::env::remove_var("XDG_CACHE_HOME"),
}
match &self.visual {
Some(value) => std::env::set_var("VISUAL", value),
None => std::env::remove_var("VISUAL"),
}
match &self.editor {
Some(value) => std::env::set_var("EDITOR", value),
None => std::env::remove_var("EDITOR"),
}
}
}
fn with_isolated_xdg_dirs<T>(run: impl FnOnce() -> T) -> T {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let lock = ENV_LOCK.get_or_init(|| Mutex::new(()));
let _guard = lock.lock().expect("lock env mutex");
let _restore = EnvRestore::capture();
let root = tempdir().expect("create temp root");
std::env::set_var("HOME", root.path());
std::env::set_var("XDG_CONFIG_HOME", root.path().join("config-home"));
std::env::set_var("XDG_CACHE_HOME", root.path().join("cache-home"));
run()
}
fn new_collection_dir(root: &Path, name: &str) -> PathBuf {
let path = root.join(name);
fs::create_dir_all(&path).expect("create collection directory");
path
}
#[test]
fn editor_command_resolution_prefers_visual_then_editor_then_vi() {
with_isolated_xdg_dirs(|| {
std::env::set_var("VISUAL", "nvim -f");
std::env::set_var("EDITOR", "vim");
let from_visual = resolve_editor_command().expect("resolve visual");
assert_eq!(from_visual, vec!["nvim".to_string(), "-f".to_string()]);
std::env::remove_var("VISUAL");
let from_editor = resolve_editor_command().expect("resolve editor");
assert_eq!(from_editor, vec!["vim".to_string()]);
std::env::remove_var("EDITOR");
let fallback = resolve_editor_command().expect("resolve fallback");
assert_eq!(fallback, vec!["vi".to_string()]);
});
}
#[test]
fn parse_editor_command_rejects_invalid_shell_words() {
let err = parse_editor_command("'").expect_err("invalid shell words should fail");
assert!(
err.to_string().contains("invalid editor command"),
"unexpected error: {err}"
);
}
#[test]
fn eval_run_report_formats_summary_and_attention_queries() {
let output = format_eval_run_report(&EvalRunReport {
total_cases: 1,
modes: vec![
EvalModeReport {
mode: SearchMode::Keyword,
no_rerank: true,
ndcg_at_10: 1.0,
recall_at_10: 1.0,
mrr_at_10: 1.0,
latency_p50_ms: 2,
latency_p95_ms: 3,
queries: vec![EvalQueryReport {
query: "trait object generic".to_string(),
space: Some("default".to_string()),
collections: vec!["rust".to_string()],
judgments: vec![EvalJudgment {
path: "rust/guides/traits.md".to_string(),
relevance: 1,
}],
returned_paths: vec!["rust/guides/traits.md".to_string()],
matched_paths: vec!["rust/guides/traits.md".to_string()],
first_relevant_rank: Some(1),
elapsed_ms: 2,
}],
},
EvalModeReport {
mode: SearchMode::Deep,
no_rerank: false,
ndcg_at_10: 0.0,
recall_at_10: 0.0,
mrr_at_10: 0.0,
latency_p50_ms: 8,
latency_p95_ms: 12,
queries: vec![EvalQueryReport {
query: "trait object generic".to_string(),
space: Some("default".to_string()),
collections: vec!["rust".to_string()],
judgments: vec![EvalJudgment {
path: "rust/guides/traits.md".to_string(),
relevance: 1,
}],
returned_paths: vec!["rust/overview.md".to_string()],
matched_paths: vec![],
first_relevant_rank: None,
elapsed_ms: 8,
}],
},
],
failed_modes: vec![kbolt_types::EvalModeFailure {
mode: SearchMode::Semantic,
no_rerank: true,
error: "model not available".to_string(),
}],
});
assert!(output.starts_with("eval complete"));
assert!(output.contains("summary:\n- 1 case(s)"));
assert!(output.contains("modes:\n- keyword"));
assert!(output.contains(" nDCG@10: 1.000"));
assert!(output.contains(" Recall@10: 1.000"));
assert!(output.contains(" MRR@10: 1.000"));
assert!(output.contains(" p50: 2ms"));
assert!(output.contains(" p95: 3ms"));
assert!(output.contains("- deep"));
assert!(output.contains(" nDCG@10: 0.000"));
assert!(output.contains("failed:\n- semantic: model not available"));
assert!(output.contains("attention:\n- deep: trait object generic"));
assert!(output.contains(" first relevant: none"));
assert!(output.contains(" expected: rust/guides/traits.md(rel=1)"));
assert!(output.contains(" returned: rust/overview.md"));
}
#[test]
fn eval_run_report_formats_empty_attention_state() {
let output = format_eval_run_report(&EvalRunReport {
total_cases: 1,
modes: vec![EvalModeReport {
mode: SearchMode::Auto,
no_rerank: false,
ndcg_at_10: 1.0,
recall_at_10: 1.0,
mrr_at_10: 1.0,
latency_p50_ms: 4,
latency_p95_ms: 5,
queries: vec![EvalQueryReport {
query: "trait object generic".to_string(),
space: Some("default".to_string()),
collections: vec!["rust".to_string()],
judgments: vec![EvalJudgment {
path: "rust/guides/traits.md".to_string(),
relevance: 1,
}],
returned_paths: vec!["rust/guides/traits.md".to_string()],
matched_paths: vec!["rust/guides/traits.md".to_string()],
first_relevant_rank: Some(1),
elapsed_ms: 4,
}],
}],
failed_modes: Vec::new(),
});
assert!(output.contains("modes:\n- auto+rerank"));
assert!(output.contains("attention:\n- none"));
assert!(!output.contains("failed:"));
}
#[test]
fn colored_eval_run_report_marks_failed_modes_as_errors() {
let output = format_eval_run_report_color(
&EvalRunReport {
total_cases: 1,
modes: Vec::new(),
failed_modes: vec![kbolt_types::EvalModeFailure {
mode: SearchMode::Semantic,
no_rerank: true,
error: "model not available".to_string(),
}],
},
true,
);
assert!(
output.contains("\x1b[31msemantic: model not available\x1b[0m"),
"failed eval modes should be error-colored:\n{output}"
);
assert!(
output.contains("\x1b[1;36mattention:\x1b[0m\n\x1b[90m-\x1b[0m none"),
"unexpected output:\n{output}"
);
}
#[test]
fn eval_import_report_formats_next_steps() {
let output = format_eval_import_report(&EvalImportReport {
dataset: "scifact".to_string(),
source: "/tmp/scifact-source".to_string(),
output_dir: "/tmp/scifact-bench".to_string(),
corpus_dir: "/tmp/scifact-bench/corpus".to_string(),
manifest_path: "/tmp/scifact-bench/eval.toml".to_string(),
default_space: "bench".to_string(),
collection: "scifact".to_string(),
document_count: 2,
query_count: 2,
judgment_count: 3,
});
assert!(output.contains("benchmark imported: scifact"));
assert!(output.contains("paths:"));
assert!(output.contains("- corpus: /tmp/scifact-bench/corpus"));
assert!(output.contains("contents:"));
assert!(output.contains("- 2 document(s)"));
assert!(output.contains("- 2 query(s)"));
assert!(output.contains("- 3 judgment(s)"));
assert!(output.contains("kbolt space add bench"));
assert!(output.contains("kbolt eval run --file /tmp/scifact-bench/eval.toml"));
}
#[test]
fn models_list_reports_role_binding_readiness() {
with_isolated_xdg_dirs(|| {
let engine = Engine::new(None).expect("create engine");
let adapter = CliAdapter::new(engine);
let output = adapter.models_list().expect("list models");
assert!(
output.starts_with("models:\n"),
"unexpected output: {output}"
);
assert!(
output.contains("- embedder: not configured"),
"unexpected output: {output}"
);
assert!(
output.contains("- reranker: not configured"),
"unexpected output: {output}"
);
assert!(
output.contains("- expander: not configured"),
"unexpected output: {output}"
);
});
}
#[test]
fn models_list_surfaces_not_ready_issue_without_provider_dump() {
let output = format_models_list(&kbolt_types::ModelStatus {
embedder: ModelInfo {
configured: true,
ready: false,
profile: Some("kbolt_local_embed".to_string()),
kind: Some("llama_cpp_server".to_string()),
operation: Some("embedding".to_string()),
model: Some("embeddinggemma".to_string()),
endpoint: Some("http://127.0.0.1:8101".to_string()),
issue: Some("endpoint is unreachable".to_string()),
},
reranker: ModelInfo {
configured: true,
ready: true,
profile: Some("kbolt_local_rerank".to_string()),
kind: Some("llama_cpp_server".to_string()),
operation: Some("reranking".to_string()),
model: Some("qwen3-reranker".to_string()),
endpoint: Some("http://127.0.0.1:8102".to_string()),
issue: None,
},
expander: ModelInfo {
configured: false,
ready: false,
profile: None,
kind: None,
operation: None,
model: None,
endpoint: None,
issue: None,
},
});
assert!(output.contains("- embedder: not ready (embeddinggemma)"));
assert!(output.contains(" issue: endpoint is unreachable"));
assert!(output.contains("- reranker: ready (qwen3-reranker)"));
assert!(output.contains("- expander: not configured"));
assert!(!output.contains("profile="), "unexpected output:\n{output}");
assert!(
!output.contains("endpoint=http"),
"unexpected output:\n{output}"
);
}
#[test]
fn colored_models_list_marks_unavailable_model_states_as_warnings() {
let output = format_models_list_color(
&kbolt_types::ModelStatus {
embedder: ModelInfo {
configured: true,
ready: false,
profile: Some("kbolt_local_embed".to_string()),
kind: Some("llama_cpp_server".to_string()),
operation: Some("embedding".to_string()),
model: Some("embeddinggemma".to_string()),
endpoint: Some("http://127.0.0.1:8101".to_string()),
issue: Some("endpoint is unreachable".to_string()),
},
reranker: ModelInfo {
configured: true,
ready: true,
profile: Some("kbolt_local_rerank".to_string()),
kind: Some("llama_cpp_server".to_string()),
operation: Some("reranking".to_string()),
model: Some("qwen3-reranker".to_string()),
endpoint: Some("http://127.0.0.1:8102".to_string()),
issue: None,
},
expander: ModelInfo {
configured: false,
ready: false,
profile: None,
kind: None,
operation: None,
model: None,
endpoint: None,
issue: None,
},
},
true,
);
assert!(
output.contains("embedder: \x1b[33mnot ready\x1b[0m (embeddinggemma)"),
"unexpected output:\n{output}"
);
assert!(
output.contains("issue: \x1b[33mendpoint is unreachable\x1b[0m"),
"unexpected output:\n{output}"
);
assert!(
output.contains("reranker: ready (qwen3-reranker)"),
"unexpected output:\n{output}"
);
assert!(
output.contains("expander: \x1b[33mnot configured\x1b[0m"),
"unexpected output:\n{output}"
);
}
#[test]
fn doctor_report_success_is_concise() {
let output = format_doctor_report(&DoctorReport {
setup_status: DoctorSetupStatus::Configured,
config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
config_dir: Some(PathBuf::from("/tmp/kbolt")),
cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
ready: true,
checks: vec![
DoctorCheck {
id: "roles.embedder.bound".to_string(),
scope: "roles.embedder".to_string(),
status: DoctorCheckStatus::Pass,
elapsed_ms: 0,
message: "bound".to_string(),
fix: None,
},
DoctorCheck {
id: "roles.expander.bound".to_string(),
scope: "roles.expander".to_string(),
status: DoctorCheckStatus::Warn,
elapsed_ms: 0,
message: "role is not configured".to_string(),
fix: Some("configure expander".to_string()),
},
],
});
assert!(
output.contains("kbolt is ready"),
"unexpected output:\n{output}"
);
assert!(output.contains("configured:"));
assert!(output.contains("- embedder"));
assert!(output.contains("not enabled:"));
assert!(output.contains("- expander"));
assert!(
!output.contains("PASS"),
"should not show raw check status in success case"
);
}
#[test]
fn doctor_report_shows_failures_with_fixes() {
let output = format_doctor_report(&DoctorReport {
setup_status: DoctorSetupStatus::Configured,
config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
config_dir: Some(PathBuf::from("/tmp/kbolt")),
cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
ready: false,
checks: vec![DoctorCheck {
id: "roles.embedder.reachable".to_string(),
scope: "roles.embedder".to_string(),
status: DoctorCheckStatus::Fail,
elapsed_ms: 17,
message: "endpoint is unreachable".to_string(),
fix: Some("Start the embedding server.".to_string()),
}],
});
assert!(
output.contains("kbolt has issues"),
"unexpected output:\n{output}"
);
assert!(output.contains("failures:"));
assert!(output.contains("endpoint is unreachable"));
assert!(output.contains("fix: Start the embedding server."));
}
#[test]
fn doctor_report_missing_config_guides_to_setup() {
let output = format_doctor_report(&DoctorReport {
setup_status: DoctorSetupStatus::ConfigMissing,
config_file: None,
config_dir: None,
cache_dir: None,
ready: false,
checks: vec![],
});
assert!(
output.contains("kbolt is not set up"),
"unexpected output:\n{output}"
);
assert!(output.contains("kbolt setup local"));
}
#[test]
fn doctor_report_invalid_config_groups_failures_and_config_path() {
let output = format_doctor_report(&DoctorReport {
setup_status: DoctorSetupStatus::ConfigInvalid,
config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
config_dir: Some(PathBuf::from("/tmp/kbolt")),
cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
ready: false,
checks: vec![DoctorCheck {
id: "config.parse".to_string(),
scope: "config".to_string(),
status: DoctorCheckStatus::Fail,
elapsed_ms: 0,
message: "failed to parse config".to_string(),
fix: Some("Fix /tmp/kbolt/index.toml.".to_string()),
}],
});
assert!(
output.starts_with("kbolt configuration is invalid\n"),
"unexpected output:\n{output}"
);
assert!(output.contains("failures:"));
assert!(output.contains("- failed to parse config"));
assert!(output.contains("fix: Fix /tmp/kbolt/index.toml."));
assert!(output.contains("config:"));
assert!(output.contains("- /tmp/kbolt/index.toml"));
}
#[test]
fn doctor_report_not_configured_guides_to_setup() {
let output = format_doctor_report(&DoctorReport {
setup_status: DoctorSetupStatus::NotConfigured,
config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
config_dir: Some(PathBuf::from("/tmp/kbolt")),
cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
ready: false,
checks: vec![],
});
assert!(
output.starts_with("kbolt is installed but no inference roles are configured\n"),
"unexpected output:\n{output}"
);
assert!(output.contains("next:"));
assert!(output.contains("- kbolt setup local"));
}
#[test]
fn doctor_report_treats_unindexed_storage_as_expected_next_step() {
let output = format_doctor_report(&DoctorReport {
setup_status: DoctorSetupStatus::Configured,
config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
config_dir: Some(PathBuf::from("/tmp/kbolt")),
cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
ready: true,
checks: vec![
DoctorCheck {
id: "roles.embedder.bound".to_string(),
scope: "roles.embedder".to_string(),
status: DoctorCheckStatus::Pass,
elapsed_ms: 0,
message: "bound".to_string(),
fix: None,
},
DoctorCheck {
id: "storage.sqlite_readable".to_string(),
scope: "storage".to_string(),
status: DoctorCheckStatus::Warn,
elapsed_ms: 0,
message: "index database does not exist yet: /tmp/cache/kbolt/meta.sqlite"
.to_string(),
fix: Some(
"Run `kbolt update` after adding a collection to build the index."
.to_string(),
),
},
DoctorCheck {
id: "storage.search_indexes_readable".to_string(),
scope: "storage".to_string(),
status: DoctorCheckStatus::Warn,
elapsed_ms: 0,
message: "search index directory does not exist yet: /tmp/cache/kbolt/spaces"
.to_string(),
fix: Some(
"Run `kbolt update` after adding a collection to build search indexes."
.to_string(),
),
},
],
});
assert!(
output.contains("kbolt is ready"),
"unexpected output:\n{output}"
);
assert!(output.contains("indexing:"), "unexpected output:\n{output}");
assert!(
output.contains("no collections have been indexed yet"),
"unexpected output:\n{output}"
);
assert!(output.contains("next:"), "unexpected output:\n{output}");
assert!(
output.contains("kbolt collection add /path/to/docs"),
"unexpected output:\n{output}"
);
assert!(
output.contains("or, if collections are already registered: kbolt update"),
"unexpected output:\n{output}"
);
assert!(
!output.contains("warnings:"),
"unexpected output:\n{output}"
);
}
#[test]
fn expected_unindexed_storage_warning_matches_only_storage_warns() {
let warn = DoctorCheck {
id: "storage.sqlite_readable".to_string(),
scope: "storage".to_string(),
status: DoctorCheckStatus::Warn,
elapsed_ms: 0,
message: "missing".to_string(),
fix: None,
};
assert!(is_expected_unindexed_storage_warning(&warn));
let fail = DoctorCheck {
status: DoctorCheckStatus::Fail,
..warn.clone()
};
assert!(!is_expected_unindexed_storage_warning(&fail));
let other = DoctorCheck {
id: "roles.expander.bound".to_string(),
..warn
};
assert!(!is_expected_unindexed_storage_warning(&other));
}
#[test]
fn local_report_shows_ready_services_and_hides_internals() {
let output = format_local_report(&LocalReport {
action: LocalAction::Setup,
config_file: PathBuf::from("/tmp/kbolt/index.toml"),
cache_dir: PathBuf::from("/tmp/cache/kbolt"),
llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
ready: true,
notes: vec![],
services: vec![
kbolt_types::LocalServiceReport {
name: "embedder".to_string(),
provider: "kbolt_local_embed".to_string(),
enabled: true,
configured: true,
managed: true,
running: true,
ready: true,
model: "embeddinggemma".to_string(),
model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
endpoint: "http://127.0.0.1:8101".to_string(),
port: 8101,
pid: Some(42),
pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
issue: None,
},
kbolt_types::LocalServiceReport {
name: "expander".to_string(),
provider: "kbolt_local_expand".to_string(),
enabled: false,
configured: false,
managed: false,
running: false,
ready: false,
model: "qwen3-1.7b".to_string(),
model_path: PathBuf::from("/tmp/cache/kbolt/models/expander/model.gguf"),
endpoint: "http://127.0.0.1:8103".to_string(),
port: 8103,
pid: None,
pid_file: PathBuf::from("/tmp/cache/kbolt/run/expander.pid"),
log_file: PathBuf::from("/tmp/cache/kbolt/logs/expander.log"),
issue: Some("not configured".to_string()),
},
],
});
assert!(
output.contains("local setup complete"),
"unexpected output:\n{output}"
);
assert!(output.contains("- embedder (embeddinggemma)"));
assert!(output.contains("not configured:"));
assert!(output.contains("- expander"));
assert!(output.contains("/tmp/kbolt/index.toml"));
assert!(output.contains("kbolt collection add"));
assert!(
!output.contains("pid"),
"should not expose pid in default output"
);
assert!(
!output.contains("log_file"),
"should not expose log_file in default output"
);
assert!(
!output.contains("model_path"),
"should not expose model_path in default output"
);
}
#[test]
fn local_report_surfaces_notes() {
let output = format_local_report(&LocalReport {
action: LocalAction::Setup,
config_file: PathBuf::from("/tmp/kbolt/index.toml"),
cache_dir: PathBuf::from("/tmp/cache/kbolt"),
llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
ready: true,
notes: vec![
"moved incompatible old config to /tmp/index.toml.invalid.bak".to_string(),
"started embedder on http://127.0.0.1:8101".to_string(),
],
services: vec![],
});
assert!(output.contains("notes:"), "unexpected output:\n{output}");
assert!(
output.contains("moved incompatible old config"),
"unexpected output:\n{output}"
);
assert!(
output.contains("started embedder on http://127.0.0.1:8101"),
"unexpected output:\n{output}"
);
}
#[test]
fn local_report_shows_issues_when_not_ready() {
let output = format_local_report(&LocalReport {
action: LocalAction::Setup,
config_file: PathBuf::from("/tmp/kbolt/index.toml"),
cache_dir: PathBuf::from("/tmp/cache/kbolt"),
llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
ready: false,
notes: vec![],
services: vec![kbolt_types::LocalServiceReport {
name: "embedder".to_string(),
provider: "kbolt_local_embed".to_string(),
enabled: true,
configured: true,
managed: true,
running: true,
ready: false,
model: "embeddinggemma".to_string(),
model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
endpoint: "http://127.0.0.1:8101".to_string(),
port: 8101,
pid: Some(42),
pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
issue: Some("service is not ready".to_string()),
}],
});
assert!(
output.contains("(not ready)"),
"unexpected output:\n{output}"
);
assert!(output.contains("issues:"));
assert!(output.contains("embedder: service is not ready"));
assert!(output.contains("log: /tmp/cache/kbolt/logs/embedder.log"));
}
#[test]
fn local_status_report_uses_server_summary_sections() {
let output = format_local_report(&LocalReport {
action: LocalAction::Status,
config_file: PathBuf::from("/tmp/kbolt/index.toml"),
cache_dir: PathBuf::from("/tmp/cache/kbolt"),
llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
ready: false,
notes: vec![],
services: vec![
kbolt_types::LocalServiceReport {
name: "embedder".to_string(),
provider: "kbolt_local_embed".to_string(),
enabled: true,
configured: true,
managed: true,
running: true,
ready: true,
model: "embeddinggemma".to_string(),
model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
endpoint: "http://127.0.0.1:8101".to_string(),
port: 8101,
pid: Some(42),
pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
issue: None,
},
kbolt_types::LocalServiceReport {
name: "reranker".to_string(),
provider: "kbolt_local_rerank".to_string(),
enabled: true,
configured: true,
managed: true,
running: false,
ready: false,
model: "qwen3-reranker".to_string(),
model_path: PathBuf::from("/tmp/cache/kbolt/models/reranker/model.gguf"),
endpoint: "http://127.0.0.1:8102".to_string(),
port: 8102,
pid: None,
pid_file: PathBuf::from("/tmp/cache/kbolt/run/reranker.pid"),
log_file: PathBuf::from("/tmp/cache/kbolt/logs/reranker.log"),
issue: Some("service is not ready".to_string()),
},
],
});
assert!(
output.starts_with("local servers (not ready)\n"),
"unexpected output:\n{output}"
);
assert!(output.contains("ready:"));
assert!(output.contains("- embedder (embeddinggemma)"));
assert!(output.contains("issues:"));
assert!(output.contains("- reranker: service is not ready"));
assert!(output.contains("config:"));
}
#[test]
fn local_stop_report_treats_stopped_services_as_expected() {
let output = format_local_report(&LocalReport {
action: LocalAction::Stop,
config_file: PathBuf::from("/tmp/kbolt/index.toml"),
cache_dir: PathBuf::from("/tmp/cache/kbolt"),
llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
ready: false,
notes: vec![
"stopped embedder".to_string(),
"stopped reranker".to_string(),
],
services: vec![
kbolt_types::LocalServiceReport {
name: "embedder".to_string(),
provider: "kbolt_local_embed".to_string(),
enabled: true,
configured: true,
managed: false,
running: false,
ready: false,
model: "embeddinggemma".to_string(),
model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
endpoint: "http://127.0.0.1:8101".to_string(),
port: 8101,
pid: None,
pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
issue: Some("service is not ready".to_string()),
},
kbolt_types::LocalServiceReport {
name: "reranker".to_string(),
provider: "kbolt_local_rerank".to_string(),
enabled: true,
configured: true,
managed: false,
running: false,
ready: false,
model: "qwen3-reranker".to_string(),
model_path: PathBuf::from("/tmp/cache/kbolt/models/reranker/model.gguf"),
endpoint: "http://127.0.0.1:8102".to_string(),
port: 8102,
pid: None,
pid_file: PathBuf::from("/tmp/cache/kbolt/run/reranker.pid"),
log_file: PathBuf::from("/tmp/cache/kbolt/logs/reranker.log"),
issue: Some("service is not ready".to_string()),
},
kbolt_types::LocalServiceReport {
name: "expander".to_string(),
provider: "kbolt_local_expand".to_string(),
enabled: false,
configured: false,
managed: false,
running: false,
ready: false,
model: "qwen3-1.7b".to_string(),
model_path: PathBuf::from("/tmp/cache/kbolt/models/expander/model.gguf"),
endpoint: "http://127.0.0.1:8103".to_string(),
port: 8103,
pid: None,
pid_file: PathBuf::from("/tmp/cache/kbolt/run/expander.pid"),
log_file: PathBuf::from("/tmp/cache/kbolt/logs/expander.log"),
issue: Some("service is not configured".to_string()),
},
],
});
assert!(
output.starts_with("local servers stopped\n"),
"unexpected output:\n{output}"
);
assert!(
!output.contains("(not ready)"),
"unexpected output:\n{output}"
);
assert!(!output.contains("issues:"), "unexpected output:\n{output}");
assert!(
!output.contains("not configured:"),
"unexpected output:\n{output}"
);
assert!(
output.contains("stopped embedder"),
"unexpected output:\n{output}"
);
assert!(
output.contains("stopped reranker"),
"unexpected output:\n{output}"
);
}
#[test]
fn truncate_snippet_preserves_short_text() {
assert_eq!(
truncate_snippet("line one\nline two", 4),
"line one\nline two"
);
}
#[test]
fn truncate_snippet_truncates_long_text() {
let text = "one\ntwo\nthree\nfour\nfive\nsix";
let result = truncate_snippet(text, 3);
assert!(result.contains("one\ntwo\nthree\n"), "unexpected: {result}");
assert!(result.contains("(+3 more lines)"), "unexpected: {result}");
}
#[test]
fn search_rejects_conflicting_mode_flags() {
with_isolated_xdg_dirs(|| {
let adapter = CliAdapter::new(Engine::new(None).expect("create engine"));
let err = adapter
.search(CliSearchOptions {
space: None,
query: "alpha",
collections: &[],
limit: 10,
min_score: 0.0,
deep: true,
keyword: true,
semantic: false,
rerank: false,
no_rerank: false,
debug: false,
})
.expect_err("conflicting search flags should fail");
assert!(
err.to_string()
.contains("only one of --deep, --keyword, or --semantic"),
"unexpected error: {err}"
);
});
}
#[test]
fn empty_index_hint_fresh_install_suggests_scoped_add_collection() {
with_isolated_xdg_dirs(|| {
let adapter = CliAdapter::new(Engine::new(None).expect("create engine"));
let hint = adapter
.empty_index_hint(None, &[], None)
.expect("hint should fire on a fresh home");
assert!(
hint.contains("no collections registered yet"),
"unexpected hint: {hint}"
);
assert!(
hint.contains("next:\n- "),
"hint should use sectioned next steps: {hint}"
);
assert!(
hint.contains("kbolt --space default collection add /path/to/docs"),
"should suggest --space default on fresh install: {hint}"
);
});
}
#[test]
fn empty_index_hint_is_silent_when_any_collection_has_content() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create temp root");
let engine = Engine::new(None).expect("create engine");
let coll_path = new_collection_dir(root.path(), "notes");
fs::write(coll_path.join("a.md"), "hello world\n").expect("write file");
engine
.add_collection(AddCollectionRequest {
path: coll_path,
space: Some("default".to_string()),
name: Some("notes".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add collection");
let adapter = CliAdapter::new(engine);
adapter
.update(Some("default"), &[], true, false, false)
.expect("run update");
let hint = adapter.empty_index_hint(None, &[], None);
assert!(
hint.is_none(),
"expected silent when content exists, got: {hint:?}"
);
});
}
#[test]
fn empty_index_hint_is_silent_on_mixed_unscoped_search() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create temp root");
let engine = Engine::new(None).expect("create engine");
let hot = new_collection_dir(root.path(), "hot");
fs::write(hot.join("a.md"), "content\n").expect("write file");
engine
.add_collection(AddCollectionRequest {
path: hot,
space: Some("default".to_string()),
name: Some("hot".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add hot");
let cold = new_collection_dir(root.path(), "cold");
engine
.add_collection(AddCollectionRequest {
path: cold,
space: Some("default".to_string()),
name: Some("cold".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add cold");
let adapter = CliAdapter::new(engine);
adapter
.update(Some("default"), &[], true, false, false)
.expect("run update");
let hint = adapter.empty_index_hint(None, &[], None);
assert!(
hint.is_none(),
"expected silent on mixed unscoped, got: {hint:?}"
);
});
}
#[test]
fn empty_index_hint_scoped_to_empty_collection_suggests_update() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create temp root");
let engine = Engine::new(None).expect("create engine");
let hot = new_collection_dir(root.path(), "hot");
fs::write(hot.join("a.md"), "content\n").expect("write file");
engine
.add_collection(AddCollectionRequest {
path: hot,
space: Some("default".to_string()),
name: Some("hot".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add hot");
let cold = new_collection_dir(root.path(), "cold");
engine
.add_collection(AddCollectionRequest {
path: cold,
space: Some("default".to_string()),
name: Some("cold".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add cold");
let adapter = CliAdapter::new(engine);
adapter
.update(Some("default"), &[], true, false, false)
.expect("run update");
let hint = adapter
.empty_index_hint(
None,
&["cold".to_string()],
Some(&SearchEmptyReason::UnindexedScope),
)
.expect("hint should fire when scoped to an empty collection");
assert!(
hint.contains("nothing indexed for this scope"),
"unexpected hint: {hint}"
);
assert!(
hint.contains("next:\n- "),
"hint should use sectioned next steps: {hint}"
);
assert!(
hint.contains("kbolt --space default update --collection cold"),
"should point at update with the actual space: {hint}"
);
});
}
#[test]
fn empty_index_hint_shell_quotes_names_with_whitespace() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create temp root");
let engine = Engine::new(None).expect("create engine");
let cold = new_collection_dir(root.path(), "cold");
engine
.add_collection(AddCollectionRequest {
path: cold,
space: Some("default".to_string()),
name: Some("cold docs".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add cold docs");
let adapter = CliAdapter::new(engine);
let hint = adapter
.empty_index_hint(
None,
&["cold docs".to_string()],
Some(&SearchEmptyReason::UnindexedScope),
)
.expect("hint should fire");
assert!(
hint.contains("kbolt --space default update --collection 'cold docs'"),
"collection name with whitespace must be single-quoted in the hint: {hint}"
);
});
}
#[test]
fn shell_quote_arg_leaves_safe_tokens_alone_and_escapes_risky_ones() {
assert_eq!(shell_quote_arg("default"), "default");
assert_eq!(shell_quote_arg("work-notes"), "work-notes");
assert_eq!(shell_quote_arg("./path/to/docs"), "./path/to/docs");
assert_eq!(shell_quote_arg("cold docs"), "'cold docs'");
assert_eq!(shell_quote_arg(""), "''");
assert_eq!(shell_quote_arg("it's fine"), "'it'\\''s fine'");
}
#[test]
fn resolve_no_rerank_for_mode_matches_cli_contract() {
assert!(resolve_no_rerank_for_mode(SearchMode::Auto, false, false));
assert!(!resolve_no_rerank_for_mode(SearchMode::Auto, true, false));
assert!(!resolve_no_rerank_for_mode(SearchMode::Deep, false, false));
assert!(resolve_no_rerank_for_mode(SearchMode::Deep, false, true));
assert!(resolve_no_rerank_for_mode(SearchMode::Keyword, true, false));
assert!(resolve_no_rerank_for_mode(
SearchMode::Semantic,
true,
false
));
}
#[test]
fn search_reports_requested_and_effective_mode_for_auto_keyword_fallback() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
let work_path = new_collection_dir(root.path(), "work-api");
engine
.add_collection(AddCollectionRequest {
path: work_path.clone(),
space: Some("work".to_string()),
name: Some("api".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add collection");
fs::write(work_path.join("a.md"), "fallback token\n").expect("write file");
let adapter = CliAdapter::new(engine);
adapter
.update(Some("work"), &["api".to_string()], true, false, false)
.expect("run update");
let output = adapter
.search(CliSearchOptions {
space: Some("work"),
query: "fallback",
collections: &["api".to_string()],
limit: 5,
min_score: 0.0,
deep: false,
keyword: false,
semantic: false,
rerank: false,
no_rerank: false,
debug: true,
})
.expect("run auto search");
assert!(
output.starts_with("debug:\n"),
"unexpected output: {output}"
);
assert!(
output.contains("mode: auto -> keyword"),
"unexpected output: {output}"
);
assert!(
output.contains("pipeline: keyword"),
"unexpected output: {output}"
);
assert!(
output.contains("result count: 1"),
"unexpected output: {output}"
);
assert!(
output.contains("note: dense retrieval unavailable: not configured"),
"unexpected output: {output}"
);
});
}
#[test]
fn search_normal_output_includes_subtle_capability_fallbacks() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
let work_path = new_collection_dir(root.path(), "work-api");
engine
.add_collection(AddCollectionRequest {
path: work_path.clone(),
space: Some("work".to_string()),
name: Some("api".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add collection");
fs::write(work_path.join("a.md"), "fallback token\n").expect("write file");
let adapter = CliAdapter::new(engine);
adapter
.update(Some("work"), &["api".to_string()], true, false, false)
.expect("run update");
let output = adapter
.search(CliSearchOptions {
space: Some("work"),
query: "fallback",
collections: &["api".to_string()],
limit: 5,
min_score: 0.0,
deep: false,
keyword: false,
semantic: false,
rerank: true,
no_rerank: false,
debug: false,
})
.expect("run auto search");
assert!(
output.contains("keyword only (dense unavailable)"),
"unexpected output: {output}"
);
assert!(
output.contains("rerank skipped"),
"unexpected output: {output}"
);
assert!(
!output.contains("note:"),
"normal output should keep fallback messaging subtle: {output}"
);
let colored_output = adapter
.with_color(true)
.search(CliSearchOptions {
space: Some("work"),
query: "fallback",
collections: &["api".to_string()],
limit: 5,
min_score: 0.0,
deep: false,
keyword: false,
semantic: false,
rerank: true,
no_rerank: false,
debug: false,
})
.expect("run colored auto search");
assert!(
colored_output.contains("\x1b[33mkeyword only (dense unavailable)\x1b[0m"),
"degraded dense fallback should use warning color:\n{colored_output}"
);
assert!(
colored_output.contains("\x1b[33mrerank skipped\x1b[0m"),
"skipped rerank notice should use warning color:\n{colored_output}"
);
});
}
#[test]
fn search_result_paths_include_space_context() {
assert_eq!(
format_search_result_path("work", "api/guide.md"),
"work/api/guide.md"
);
}
fn make_search_result(docid: &str, path: &str, score: f32, text: &str) -> SearchResult {
SearchResult {
docid: docid.to_string(),
chunk: SearchResultChunk {
locator: format!("{docid}@1"),
ordinal: 1,
},
path: path.to_string(),
title: format!("title for {path}"),
space: "work".to_string(),
collection: "docs".to_string(),
heading: None,
text: text.to_string(),
score,
signals: None,
}
}
fn make_search_result_in_space(
docid: &str,
space: &str,
path: &str,
score: f32,
text: &str,
) -> SearchResult {
SearchResult {
docid: docid.to_string(),
chunk: SearchResultChunk {
locator: format!("{docid}@1"),
ordinal: 1,
},
path: path.to_string(),
title: format!("title for {path}"),
space: space.to_string(),
collection: "docs".to_string(),
heading: None,
text: text.to_string(),
score,
signals: None,
}
}
#[test]
fn additional_match_label_uses_heading_or_chunk_ordinal() {
let mut result = make_search_result("#doc-a", "docs/a.md", 0.95, "alpha");
result.heading = Some("Guide > Setup".to_string());
result.chunk.ordinal = 2;
assert_eq!(
format_additional_match_label(&result),
"Guide > Setup (chunk 2)"
);
result.heading = None;
assert_eq!(format_additional_match_label(&result), "chunk 2");
}
#[test]
fn search_chunk_read_command_uses_compact_docid_locator() {
let locator = format_compact_chunk_locator("#96eafc", 2);
assert_eq!(
format_chunk_read_command("uiux", &locator),
"kbolt --space uiux get '#96eafc@2'"
);
}
#[test]
fn paint_only_emits_ansi_when_enabled() {
assert_eq!(paint(false, CliStyle::Identity, "path"), "path");
assert_eq!(paint(true, CliStyle::Label, "path:"), "path:");
assert_eq!(
paint(true, CliStyle::Identity, "path"),
"\x1b[34mpath\x1b[0m"
);
assert_eq!(paint_identity(true, "#409380"), "\x1b[34m#409380\x1b[0m");
assert_eq!(
paint_action(true, "kbolt get '#409380@1'"),
"\x1b[32mkbolt get '#409380@1'\x1b[0m"
);
assert_eq!(paint_warning(true, "stale"), "\x1b[33mstale\x1b[0m");
assert_eq!(paint_error(true, "failed"), "\x1b[31mfailed\x1b[0m");
}
#[test]
fn palette_avoids_dim_and_black_foreground_styles() {
for style in CliStyle::ALL {
if let Some(code) = ansi_code(style) {
assert!(
!code.split(';').any(|part| part == "2" || part == "30"),
"palette style {code} must not use dim or black foreground"
);
}
}
}
#[test]
fn color_multi_get_keeps_document_body_plain() {
let output = format_multi_get_response_color(
&MultiGetResponse {
items: vec![MultiGetItem::Document(DocumentResponse {
docid: "#409380".to_string(),
title: "Guide".to_string(),
path: "api/guide.md".to_string(),
space: "work".to_string(),
collection: "api".to_string(),
content: "plain body line\n- copyable bullet".to_string(),
stale: false,
total_lines: 2,
returned_lines: 2,
})],
omitted: Vec::new(),
resolved_count: 1,
warnings: Vec::new(),
},
true,
);
assert!(output.contains("\x1b[1;36mitems:\x1b[0m"));
assert!(output.lines().any(|line| line == "plain body line"));
assert!(output.lines().any(|line| line == "- copyable bullet"));
}
#[test]
fn color_multi_get_warnings_are_warning_colored() {
let output = format_multi_get_response_color(
&MultiGetResponse {
items: Vec::new(),
omitted: Vec::new(),
resolved_count: 0,
warnings: vec!["item not found: api/missing.md".to_string()],
},
true,
);
assert!(output.contains("\x1b[33mitem not found: api/missing.md\x1b[0m"));
}
#[test]
fn color_multi_get_omitted_items_are_warning_colored() {
let output = format_multi_get_response_color(
&MultiGetResponse {
items: Vec::new(),
omitted: vec![OmittedItem {
kind: MultiGetItemKind::Document,
locator: "#large1".to_string(),
path: "api/large.md".to_string(),
docid: "#large1".to_string(),
space: "work".to_string(),
size_bytes: 8192,
reason: OmitReason::MaxBytes,
}],
resolved_count: 1,
warnings: Vec::new(),
},
true,
);
assert!(output.contains("\x1b[33mapi/large.md (8.0 KB, size limit)\x1b[0m"));
}
#[test]
fn search_chunk_locator_extends_short_docid_collisions() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
let docs_path = new_collection_dir(root.path(), "work-docs");
engine
.add_collection(AddCollectionRequest {
path: docs_path.clone(),
space: Some("work".to_string()),
name: Some("docs".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add collection");
fs::write(docs_path.join("guide.md"), "# Guide\n\nalpha target\n")
.expect("write guide");
let adapter = CliAdapter::new(engine);
adapter
.update(Some("work"), &["docs".to_string()], true, false, false)
.expect("run update");
let space_row = adapter
.engine
.storage()
.get_space("work")
.expect("get space");
let collection_row = adapter
.engine
.storage()
.get_collection(space_row.id, "docs")
.expect("get collection");
let document = adapter
.engine
.storage()
.get_document_by_path(collection_row.id, "guide.md")
.expect("get document")
.expect("document exists");
let seventh = if &document.hash[6..7] == "a" {
"b"
} else {
"a"
};
let colliding_hash = format!(
"{}{}{}",
&document.hash[..6],
seventh,
"0".repeat(document.hash.len().saturating_sub(7))
);
adapter
.engine
.storage()
.upsert_document(
collection_row.id,
"collision.md",
Some("collision.md"),
&colliding_hash,
"2026-05-23T12:00:00Z",
)
.expect("insert colliding document");
let chunk = adapter
.engine
.storage()
.get_chunks_for_document(document.id)
.expect("get chunks")
.into_iter()
.next()
.expect("chunk exists");
let ordinal = usize::try_from(chunk.seq + 1).expect("positive chunk ordinal");
let result = SearchResult {
docid: format!("#{}", &document.hash[..6]),
chunk: SearchResultChunk {
locator: format!("#{}@{ordinal}", document.hash),
ordinal,
},
path: "guide.md".to_string(),
title: "Guide".to_string(),
space: "work".to_string(),
collection: "docs".to_string(),
heading: None,
text: "alpha target".to_string(),
score: 1.0,
signals: None,
};
let locator = adapter
.search_chunk_locator(&result)
.expect("compact locator");
assert!(
locator.starts_with(&format!("#{}", &document.hash[..7])),
"locator should extend the colliding short prefix: {locator}"
);
let chunk_output = adapter
.get(CliGetOptions {
space: Some("work"),
identifier: &locator,
offset: None,
limit: None,
})
.expect("rendered compact locator should be executable");
assert!(
chunk_output.contains("alpha target"),
"unexpected chunk output:\n{chunk_output}"
);
assert!(
chunk_output.contains(&locator),
"chunk output should keep the compact handle from the read flow:\n{chunk_output}"
);
let full_locator = format!("#{}@{ordinal}", document.hash);
let full_locator_output = adapter
.get(CliGetOptions {
space: Some("work"),
identifier: &full_locator,
offset: None,
limit: None,
})
.expect("full chunk locator should be executable");
assert!(
full_locator_output.contains(&locator),
"human chunk output should compact full locators:\n{full_locator_output}"
);
assert!(
!full_locator_output.contains(&document.hash),
"human chunk output should not expose the full document hash:\n{full_locator_output}"
);
let multi_get_output = adapter
.multi_get(
Some("work"),
std::slice::from_ref(&full_locator),
10,
51_200,
)
.expect("multi-get should accept full chunk locators");
assert!(
multi_get_output.contains(&locator),
"multi-get chunk output should use the same compact handle:\n{multi_get_output}"
);
assert!(
!multi_get_output.contains(&document.hash),
"multi-get chunk output should not expose the full document hash:\n{multi_get_output}"
);
let omitted_locators = vec![full_locator.clone(), full_locator.clone()];
let omitted_output = adapter
.multi_get(Some("work"), &omitted_locators, 1, 51_200)
.expect("multi-get should compact omitted chunk locators");
assert!(
omitted_output.contains(&locator),
"omitted chunk output should use the same compact handle:\n{omitted_output}"
);
assert!(
!omitted_output.contains(&document.hash),
"omitted chunk output should not expose the full document hash:\n{omitted_output}"
);
});
}
#[test]
fn group_search_results_uses_first_chunk_per_document_and_counts_duplicates() {
let results = vec![
make_search_result("doc-a", "docs/a.md", 0.95, "alpha first"),
make_search_result("doc-a", "docs/a.md", 0.91, "alpha second"),
make_search_result("doc-b", "docs/b.md", 0.88, "beta first"),
make_search_result("doc-c", "docs/c.md", 0.83, "gamma first"),
make_search_result("doc-b", "docs/b.md", 0.81, "beta second"),
];
let grouped = group_search_results(&results, 10);
assert_eq!(grouped.len(), 3);
assert_eq!(grouped[0].primary.docid, "doc-a");
assert_eq!(grouped[0].primary.text, "alpha first");
assert_eq!(grouped[0].additional_matches.len(), 1);
assert_eq!(grouped[1].primary.docid, "doc-b");
assert_eq!(grouped[1].primary.text, "beta first");
assert_eq!(grouped[1].additional_matches.len(), 1);
assert_eq!(grouped[2].primary.docid, "doc-c");
assert_eq!(grouped[2].additional_matches.len(), 0);
}
#[test]
fn group_search_results_respects_group_limit_but_counts_visible_duplicates() {
let results = vec![
make_search_result("doc-a", "docs/a.md", 0.95, "alpha first"),
make_search_result("doc-b", "docs/b.md", 0.90, "beta first"),
make_search_result("doc-c", "docs/c.md", 0.85, "gamma first"),
make_search_result("doc-b", "docs/b.md", 0.82, "beta second"),
make_search_result("doc-a", "docs/a.md", 0.80, "alpha second"),
];
let grouped = group_search_results(&results, 2);
assert_eq!(grouped.len(), 2);
assert_eq!(grouped[0].primary.docid, "doc-a");
assert_eq!(grouped[0].additional_matches.len(), 1);
assert_eq!(grouped[1].primary.docid, "doc-b");
assert_eq!(grouped[1].additional_matches.len(), 1);
assert!(
grouped.iter().all(|item| item.primary.docid != "doc-c"),
"unexpected grouped results: {:?}",
grouped
.iter()
.map(|item| item.primary.docid.as_str())
.collect::<Vec<_>>()
);
}
#[test]
fn group_search_results_does_not_merge_short_docid_collisions_across_spaces() {
let results = vec![
make_search_result_in_space(
"#abc123",
"default",
"docs/guide.md",
0.95,
"default guide",
),
make_search_result_in_space("#abc123", "work", "docs/guide.md", 0.90, "work guide"),
];
let grouped = group_search_results(&results, 10);
assert_eq!(grouped.len(), 2);
assert_eq!(grouped[0].primary.space, "default");
assert_eq!(grouped[0].primary.path, "docs/guide.md");
assert_eq!(grouped[0].additional_matches.len(), 0);
assert_eq!(grouped[1].primary.space, "work");
assert_eq!(grouped[1].primary.path, "docs/guide.md");
assert_eq!(grouped[1].additional_matches.len(), 0);
}
#[test]
fn search_groups_chunk_results_in_default_output() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
let docs_path = new_collection_dir(root.path(), "work-docs");
engine
.add_collection(AddCollectionRequest {
path: docs_path.clone(),
space: Some("work".to_string()),
name: Some("docs".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add collection");
fs::write(
docs_path.join("big.md"),
"# Big Guide\n\n## Part 1\nGuide systems depend on guide ranking.\n\
\n## Part 2\nGuide tuning changes guide retrieval quality.\n\
\n## Part 3\nGuide evaluation catches guide regressions.\n",
)
.expect("write big doc");
fs::write(
docs_path.join("small.md"),
"# Small Guide\n\nguide guide guide guide\n",
)
.expect("write small doc");
let adapter = CliAdapter::new(engine);
adapter
.update(Some("work"), &["docs".to_string()], true, false, false)
.expect("run update");
let space_row = adapter
.engine
.storage()
.get_space("work")
.expect("get space");
let collection_row = adapter
.engine
.storage()
.get_collection(space_row.id, "docs")
.expect("get collection");
let big_document = adapter
.engine
.storage()
.get_document_by_path(collection_row.id, "big.md")
.expect("get document")
.expect("document exists");
let short_locator_prefix = format!("#{}@", &big_document.hash[..6]);
let output = adapter
.search(CliSearchOptions {
space: Some("work"),
query: "guide",
collections: &["docs".to_string()],
limit: 2,
min_score: 0.0,
deep: false,
keyword: true,
semantic: false,
rerank: false,
no_rerank: false,
debug: false,
})
.expect("run grouped search");
assert!(
output.contains("2 documents"),
"unexpected output:\n{output}"
);
assert!(
output.contains("query: guide"),
"unexpected output:\n{output}"
);
assert!(
output.contains("mode: keyword"),
"unexpected output:\n{output}"
);
assert!(
output.matches("work/docs/big.md").count() == 1,
"unexpected output:\n{output}"
);
assert!(
output.contains("also found: 2 more chunks"),
"unexpected output:\n{output}"
);
assert!(
output.contains("section: Big Guide"),
"primary result should label heading metadata as a section, not a literal match:\n{output}"
);
assert!(
!output.contains("match:"),
"normal search output should not imply exact match metadata:\n{output}"
);
assert!(
output.contains("Big Guide > Part 2"),
"unexpected output:\n{output}"
);
assert!(
output.contains(&short_locator_prefix),
"unexpected output:\n{output}"
);
assert!(
!output.contains(&big_document.hash),
"normal output should not expose full hash:\n{output}"
);
let adapter = adapter.with_color(true);
let colored_output = adapter
.search(CliSearchOptions {
space: Some("work"),
query: "guide",
collections: &["docs".to_string()],
limit: 2,
min_score: 0.0,
deep: false,
keyword: true,
semantic: false,
rerank: false,
no_rerank: false,
debug: false,
})
.expect("run colored grouped search");
assert!(
colored_output.contains("\x1b["),
"colored search output should include ANSI emphasis:\n{colored_output}"
);
assert!(
colored_output.contains(&short_locator_prefix),
"colored output should keep compact locator:\n{colored_output}"
);
});
}
#[test]
fn search_debug_keeps_chunk_level_results() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
let docs_path = new_collection_dir(root.path(), "work-docs");
engine
.add_collection(AddCollectionRequest {
path: docs_path.clone(),
space: Some("work".to_string()),
name: Some("docs".to_string()),
description: None,
extensions: None,
no_index: true,
})
.expect("add collection");
fs::write(
docs_path.join("big.md"),
"# Big Guide\n\n## Part 1\nGuide systems depend on guide ranking.\n\
\n## Part 2\nGuide tuning changes guide retrieval quality.\n\
\n## Part 3\nGuide evaluation catches guide regressions.\n",
)
.expect("write big doc");
let adapter = CliAdapter::new(engine);
adapter
.update(Some("work"), &["docs".to_string()], true, false, false)
.expect("run update");
let space_row = adapter
.engine
.storage()
.get_space("work")
.expect("get space");
let collection_row = adapter
.engine
.storage()
.get_collection(space_row.id, "docs")
.expect("get collection");
let document = adapter
.engine
.storage()
.get_document_by_path(collection_row.id, "big.md")
.expect("get document")
.expect("document exists");
let adapter = adapter.with_color(true);
let output = adapter
.search(CliSearchOptions {
space: Some("work"),
query: "guide",
collections: &["docs".to_string()],
limit: 3,
min_score: 0.0,
deep: false,
keyword: true,
semantic: false,
rerank: false,
no_rerank: false,
debug: true,
})
.expect("run debug search");
assert!(
output.matches("work/docs/big.md").count() > 1,
"unexpected output:\n{output}"
);
assert!(
!output.contains("more matching section"),
"unexpected output:\n{output}"
);
assert!(
output.contains("results:\n- #"),
"debug output should use sectioned result rows:\n{output}"
);
assert!(output.contains("chunk: '#"), "unexpected output:\n{output}");
assert!(output.contains("snippet:"), "unexpected output:\n{output}");
assert!(
output.contains("elapsed:\n- "),
"unexpected output:\n{output}"
);
assert!(
output.contains(&document.hash),
"debug output should preserve full chunk locator:\n{output}"
);
assert!(
!output.contains("\x1b["),
"debug output should stay plain even if color is enabled:\n{output}"
);
});
}
#[test]
fn status_response_formats_human_storage_and_model_summary() {
let output = format_status_response(
&StatusResponse {
spaces: vec![SpaceStatus {
name: "default".to_string(),
description: Some("main workspace".to_string()),
last_updated: Some("2026-04-11T16:49:07Z".to_string()),
collections: vec![CollectionStatus {
name: "kbolt".to_string(),
path: PathBuf::from("/Users/macbook/kbolt"),
documents: 98,
active_documents: 98,
chunks: 1218,
embedded_chunks: 1218,
last_updated: "2026-04-11T16:49:07Z".to_string(),
}],
}],
models: kbolt_types::ModelStatus {
embedder: ModelInfo {
configured: true,
ready: false,
profile: Some("kbolt_local_embed".to_string()),
kind: Some("llama_cpp_server".to_string()),
operation: Some("embedding".to_string()),
model: Some("embeddinggemma".to_string()),
endpoint: Some("http://127.0.0.1:8103".to_string()),
issue: Some("endpoint is unreachable".to_string()),
},
reranker: ModelInfo {
configured: true,
ready: true,
profile: Some("kbolt_local_rerank".to_string()),
kind: Some("llama_cpp_server".to_string()),
operation: Some("reranking".to_string()),
model: Some("qwen3-reranker".to_string()),
endpoint: Some("http://127.0.0.1:8104".to_string()),
issue: None,
},
expander: ModelInfo {
configured: false,
ready: false,
profile: None,
kind: None,
operation: None,
model: None,
endpoint: None,
issue: None,
},
},
cache_dir: PathBuf::from("/Users/macbook/Library/Caches/kbolt"),
config_dir: PathBuf::from("/Users/macbook/Library/Application Support/kbolt"),
total_documents: 98,
total_chunks: 1218,
total_embedded: 1218,
disk_usage: DiskUsage {
sqlite_bytes: 348_160,
tantivy_bytes: 520_111,
usearch_bytes: 4_581_056,
models_bytes: 1_935_460_432,
total_bytes: 1_940_909_759,
},
},
Some("default"),
);
assert!(output.contains("spaces:"));
assert!(output.contains("- default (active)"));
assert!(output.contains(" description: main workspace"));
assert!(output.contains(" collections:"));
assert!(output.contains(" - kbolt"));
assert!(output.contains("storage:"));
assert!(
output.contains("- sqlite: 340 KB"),
"unexpected output:\n{output}"
);
assert!(
output.contains("- tantivy: 508 KB"),
"unexpected output:\n{output}"
);
assert!(
output.contains("- vectors: 4.4 MB"),
"unexpected output:\n{output}"
);
assert!(
output.contains("- models: 1.8 GB"),
"unexpected output:\n{output}"
);
assert!(
output.contains("- total: 1.8 GB"),
"unexpected output:\n{output}"
);
assert!(output.contains("- embedder: not ready (embeddinggemma)"));
assert!(output.contains(" issue: endpoint is unreachable"));
assert!(!output.contains("profile="), "unexpected output:\n{output}");
}
#[test]
fn active_space_name_for_status_follows_cli_precedence_without_validation() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create config root");
std::fs::write(
root.path().join("index.toml"),
"default_space = \"default\"\n",
)
.expect("write config");
let engine = Engine::new(Some(root.path())).expect("create engine");
std::env::remove_var("KBOLT_SPACE");
assert_eq!(
active_space_name_for_status(&engine, Some("work")).as_deref(),
Some("work")
);
std::env::set_var("KBOLT_SPACE", "ops");
assert_eq!(
active_space_name_for_status(&engine, None).as_deref(),
Some("ops")
);
std::env::remove_var("KBOLT_SPACE");
assert_eq!(
active_space_name_for_status(&engine, None).as_deref(),
Some("default")
);
});
}
#[test]
fn optional_search_signal_uses_human_values() {
assert_eq!(format_optional_search_signal(None), "-");
assert_eq!(format_optional_search_signal(Some(0.824)), "0.82");
}
#[test]
fn file_list_hides_docids_by_default() {
let output = format_file_list(
&[
FileEntry {
path: "docs/keep.md".to_string(),
title: "keep.md".to_string(),
docid: "#3c96dd".to_string(),
active: true,
chunk_count: 1,
embedded: false,
},
FileEntry {
path: "docs/old.md".to_string(),
title: "old.md".to_string(),
docid: "#deadbe".to_string(),
active: false,
chunk_count: 1,
embedded: false,
},
],
false,
);
assert!(
output.contains("- docs/keep.md (1 chunk(s), not embedded)"),
"unexpected output:\n{output}"
);
assert!(!output.contains("#3c96dd"), "unexpected output:\n{output}");
assert!(
!output.contains("keep.md |"),
"unexpected output:\n{output}"
);
}
#[test]
fn file_list_marks_inactive_files_with_all() {
let output = format_file_list(
&[FileEntry {
path: "docs/old.md".to_string(),
title: "old.md".to_string(),
docid: "#deadbe".to_string(),
active: false,
chunk_count: 1,
embedded: false,
}],
true,
);
assert!(output.contains("- docs/old.md (inactive, 1 chunk(s), not embedded)"));
assert!(!output.contains("#deadbe"));
}
#[test]
fn color_file_list_marks_paths_and_problem_states() {
let output = format_file_list_color(
&[
FileEntry {
path: "docs/keep.md".to_string(),
title: "keep.md".to_string(),
docid: "#3c96dd".to_string(),
active: true,
chunk_count: 1,
embedded: true,
},
FileEntry {
path: "docs/old.md".to_string(),
title: "old.md".to_string(),
docid: "#deadbe".to_string(),
active: false,
chunk_count: 1,
embedded: false,
},
],
true,
true,
);
assert!(output.contains("\x1b[34mdocs/keep.md\x1b[0m"));
assert!(output.contains("\x1b[90membedded\x1b[0m"));
assert!(output.contains("\x1b[33minactive\x1b[0m"));
assert!(output.contains("\x1b[33mnot embedded\x1b[0m"));
assert!(!output.contains("#deadbe"));
}
#[test]
fn ignore_commands_use_sectioned_output() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
engine
.add_collection(AddCollectionRequest {
path: new_collection_dir(root.path(), "work-api"),
space: Some("work".to_string()),
name: Some("api".to_string()),
description: None,
extensions: Some(vec!["md".to_string()]),
no_index: true,
})
.expect("add collection");
let adapter = CliAdapter::new(engine);
let empty = adapter
.ignore_show(Some("work"), "api")
.expect("show empty ignore");
assert!(empty.contains("ignore: work/api"));
assert!(empty.contains("contents:"));
assert!(empty.contains("- none"));
let added = adapter
.ignore_add(Some("work"), "api", "dist/")
.expect("add ignore pattern");
assert!(added.contains("ignore updated: work/api"));
assert!(added.contains("added:"));
assert!(added.contains("- dist/"));
let shown = adapter
.ignore_show(Some("work"), "api")
.expect("show ignore pattern");
assert!(shown.contains("contents:"));
assert!(shown.contains("- dist/"));
let listed = adapter
.ignore_list(Some("work"))
.expect("list ignore files");
assert!(listed.contains("ignore files:"));
assert!(listed.contains("- work/api: 1 pattern(s)"));
let missing = adapter
.ignore_remove(Some("work"), "api", "target/")
.expect("remove missing ignore pattern");
assert!(missing.contains("ignore unchanged: work/api"));
assert!(missing.contains("missing:"));
assert!(missing.contains("- target/"));
let removed = adapter
.ignore_remove(Some("work"), "api", "dist/")
.expect("remove ignore pattern");
assert!(removed.contains("ignore updated: work/api"));
assert!(removed.contains("removed:"));
assert!(removed.contains("- dist/"));
assert!(removed.contains("matches:"));
assert!(removed.contains("- 1"));
});
}
#[test]
fn ignore_show_labels_comments_as_contents_not_patterns() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
engine
.add_collection(AddCollectionRequest {
path: new_collection_dir(root.path(), "work-api"),
space: Some("work".to_string()),
name: Some("api".to_string()),
description: None,
extensions: Some(vec!["md".to_string()]),
no_index: true,
})
.expect("add collection");
let adapter = CliAdapter::new(engine);
adapter
.ignore_add(Some("work"), "api", "# generated files")
.expect("add comment line");
let shown = adapter
.ignore_show(Some("work"), "api")
.expect("show ignore contents");
assert!(shown.contains("contents:"));
assert!(!shown.contains("patterns:"), "unexpected output:\n{shown}");
assert!(shown.contains("- # generated files"));
let listed = adapter
.ignore_list(Some("work"))
.expect("list ignore files");
assert!(listed.contains("- work/api: 0 pattern(s)"));
});
}
#[test]
fn ignore_show_preserves_pattern_spacing_for_copy_paste() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
engine
.add_collection(AddCollectionRequest {
path: new_collection_dir(root.path(), "work-api"),
space: Some("work".to_string()),
name: Some("api".to_string()),
description: None,
extensions: Some(vec!["md".to_string()]),
no_index: true,
})
.expect("add collection");
let adapter = CliAdapter::new(engine);
adapter
.ignore_add(Some("work"), "api", " build/ ")
.expect("add spaced pattern");
let shown = adapter
.ignore_show(Some("work"), "api")
.expect("show ignore contents");
assert!(
shown.lines().any(|line| line == "- build/ "),
"expected exact spacing in output:\n{shown}"
);
});
}
#[test]
fn colored_ignore_show_preserves_pattern_spacing_for_copy_paste() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
engine
.add_collection(AddCollectionRequest {
path: new_collection_dir(root.path(), "work-api"),
space: Some("work".to_string()),
name: Some("api".to_string()),
description: None,
extensions: Some(vec!["md".to_string()]),
no_index: true,
})
.expect("add collection");
let adapter = CliAdapter::new(engine).with_color(true);
adapter
.ignore_add(Some("work"), "api", " build/ ")
.expect("add spaced pattern");
let shown = adapter
.ignore_show(Some("work"), "api")
.expect("show ignore contents");
assert!(shown.contains("\x1b[1;36mcontents:\x1b[0m"));
assert!(
shown.lines().any(|line| line == "- build/ "),
"expected exact spacing in output:\n{shown}"
);
});
}
#[test]
fn ignore_edit_reports_collection_and_path() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
engine
.add_collection(AddCollectionRequest {
path: new_collection_dir(root.path(), "work-api"),
space: Some("work".to_string()),
name: Some("api".to_string()),
description: None,
extensions: Some(vec!["md".to_string()]),
no_index: true,
})
.expect("add collection");
let adapter = CliAdapter::new(engine);
std::env::set_var("VISUAL", "/usr/bin/true");
std::env::remove_var("EDITOR");
let output = adapter
.ignore_edit(Some("work"), "api")
.expect("edit ignore file");
assert!(output.contains("ignore updated: work/api"));
assert!(output.contains(" path:"));
});
}
#[test]
fn collection_info_is_human_readable() {
let output = format_collection_info(&CollectionInfo {
name: "api".to_string(),
space: "work".to_string(),
path: PathBuf::from("/tmp/work-api"),
description: Some("API reference".to_string()),
extensions: Some(vec!["md".to_string(), "txt".to_string()]),
document_count: 97,
active_document_count: 96,
chunk_count: 1183,
embedded_chunk_count: 1180,
created: "2026-04-13T12:00:00Z".to_string(),
updated: "2026-04-14T09:30:00Z".to_string(),
});
assert!(output.contains("collection: work/api"));
assert!(output.contains("path: /tmp/work-api"));
assert!(output.contains("description: API reference"));
assert!(output.contains("extensions: md, txt"));
assert!(output.contains("documents:"));
assert!(output.contains("- 96 active / 97 total"));
assert!(output.contains("- 1183 chunks"));
assert!(output.contains("- 1180 embedded"));
assert!(output.contains("timestamps:"));
assert!(output.contains("- created 2026-04-13T12:00:00Z"));
assert!(output.contains("- updated 2026-04-14T09:30:00Z"));
assert!(
!output.contains("active_documents:"),
"unexpected output:\n{output}"
);
}
#[test]
fn document_response_is_human_readable() {
let output = format_document_response(
&DocumentResponse {
docid: "#409380".to_string(),
path: "kbolt/README.md".to_string(),
title: "README".to_string(),
space: "default".to_string(),
collection: "kbolt".to_string(),
content: "line one\nline two".to_string(),
stale: false,
total_lines: 68,
returned_lines: 5,
},
false,
);
assert!(output.contains("document: default/kbolt/README.md"));
assert!(output.contains("title: README"));
assert!(output.contains("docid: #409380"));
assert!(
!output.contains("shown:"),
"human get output should not expose logical line counts:\n{output}"
);
assert!(output.contains("\n\nline one\nline two"));
assert!(
!output.contains("stale: false"),
"unexpected output:\n{output}"
);
assert!(!output.contains("content:"), "unexpected output:\n{output}");
assert!(
!output.contains("collection: kbolt"),
"unexpected output:\n{output}"
);
}
#[test]
fn colored_document_response_keeps_body_plain() {
let output = format_document_response(
&DocumentResponse {
docid: "#409380".to_string(),
path: "kbolt/README.md".to_string(),
title: "README".to_string(),
space: "default".to_string(),
collection: "kbolt".to_string(),
content: "plain body line\n- copyable bullet".to_string(),
stale: false,
total_lines: 2,
returned_lines: 2,
},
true,
);
assert!(output.contains("document: \x1b[34mdefault/kbolt/README.md\x1b[0m"));
assert!(output.lines().any(|line| line == "plain body line"));
assert!(output.lines().any(|line| line == "- copyable bullet"));
}
#[test]
fn chunk_response_is_human_readable() {
let output = format_chunk_response(
&ChunkResponse {
locator: "#409380badc0ffee@3".to_string(),
docid: "#409380".to_string(),
chunk_ordinal: 3,
path: "kbolt/README.md".to_string(),
title: "README".to_string(),
space: "default".to_string(),
collection: "kbolt".to_string(),
heading: Some("Install".to_string()),
content: "line one\nline two".to_string(),
stale: false,
total_lines: 12,
returned_lines: 2,
},
"#409380@3",
false,
);
assert!(output.contains("README #409380@3"));
assert!(output.contains("path: default/kbolt/README.md"));
assert!(
!output.contains("#409380badc0ffee@3"),
"human chunk output should not expand compact handles:\n{output}"
);
assert!(output.contains("section: Install"));
assert!(
!output.contains("shown:"),
"human chunk output should not expose logical line counts:\n{output}"
);
assert!(output.contains("\n\nline one\nline two"));
assert!(
!output.contains("stale: false"),
"unexpected output:\n{output}"
);
assert!(!output.contains("content:"), "unexpected output:\n{output}");
assert!(
!output.contains("collection: kbolt"),
"unexpected output:\n{output}"
);
}
#[test]
fn colored_chunk_response_keeps_body_plain() {
let output = format_chunk_response(
&ChunkResponse {
locator: "#409380badc0ffee@3".to_string(),
docid: "#409380".to_string(),
chunk_ordinal: 3,
path: "kbolt/README.md".to_string(),
title: "README".to_string(),
space: "default".to_string(),
collection: "kbolt".to_string(),
heading: Some("Install".to_string()),
content: "plain chunk line\n- copyable bullet".to_string(),
stale: false,
total_lines: 2,
returned_lines: 2,
},
"#409380@3",
true,
);
assert!(output.contains("\x1b[34m#409380@3\x1b[0m"));
assert!(output.lines().any(|line| line == "plain chunk line"));
assert!(output.lines().any(|line| line == "- copyable bullet"));
}
#[test]
fn multi_get_response_is_human_readable() {
let mut chunk_display_locators = std::collections::HashMap::new();
chunk_display_locators.insert("#409380badc0ffee@3".to_string(), "#409380@3".to_string());
chunk_display_locators.insert("#409380badc0ffee@4".to_string(), "#409380@4".to_string());
let response = MultiGetResponse {
items: vec![
MultiGetItem::Document(DocumentResponse {
docid: "#409380".to_string(),
path: "kbolt/README.md".to_string(),
title: "README".to_string(),
space: "default".to_string(),
collection: "kbolt".to_string(),
content: "line one\nline two".to_string(),
stale: false,
total_lines: 68,
returned_lines: 68,
}),
MultiGetItem::Document(DocumentResponse {
docid: "#abcd12".to_string(),
path: "api/guide.md".to_string(),
title: "Guide".to_string(),
space: "work".to_string(),
collection: "api".to_string(),
content: "guide body".to_string(),
stale: true,
total_lines: 12,
returned_lines: 4,
}),
MultiGetItem::Chunk(ChunkResponse {
locator: "#409380badc0ffee@3".to_string(),
docid: "#409380".to_string(),
chunk_ordinal: 3,
path: "kbolt/README.md".to_string(),
title: "README".to_string(),
space: "default".to_string(),
collection: "kbolt".to_string(),
heading: Some("Install".to_string()),
content: "chunk body".to_string(),
stale: false,
total_lines: 1,
returned_lines: 1,
}),
],
omitted: vec![
OmittedItem {
kind: MultiGetItemKind::Document,
locator: "#large1".to_string(),
path: "api/large.md".to_string(),
docid: "#large1".to_string(),
space: "work".to_string(),
size_bytes: 8192,
reason: OmitReason::MaxBytes,
},
OmittedItem {
kind: MultiGetItemKind::Chunk,
locator: "#409380badc0ffee@4".to_string(),
path: "kbolt/README.md".to_string(),
docid: "#409380".to_string(),
space: "default".to_string(),
size_bytes: 4096,
reason: OmitReason::MaxFiles,
},
],
resolved_count: 4,
warnings: vec!["item not found: api/missing.md".to_string()],
};
let output = format_multi_get_response_color_with_chunk_locators(
&response,
false,
&chunk_display_locators,
);
assert!(output.contains("items:\n- 3 returned"));
assert!(output.contains("- 4 resolved"));
assert!(output.contains("1. default/kbolt/README.md"));
assert!(output.contains(" title: README"));
assert!(output.contains(" docid: #409380"));
assert!(
!output.contains(" shown:"),
"multi-get output should not expose logical line counts:\n{output}"
);
assert!(output.contains("2. work/api/guide.md"));
assert!(output.contains(" status: stale"));
assert!(output.contains("3. default/kbolt/README.md"));
assert!(output.contains(" locator: #409380@3"));
assert!(output.contains(" section: Install"));
assert!(output.contains("chunk body"));
assert!(
!output.contains("shown: 4 of 12 lines"),
"multi-get output should not expose logical line counts:\n{output}"
);
assert!(output.contains("omitted:"));
assert!(output.contains("- api/large.md (8.0 KB, size limit)"));
assert!(output.contains("- #409380@4 in kbolt/README.md (4.0 KB, max files)"));
assert!(
!output.contains("#409380badc0ffee"),
"multi-get human output should use compact chunk locators:\n{output}"
);
assert!(output.contains("warnings:"));
assert!(output.contains("- item not found: api/missing.md"));
assert!(
!output.contains("resolved_count:"),
"unexpected output:\n{output}"
);
assert!(
!output.contains("--- #409380"),
"unexpected output:\n{output}"
);
}
#[test]
fn multi_get_response_formats_empty_state() {
let output = format_multi_get_response(&MultiGetResponse {
items: Vec::new(),
omitted: Vec::new(),
resolved_count: 0,
warnings: Vec::new(),
});
assert_eq!(output, "items:\n- none returned");
}
#[test]
fn multi_get_response_reports_resolved_count_when_all_omitted() {
let output = format_multi_get_response(&MultiGetResponse {
items: Vec::new(),
omitted: vec![OmittedItem {
kind: MultiGetItemKind::Document,
locator: "#large1".to_string(),
path: "api/large.md".to_string(),
docid: "#large1".to_string(),
space: "work".to_string(),
size_bytes: 8192,
reason: OmitReason::MaxBytes,
}],
resolved_count: 1,
warnings: Vec::new(),
});
assert!(output.contains("items:\n- none returned\n- 1 resolved"));
assert!(output.contains("omitted:\n- api/large.md (8.0 KB, size limit)"));
}
#[test]
fn update_verbose_reports_buffered_decisions_before_summary() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
engine.add_space("work", None).expect("add work");
let collection_path = new_collection_dir(root.path(), "work-api");
engine
.add_collection(AddCollectionRequest {
path: collection_path.clone(),
space: Some("work".to_string()),
name: Some("api".to_string()),
description: None,
extensions: Some(vec!["rs".to_string()]),
no_index: true,
})
.expect("add collection");
let adapter = CliAdapter::new(engine);
fs::create_dir_all(collection_path.join("src")).expect("create src dir");
fs::write(collection_path.join("src/lib.rs"), "fn alpha() {}\n")
.expect("write valid file");
fs::write(collection_path.join("src/bad.rs"), [0xff, 0xfe, 0xfd])
.expect("write invalid file");
let output = adapter
.update(Some("work"), &["api".to_string()], true, false, true)
.expect("run verbose update");
let summary_index = output
.lines()
.position(|line| line == "update complete")
.expect("expected summary output");
assert!(summary_index > 0, "unexpected output: {output}");
assert!(
output.lines().next().unwrap_or_default() == "decisions:",
"unexpected output: {output}"
);
assert!(
output.contains("- work/api/src/lib.rs: new"),
"unexpected output: {output}"
);
assert!(
output.contains("- work/api/src/bad.rs: extract_failed (extract failed:"),
"unexpected output: {output}"
);
assert!(output.contains("summary:"), "unexpected output: {output}");
assert!(
output.contains("- 2 document(s) scanned"),
"unexpected output: {output}"
);
});
}
#[test]
fn collection_add_result_formats_no_index_message() {
let output = format_collection_add_result(&AddCollectionResult {
collection: CollectionInfo {
name: "api".to_string(),
space: "work".to_string(),
path: PathBuf::from("/tmp/work-api"),
description: None,
extensions: None,
document_count: 0,
active_document_count: 0,
chunk_count: 0,
embedded_chunk_count: 0,
created: "2026-03-31T00:00:00Z".to_string(),
updated: "2026-03-31T00:00:00Z".to_string(),
},
initial_indexing: InitialIndexingOutcome::Skipped,
});
assert!(output.contains("collection added: work/api"));
assert!(output.contains("indexing:"));
assert!(output.contains("- skipped (--no-index)"));
assert!(output.contains("next:"));
assert!(output.contains("- kbolt --space work update --collection api"));
}
#[test]
fn collection_add_result_formats_incomplete_initial_indexing() {
let output = format_collection_add_result(&AddCollectionResult {
collection: CollectionInfo {
name: "api".to_string(),
space: "work".to_string(),
path: PathBuf::from("/tmp/work-api"),
description: None,
extensions: None,
document_count: 3,
active_document_count: 3,
chunk_count: 3,
embedded_chunk_count: 2,
created: "2026-03-31T00:00:00Z".to_string(),
updated: "2026-03-31T00:00:00Z".to_string(),
},
initial_indexing: InitialIndexingOutcome::Indexed(UpdateReport {
scanned_docs: 3,
skipped_mtime_docs: 0,
skipped_hash_docs: 0,
added_docs: 2,
updated_docs: 0,
failed_docs: 1,
deactivated_docs: 0,
reactivated_docs: 0,
reaped_docs: 0,
embedded_chunks: 2,
decisions: Vec::new(),
errors: vec![kbolt_types::FileError {
path: "work/api/bad.md".to_string(),
error: "extract failed".to_string(),
}],
elapsed_ms: 5,
}),
});
assert!(output.contains("collection added: work/api"));
assert!(output.contains("initial indexing incomplete"));
assert!(output.contains("summary:"));
assert!(output.contains("- 3 document(s) scanned"));
assert!(output.contains("- 2 added"));
assert!(output.contains("- 1 failed"));
assert!(output.contains("- 2 chunk(s) embedded"));
assert!(output.contains("- completed in 5ms"));
assert!(output.contains("errors:"));
assert!(output.contains("- work/api/bad.md: extract failed"));
assert!(output.contains("next:"));
assert!(output.contains("- kbolt --space work update --collection api"));
}
#[test]
fn collection_add_result_formats_model_block_with_resume_steps() {
let output = format_collection_add_result(&AddCollectionResult {
collection: CollectionInfo {
name: "api".to_string(),
space: "work".to_string(),
path: PathBuf::from("/tmp/work-api"),
description: None,
extensions: None,
document_count: 0,
active_document_count: 0,
chunk_count: 0,
embedded_chunk_count: 0,
created: "2026-03-31T00:00:00Z".to_string(),
updated: "2026-03-31T00:00:00Z".to_string(),
},
initial_indexing: InitialIndexingOutcome::Blocked(
InitialIndexingBlock::ModelNotAvailable {
name: "embed-model".to_string(),
},
),
});
assert!(output.contains("collection added: work/api"));
assert!(output.contains("indexing blocked: model 'embed-model' is not available"));
assert!(output.contains("next:"));
assert!(output.contains("- kbolt setup local"));
assert!(output.contains("- or configure [roles.embedder] in index.toml"));
assert!(output.contains("- then run: kbolt --space work update --collection api"));
}
#[test]
fn update_report_is_human_readable() {
let output = format_update_report(
&UpdateReport {
scanned_docs: 12,
skipped_mtime_docs: 5,
skipped_hash_docs: 1,
added_docs: 3,
updated_docs: 2,
failed_docs: 1,
deactivated_docs: 0,
reactivated_docs: 1,
reaped_docs: 0,
embedded_chunks: 8,
decisions: Vec::new(),
errors: vec![kbolt_types::FileError {
path: "work/api/src/bad.rs".to_string(),
error: "extract failed".to_string(),
}],
elapsed_ms: 1_250,
},
false,
false,
);
assert!(output.starts_with("update complete"));
assert!(output.contains("summary:"));
assert!(output.contains("- 12 document(s) scanned"));
assert!(output.contains("- 6 unchanged"));
assert!(output.contains("- 3 added"));
assert!(output.contains("- 2 updated"));
assert!(output.contains("- 1 failed"));
assert!(output.contains("- 1 reactivated"));
assert!(output.contains("- 8 chunk(s) embedded"));
assert!(output.contains("- completed in 1.2s"));
assert!(output.contains("errors:"));
assert!(output.contains("- work/api/src/bad.rs: extract failed"));
assert!(
!output.contains("scanned_docs:"),
"unexpected output:\n{output}"
);
}
#[test]
fn update_report_mentions_no_embed_skip() {
let report = make_update_report(Vec::new(), 0);
let output = format_update_report(&report, false, true);
assert!(
output.contains("- embedding skipped (--no-embed)"),
"expected no-embed summary line: {output}"
);
assert!(
output.contains("- completed in 0ms"),
"expected elapsed summary to remain present: {output}"
);
}
#[test]
fn colored_update_report_uses_warning_error_and_action_colors() {
let report = make_update_report(
vec![
make_file_error("a.md"),
make_file_error("b.md"),
make_file_error("c.md"),
make_file_error("d.md"),
],
1,
);
let output = format_update_report_color(&report, false, true, true);
assert!(output.contains("\x1b[31m1 failed\x1b[0m"));
assert!(output.contains("\x1b[31ma.md: test failure\x1b[0m"));
assert!(output.contains("\x1b[33membedding skipped (--no-embed)\x1b[0m"));
assert!(output.contains("\x1b[32mrun with --verbose for the full error list\x1b[0m"));
}
#[test]
fn colored_collection_add_skip_marks_status_and_next_action() {
let result = AddCollectionResult {
collection: make_collection_info("team notes", "cold docs"),
initial_indexing: InitialIndexingOutcome::Skipped,
};
let output = format_collection_add_result_color(&result, true);
assert!(output.contains("\x1b[33mskipped (--no-index)\x1b[0m"));
assert!(
output.contains(
"\x1b[32mkbolt --space 'team notes' update --collection 'cold docs'\x1b[0m"
),
"expected colored, quoted next command: {output}"
);
assert!(!output.contains("\x1b[32mcollection added"));
let plain_output = format_collection_add_result_color(&result, false);
assert!(!plain_output.contains("\x1b["));
assert!(plain_output.contains("- skipped (--no-index)"));
assert!(
plain_output.contains("- kbolt --space 'team notes' update --collection 'cold docs'")
);
}
#[test]
fn update_verbose_reports_unmatched_errors_before_summary() {
let report = UpdateReport {
scanned_docs: 1,
skipped_mtime_docs: 0,
skipped_hash_docs: 0,
added_docs: 0,
updated_docs: 0,
failed_docs: 1,
deactivated_docs: 0,
reactivated_docs: 0,
reaped_docs: 0,
embedded_chunks: 0,
decisions: Vec::new(),
errors: vec![kbolt_types::FileError {
path: "work/api/missing.md".to_string(),
error: "read failed".to_string(),
}],
elapsed_ms: 4,
};
let output = format_update_report(&report, true, false);
assert!(
output.starts_with("errors:\n- work/api/missing.md: read failed"),
"unexpected output:\n{output}"
);
assert!(output.contains("\n\nupdate complete\n"));
assert!(output.contains("summary:"));
assert!(output.contains("- 1 failed"));
}
#[test]
fn format_elapsed_ms_uses_human_units() {
assert_eq!(format_elapsed_ms(8), "8ms");
assert_eq!(format_elapsed_ms(1_250), "1.2s");
assert_eq!(format_elapsed_ms(125_000), "2.1m");
}
#[test]
fn space_acknowledgements_remain_plain_without_color() {
assert_eq!(
format_space_add_response("work", false),
"space added: work"
);
assert_eq!(
format_space_describe_response("work", false),
"space description updated: work"
);
assert_eq!(
format_space_rename_response("work", "team", false),
"space renamed: work -> team"
);
assert_eq!(
format_space_remove_response("team", false),
"removed: space team"
);
assert_eq!(
format_space_remove_default_response(false),
"removed: space default\ndefault space: cleared"
);
assert_eq!(
format_space_default_response(Some("team"), false),
"default space: team"
);
assert_eq!(
format_space_current_response(Some(("team", "default")), false),
"active space: team (default)"
);
}
#[test]
fn colored_space_acknowledgements_mark_identities() {
assert_eq!(
format_space_add_response("work", true),
"space added: \x1b[34mwork\x1b[0m"
);
assert_eq!(
format_space_describe_response("work", true),
"space description updated: \x1b[34mwork\x1b[0m"
);
assert_eq!(
format_space_rename_response("work", "team", true),
"space renamed: \x1b[34mwork\x1b[0m -> \x1b[34mteam\x1b[0m"
);
assert_eq!(
format_space_remove_response("team", true),
"removed: space \x1b[34mteam\x1b[0m"
);
assert_eq!(
format_space_remove_default_response(true),
"removed: space \x1b[34mdefault\x1b[0m\ndefault space: \x1b[33mcleared\x1b[0m"
);
assert_eq!(
format_space_default_response(Some("team"), true),
"default space: \x1b[34mteam\x1b[0m"
);
assert_eq!(
format_space_current_response(Some(("team", "default")), true),
"active space: \x1b[34mteam\x1b[0m (\x1b[90mdefault\x1b[0m)"
);
}
#[test]
fn collection_acknowledgements_remain_plain_without_color() {
assert_eq!(
format_collection_describe_response("docs", false),
"collection description updated: docs"
);
assert_eq!(
format_collection_rename_response("docs", "notes", false),
"collection renamed: docs -> notes"
);
assert_eq!(
format_collection_remove_response("work", "notes", false),
"removed: collection notes\nspace: work"
);
}
#[test]
fn colored_collection_acknowledgements_mark_identities() {
assert_eq!(
format_collection_describe_response("docs", true),
"collection description updated: \x1b[34mdocs\x1b[0m"
);
assert_eq!(
format_collection_rename_response("docs", "notes", true),
"collection renamed: \x1b[34mdocs\x1b[0m -> \x1b[34mnotes\x1b[0m"
);
assert_eq!(
format_collection_remove_response("work", "notes", true),
"removed: collection \x1b[34mnotes\x1b[0m\nspace: \x1b[34mwork\x1b[0m"
);
}
#[test]
fn space_add_with_directories_reports_registration_without_indexing() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
let mut adapter = CliAdapter::new(engine);
let work_path = new_collection_dir(root.path(), "work-api");
let notes_path = new_collection_dir(root.path(), "work-notes");
let output = adapter
.space_add("work", Some("work docs"), false, &[work_path, notes_path])
.expect("add space with directories");
assert!(output.contains("space added: work"));
assert!(output.contains("description: work docs"));
assert!(output.contains("collections:"));
assert!(output.contains("- 2 registered"));
assert!(output.contains("indexing:"));
assert!(output.contains("- skipped (collections registered only)"));
assert!(output.contains("next:"));
assert!(output.contains("- kbolt --space work update"));
});
}
#[test]
fn space_add_without_directories_reports_created_space() {
with_isolated_xdg_dirs(|| {
let engine = Engine::new(None).expect("create engine");
let mut adapter = CliAdapter::new(engine);
let output = adapter
.space_add("work", Some("work docs"), false, &[])
.expect("add space");
assert!(
output.starts_with("space added: work\n"),
"unexpected output:\n{output}"
);
assert!(output.contains("description: work docs"));
assert!(!output.contains("next:"), "unexpected output:\n{output}");
});
}
#[test]
fn space_add_with_partial_directory_failure_keeps_indexing_state_visible() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
let mut adapter = CliAdapter::new(engine);
let valid_path = new_collection_dir(root.path(), "work-api");
let missing_path = root.path().join("missing-docs");
let output = adapter
.space_add("work", None, false, &[valid_path, missing_path.clone()])
.expect("add space with partial failure");
assert!(output.contains("space added: work"));
assert!(output.contains("collections:"));
assert!(output.contains("- 1 registered"));
assert!(output.contains("failed:"));
assert!(output.contains("- 1 collection(s)"));
assert!(output.contains(&missing_path.display().to_string()));
assert!(output.contains("indexing:"));
assert!(output.contains("- skipped (collections registered only)"));
assert!(output.contains("- kbolt --space work update"));
});
}
#[test]
fn space_list_and_info_use_sectioned_summaries() {
with_isolated_xdg_dirs(|| {
let engine = Engine::new(None).expect("create engine");
engine
.add_space("work", Some("work docs"))
.expect("add work");
let adapter = CliAdapter::new(engine);
let list_output = adapter.space_list().expect("list spaces");
assert!(list_output.contains("spaces:"));
assert!(
list_output.contains("- work: 0 collection(s), 0 document(s), 0 chunk(s)"),
"unexpected output:\n{list_output}"
);
assert!(list_output.contains("description: work docs"));
let info_output = adapter.space_info("work").expect("space info");
assert!(
info_output.starts_with("space: work\n"),
"unexpected output:\n{info_output}"
);
assert!(info_output.contains("description: work docs"));
assert!(info_output.contains("contents:"));
assert!(info_output.contains("- 0 collection(s)"));
assert!(info_output.contains("timestamps:"));
});
}
#[test]
fn format_schedule_add_response_renders_trigger_scope_and_backend() {
let output = format_schedule_add_response(&ScheduleAddResponse {
schedule: ScheduleDefinition {
id: "s1".to_string(),
trigger: ScheduleTrigger::Every {
interval: ScheduleInterval {
value: 30,
unit: ScheduleIntervalUnit::Minutes,
},
},
scope: ScheduleScope::All,
},
backend: ScheduleBackend::Launchd,
});
assert_eq!(
output,
"schedule added: s1\n\ndetails:\n- trigger: every 30m\n- scope: all spaces\n- backend: launchd"
);
}
#[test]
fn format_schedule_status_response_renders_entries_and_orphans() {
let output = format_schedule_status_response(&ScheduleStatusResponse {
schedules: vec![
ScheduleStatusEntry {
schedule: ScheduleDefinition {
id: "s2".to_string(),
trigger: ScheduleTrigger::Weekly {
weekdays: vec![ScheduleWeekday::Mon, ScheduleWeekday::Fri],
time: "15:00".to_string(),
},
scope: ScheduleScope::Collections {
space: "work".to_string(),
collections: vec!["api".to_string(), "docs".to_string()],
},
},
backend: ScheduleBackend::Launchd,
state: ScheduleState::Drifted,
run_state: ScheduleRunState {
last_started: Some("2026-03-07T20:00:00Z".to_string()),
last_finished: Some("2026-03-07T20:00:05Z".to_string()),
last_result: Some(ScheduleRunResult::SkippedLock),
last_error: Some("lock was held".to_string()),
},
},
ScheduleStatusEntry {
schedule: ScheduleDefinition {
id: "s3".to_string(),
trigger: ScheduleTrigger::Daily {
time: "06:30".to_string(),
},
scope: ScheduleScope::Space {
space: "research".to_string(),
},
},
backend: ScheduleBackend::SystemdUser,
state: ScheduleState::TargetMissing,
run_state: ScheduleRunState {
last_started: None,
last_finished: None,
last_result: Some(ScheduleRunResult::Failed),
last_error: None,
},
},
],
orphans: vec![ScheduleOrphan {
id: "s9".to_string(),
backend: ScheduleBackend::Launchd,
}],
});
assert!(output.contains("schedules:\n- s2"));
assert!(output.contains(" trigger: mon, fri at 3:00 PM"));
assert!(output.contains(" scope: work/api, work/docs"));
assert!(output.contains(" backend: launchd"));
assert!(output.contains(" state: drifted"));
assert!(output.contains(" last started: 2026-03-07T20:00:00Z"));
assert!(output.contains(" last finished: 2026-03-07T20:00:05Z"));
assert!(output.contains(" last result: skipped lock"));
assert!(output.contains(" last error: lock was held"));
assert!(output.contains("- s3"));
assert!(output.contains(" trigger: daily at 6:30 AM"));
assert!(output.contains(" scope: space research"));
assert!(output.contains(" backend: systemd-user"));
assert!(output.contains(" state: target missing"));
assert!(output.contains(" last started: never"));
assert!(output.contains(" last finished: never"));
assert!(output.contains(" last result: failed"));
assert!(output.contains("orphans:\n- s9 (launchd)"));
}
#[test]
fn colored_schedule_status_marks_problem_states_by_severity() {
let output = format_schedule_status_response_color(
&ScheduleStatusResponse {
schedules: vec![
ScheduleStatusEntry {
schedule: ScheduleDefinition {
id: "s2".to_string(),
trigger: ScheduleTrigger::Weekly {
weekdays: vec![ScheduleWeekday::Mon],
time: "15:00".to_string(),
},
scope: ScheduleScope::Space {
space: "work".to_string(),
},
},
backend: ScheduleBackend::Launchd,
state: ScheduleState::Drifted,
run_state: ScheduleRunState {
last_started: Some("2026-03-07T20:00:00Z".to_string()),
last_finished: Some("2026-03-07T20:00:05Z".to_string()),
last_result: Some(ScheduleRunResult::SkippedLock),
last_error: Some("lock was held".to_string()),
},
},
ScheduleStatusEntry {
schedule: ScheduleDefinition {
id: "s3".to_string(),
trigger: ScheduleTrigger::Daily {
time: "06:30".to_string(),
},
scope: ScheduleScope::Space {
space: "research".to_string(),
},
},
backend: ScheduleBackend::SystemdUser,
state: ScheduleState::TargetMissing,
run_state: ScheduleRunState {
last_started: None,
last_finished: None,
last_result: Some(ScheduleRunResult::Failed),
last_error: None,
},
},
],
orphans: vec![ScheduleOrphan {
id: "s9".to_string(),
backend: ScheduleBackend::Launchd,
}],
},
true,
);
assert!(
output.contains("state: \x1b[33mdrifted\x1b[0m"),
"drifted schedules should be warning-colored:\n{output}"
);
assert!(
output.contains("last result: \x1b[33mskipped lock\x1b[0m"),
"skipped lock result should be warning-colored:\n{output}"
);
assert!(
output.contains("last error: \x1b[31mlock was held\x1b[0m"),
"last error should be error-colored:\n{output}"
);
assert!(
output.contains("state: \x1b[33mtarget missing\x1b[0m"),
"target missing schedules should be warning-colored:\n{output}"
);
assert!(
output.contains("last result: \x1b[31mfailed\x1b[0m"),
"failed result should be error-colored:\n{output}"
);
assert!(
output.contains("\x1b[33ms9 (launchd)\x1b[0m"),
"orphan backend artifact should be warning-colored:\n{output}"
);
}
#[test]
fn format_schedule_remove_response_renders_removed_id() {
let output = format_schedule_remove_response(
&RemoveScheduleSelector::Id {
id: "s1".to_string(),
},
&ScheduleRemoveResponse {
removed_ids: vec!["s1".to_string()],
},
);
assert_eq!(output, "removed: schedule s1");
}
#[test]
fn format_schedule_remove_response_renders_all_scope() {
let output = format_schedule_remove_response(
&RemoveScheduleSelector::All,
&ScheduleRemoveResponse {
removed_ids: vec!["s1".to_string(), "s2".to_string()],
},
);
assert_eq!(output, "removed: all schedules\nscope: all spaces");
}
#[test]
fn format_schedule_remove_response_renders_space_scope() {
let output = format_schedule_remove_response(
&RemoveScheduleSelector::Scope {
scope: ScheduleScope::Space {
space: "uiux".to_string(),
},
},
&ScheduleRemoveResponse {
removed_ids: vec!["s1".to_string(), "s2".to_string()],
},
);
assert_eq!(output, "removed: schedules\nspace: uiux");
}
#[test]
fn format_schedule_remove_response_renders_collection_scope() {
let output = format_schedule_remove_response(
&RemoveScheduleSelector::Scope {
scope: ScheduleScope::Collections {
space: "uiux".to_string(),
collections: vec!["docs".to_string(), "notes".to_string()],
},
},
&ScheduleRemoveResponse {
removed_ids: vec!["s1".to_string(), "s2".to_string()],
},
);
assert_eq!(
output,
"removed: schedules\nspace: uiux\ncollections: docs, notes"
);
}
#[test]
fn format_schedule_remove_response_renders_empty_state() {
let output = format_schedule_remove_response(
&RemoveScheduleSelector::All,
&ScheduleRemoveResponse {
removed_ids: Vec::new(),
},
);
assert_eq!(output, "removed: no schedules\nscope: all spaces");
}
#[test]
fn colored_schedule_remove_response_marks_identities() {
let output = format_schedule_remove_response_color(
&RemoveScheduleSelector::Scope {
scope: ScheduleScope::Collections {
space: "uiux".to_string(),
collections: vec!["docs".to_string(), "notes".to_string()],
},
},
&ScheduleRemoveResponse {
removed_ids: vec!["s1".to_string()],
},
true,
);
assert_eq!(
output,
"removed: schedules\nspace: \x1b[34muiux\x1b[0m\ncollections: \x1b[34mdocs\x1b[0m, \x1b[34mnotes\x1b[0m"
);
}
fn make_update_report(errors: Vec<FileError>, failed_docs: usize) -> UpdateReport {
UpdateReport {
scanned_docs: 0,
skipped_mtime_docs: 0,
skipped_hash_docs: 0,
added_docs: 0,
updated_docs: 0,
failed_docs,
deactivated_docs: 0,
reactivated_docs: 0,
reaped_docs: 0,
embedded_chunks: 0,
decisions: Vec::new(),
errors,
elapsed_ms: 0,
}
}
fn make_file_error(path: &str) -> FileError {
FileError {
path: path.to_string(),
error: "test failure".to_string(),
}
}
fn make_collection_info(space: &str, name: &str) -> CollectionInfo {
CollectionInfo {
name: name.to_string(),
space: space.to_string(),
path: PathBuf::from("/tmp/x"),
description: None,
extensions: None,
document_count: 0,
active_document_count: 0,
chunk_count: 0,
embedded_chunk_count: 0,
created: "2026-04-18T00:00:00Z".to_string(),
updated: "2026-04-18T00:00:00Z".to_string(),
}
}
#[test]
fn append_update_error_lines_returns_true_when_truncated() {
let report = make_update_report(
vec![
make_file_error("a.md"),
make_file_error("b.md"),
make_file_error("c.md"),
make_file_error("d.md"),
],
4,
);
let mut lines = Vec::new();
let truncated = append_update_error_lines(&mut lines, &report, 3);
assert!(truncated);
assert!(lines.iter().any(|l| l == "- 1 more error(s)"));
}
#[test]
fn append_update_error_lines_returns_false_within_limit() {
let report = make_update_report(vec![make_file_error("a.md"), make_file_error("b.md")], 2);
let mut lines = Vec::new();
let truncated = append_update_error_lines(&mut lines, &report, 3);
assert!(!truncated);
assert!(!lines.iter().any(|l| l.contains("more error(s)")));
}
#[test]
fn format_update_report_emits_verbose_hint_on_truncation() {
let report = make_update_report(
vec![
make_file_error("a.md"),
make_file_error("b.md"),
make_file_error("c.md"),
make_file_error("d.md"),
],
4,
);
let output = format_update_report(&report, false, false);
assert!(
output.contains("- 1 more error(s)"),
"expected truncation summary: {output}"
);
assert!(
output.contains("next:\n- run with --verbose for the full error list"),
"expected verbose hint: {output}"
);
}
#[test]
fn format_update_report_omits_verbose_hint_without_truncation() {
let report = make_update_report(vec![make_file_error("a.md"), make_file_error("b.md")], 2);
let output = format_update_report(&report, false, false);
assert!(
!output.contains("more error(s)"),
"expected no truncation: {output}"
);
assert!(
!output.contains("--verbose"),
"expected no verbose hint: {output}"
);
}
#[test]
fn format_collection_add_indexing_upgrades_next_to_verbose_when_truncated() {
let collection = make_collection_info("default", "notes");
let report = make_update_report(
vec![
make_file_error("a.md"),
make_file_error("b.md"),
make_file_error("c.md"),
make_file_error("d.md"),
],
4,
);
let result = AddCollectionResult {
collection,
initial_indexing: InitialIndexingOutcome::Indexed(report),
};
let output = format_collection_add_result(&result);
assert!(
output.contains("kbolt --space default update --verbose --collection notes"),
"expected --verbose in next command: {output}"
);
}
#[test]
fn format_collection_add_indexing_omits_verbose_when_not_truncated() {
let collection = make_collection_info("default", "notes");
let report = make_update_report(vec![make_file_error("a.md"), make_file_error("b.md")], 2);
let result = AddCollectionResult {
collection,
initial_indexing: InitialIndexingOutcome::Indexed(report),
};
let output = format_collection_add_result(&result);
assert!(
output.contains("kbolt --space default update --collection notes"),
"expected plain update command: {output}"
);
assert!(
!output.contains("--verbose"),
"expected no verbose flag: {output}"
);
}
#[test]
fn format_collection_add_indexing_emits_next_block_when_truncated_without_failed_docs() {
let collection = make_collection_info("default", "notes");
let report = make_update_report(
vec![
make_file_error("a.md"),
make_file_error("b.md"),
make_file_error("c.md"),
make_file_error("d.md"),
],
0, );
let result = AddCollectionResult {
collection,
initial_indexing: InitialIndexingOutcome::Indexed(report),
};
let output = format_collection_add_result(&result);
assert!(
output.contains("next:"),
"expected a next block despite failed_docs=0: {output}"
);
assert!(
output.contains("kbolt --space default update --verbose --collection notes"),
"expected --verbose update in next: {output}"
);
}
#[test]
fn format_collection_add_indexing_shell_quotes_space_and_name_in_next() {
let collection = make_collection_info("team notes", "cold docs");
let report = make_update_report(
vec![
make_file_error("a.md"),
make_file_error("b.md"),
make_file_error("c.md"),
make_file_error("d.md"),
],
4,
);
let result = AddCollectionResult {
collection,
initial_indexing: InitialIndexingOutcome::Indexed(report),
};
let output = format_collection_add_result(&result);
assert!(
output.contains("kbolt --space 'team notes' update --verbose --collection 'cold docs'"),
"expected space and name single-quoted: {output}"
);
}
#[test]
fn space_add_note_shell_quotes_space_name_with_whitespace() {
with_isolated_xdg_dirs(|| {
let root = tempdir().expect("create collection root");
let engine = Engine::new(None).expect("create engine");
let mut adapter = CliAdapter::new(engine);
let dir = new_collection_dir(root.path(), "some-collection");
let output = adapter
.space_add("team notes", None, false, &[dir])
.expect("add space with directories");
assert!(
output.contains("- kbolt --space 'team notes' update"),
"expected quoted space in registration note: {output}"
);
});
}
#[test]
fn format_collection_add_result_skipped_shell_quotes_space_and_name() {
let collection = make_collection_info("team notes", "cold docs");
let result = AddCollectionResult {
collection,
initial_indexing: InitialIndexingOutcome::Skipped,
};
let output = format_collection_add_result(&result);
assert!(
output.contains("kbolt --space 'team notes' update --collection 'cold docs'"),
"expected quoted space and name in next block: {output}"
);
}
#[test]
fn format_collection_add_block_space_dense_repair_quotes_space_name() {
let collection = make_collection_info("default", "notes");
let result = AddCollectionResult {
collection,
initial_indexing: InitialIndexingOutcome::Blocked(
InitialIndexingBlock::SpaceDenseRepairRequired {
space: "team notes".to_string(),
reason: "dimension mismatch".to_string(),
},
),
};
let output = format_collection_add_result(&result);
assert!(
output.contains("kbolt --space 'team notes' update"),
"expected quoted space in repair-required command: {output}"
);
}
#[test]
fn format_collection_add_block_model_not_available_quotes_space_and_name() {
let collection = make_collection_info("team notes", "cold docs");
let result = AddCollectionResult {
collection,
initial_indexing: InitialIndexingOutcome::Blocked(
InitialIndexingBlock::ModelNotAvailable {
name: "embedder".to_string(),
},
),
};
let output = format_collection_add_result(&result);
assert!(
output.contains("then run: kbolt --space 'team notes' update --collection 'cold docs'"),
"expected quoted space and name in model-unavailable command: {output}"
);
}
#[test]
fn format_eval_import_report_shell_quotes_space_collection_and_paths() {
let report = EvalImportReport {
dataset: "fiqa".to_string(),
source: "/tmp/fiqa".to_string(),
output_dir: "/tmp/out".to_string(),
corpus_dir: "/Users/me/My Data/corpus".to_string(),
manifest_path: "/Users/me/My Data/eval.toml".to_string(),
default_space: "bench space".to_string(),
collection: "fiqa docs".to_string(),
document_count: 0,
query_count: 0,
judgment_count: 0,
};
let output = format_eval_import_report(&report);
assert!(
output.contains("kbolt space add 'bench space'"),
"expected quoted space in create command: {output}"
);
assert!(
output.contains("kbolt --space 'bench space' collection add '/Users/me/My Data/corpus' --name 'fiqa docs' --no-index"),
"expected quoted args in register command: {output}"
);
assert!(
output.contains("kbolt --space 'bench space' update --collection 'fiqa docs'"),
"expected quoted args in index command: {output}"
);
assert!(
output.contains("kbolt eval run --file '/Users/me/My Data/eval.toml'"),
"expected quoted manifest path in eval run command: {output}"
);
}
#[test]
fn colored_eval_import_report_marks_paths_and_command_segments() {
let report = EvalImportReport {
dataset: "fiqa".to_string(),
source: "/tmp/fiqa".to_string(),
output_dir: "/tmp/out".to_string(),
corpus_dir: "/Users/me/My Data/corpus".to_string(),
manifest_path: "/Users/me/My Data/eval.toml".to_string(),
default_space: "bench space".to_string(),
collection: "fiqa docs".to_string(),
document_count: 0,
query_count: 0,
judgment_count: 0,
};
let output = format_eval_import_report_color(&report, true);
assert!(output.contains("source: \x1b[34m/tmp/fiqa\x1b[0m"));
assert!(output.contains(
"create the benchmark space if needed: \x1b[32mkbolt space add 'bench space'\x1b[0m"
));
assert!(output.contains(
"run eval: \x1b[32mkbolt eval run --file '/Users/me/My Data/eval.toml'\x1b[0m"
));
assert!(!output.contains("\x1b[32mcreate the benchmark space if needed"));
}
}