1pub mod args;
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use kbolt_core::engine::Engine;
7use kbolt_core::Result;
8use kbolt_types::{
9 ActiveSpaceSource, AddCollectionRequest, AddCollectionResult, AddScheduleRequest,
10 CollectionInfo, DoctorCheck, DoctorCheckStatus, DoctorReport, DoctorSetupStatus,
11 DocumentResponse, EvalImportReport, EvalRunReport, FileEntry, GetRequest, InitialIndexingBlock,
12 InitialIndexingOutcome, KboltError, LocalAction, LocalReport, Locator, ModelInfo,
13 MultiGetRequest, MultiGetResponse, OmitReason, RemoveScheduleRequest, ScheduleAddResponse,
14 ScheduleBackend, ScheduleInterval, ScheduleIntervalUnit, ScheduleRunResult, ScheduleScope,
15 ScheduleState, ScheduleStatusResponse, ScheduleTrigger, ScheduleWeekday, SearchMode,
16 SearchPipeline, SearchPipelineNotice, SearchPipelineStep, SearchPipelineUnavailableReason,
17 SearchRequest, StatusResponse, UpdateDecision, UpdateDecisionKind, UpdateOptions, UpdateReport,
18};
19
20pub struct CliAdapter {
21 pub engine: Engine,
22}
23
24pub struct CliSearchOptions<'a> {
25 pub space: Option<&'a str>,
26 pub query: &'a str,
27 pub collections: &'a [String],
28 pub limit: usize,
29 pub min_score: f32,
30 pub deep: bool,
31 pub keyword: bool,
32 pub semantic: bool,
33 pub rerank: bool,
34 pub no_rerank: bool,
35 pub debug: bool,
36}
37
38struct GroupedSearchResult<'a> {
39 primary: &'a kbolt_types::SearchResult,
40 additional_matches: usize,
41}
42
43impl CliAdapter {
44 pub fn new(engine: Engine) -> Self {
45 Self { engine }
46 }
47
48 pub fn space_add(
49 &mut self,
50 name: &str,
51 description: Option<&str>,
52 strict: bool,
53 dirs: &[std::path::PathBuf],
54 ) -> Result<String> {
55 if strict {
56 use std::collections::HashSet;
57
58 let mut validation_errors = Vec::new();
59 let mut derived_names = HashSet::new();
60 for dir in dirs {
61 if !dir.is_absolute() || !dir.is_dir() {
62 validation_errors.push(format!("- {} -> invalid path", dir.display()));
63 continue;
64 }
65
66 let collection_name = dir.file_name().and_then(|item| item.to_str());
67 match collection_name {
68 Some(name) => {
69 if !derived_names.insert(name.to_string()) {
70 validation_errors.push(format!(
71 "- {} -> duplicate derived collection name '{name}'",
72 dir.display()
73 ));
74 }
75 }
76 None => validation_errors.push(format!(
77 "- {} -> cannot derive collection name from path",
78 dir.display()
79 )),
80 }
81 }
82
83 if !validation_errors.is_empty() {
84 let mut lines = Vec::new();
85 lines.push("strict mode aborted: one or more directories are invalid".to_string());
86 lines.extend(validation_errors);
87 return Err(kbolt_types::KboltError::InvalidInput(lines.join("\n")).into());
88 }
89 }
90
91 let added = self.engine.add_space(name, description)?;
92 let description = added.description.unwrap_or_default();
93 let suffix = if description.is_empty() {
94 String::new()
95 } else {
96 format!(" - {description}")
97 };
98
99 if dirs.is_empty() {
100 return Ok(format!("space added: {}{suffix}", added.name));
101 }
102
103 let mut successes = Vec::new();
104 let mut failures = Vec::new();
105 for dir in dirs {
106 let collection_name = dir
107 .file_name()
108 .and_then(|item| item.to_str())
109 .map(ToString::to_string);
110
111 let result = self.engine.add_collection(AddCollectionRequest {
112 path: dir.clone(),
113 space: Some(name.to_string()),
114 name: collection_name,
115 description: None,
116 extensions: None,
117 no_index: true,
118 });
119
120 match result {
121 Ok(info) => successes.push(format!(
122 "- {} -> {}/{}",
123 dir.display(),
124 info.collection.space,
125 info.collection.name
126 )),
127 Err(err) => {
128 if strict {
129 let rollback_result = self.engine.remove_space(name);
130 return match rollback_result {
131 Ok(()) => Err(err),
132 Err(rollback_err) => Err(kbolt_types::KboltError::Internal(format!(
133 "strict mode rollback failed: add error: {err}; rollback error: {rollback_err}"
134 ))
135 .into()),
136 };
137 }
138 failures.push(format!("- {} -> {}", dir.display(), err));
139 }
140 }
141 }
142
143 let mut lines = Vec::new();
144 lines.push(format!("space added: {}{suffix}", added.name));
145 lines.push(format!("collections registered: {}", successes.len()));
146 lines.extend(successes);
147 if !failures.is_empty() {
148 lines.push(format!("collections failed: {}", failures.len()));
149 lines.extend(failures);
150 }
151 lines.push(format!(
152 "note: collections were registered without indexing; run `kbolt --space {} update` to index them",
153 shell_quote_arg(&added.name)
154 ));
155
156 Ok(lines.join("\n"))
157 }
158
159 pub fn space_describe(&self, name: &str, text: &str) -> Result<String> {
160 self.engine.describe_space(name, text)?;
161 Ok(format!("space description updated: {name}"))
162 }
163
164 pub fn space_rename(&mut self, old: &str, new: &str) -> Result<String> {
165 self.engine.rename_space(old, new)?;
166 Ok(format!("space renamed: {old} -> {new}"))
167 }
168
169 pub fn space_remove(&mut self, name: &str) -> Result<String> {
170 self.engine.remove_space(name)?;
171 if name == "default" {
172 return Ok("default space cleared".to_string());
173 }
174 Ok(format!("space removed: {name}"))
175 }
176
177 pub fn space_default(&mut self, name: Option<&str>) -> Result<String> {
178 if let Some(space_name) = name {
179 let updated = self.engine.set_default_space(Some(space_name))?;
180 let value = updated.unwrap_or_default();
181 return Ok(format!("default space: {value}"));
182 }
183
184 let current = self.engine.config().default_space.as_deref();
185 let output = match current {
186 Some(value) => format!("default space: {value}"),
187 None => "default space: none".to_string(),
188 };
189 Ok(output)
190 }
191
192 pub fn space_current(&self, explicit: Option<&str>) -> Result<String> {
193 let active = self.engine.current_space(explicit)?;
194 let output = match active {
195 Some(active) => {
196 let source = match active.source {
197 ActiveSpaceSource::Flag => "flag",
198 ActiveSpaceSource::EnvVar => "env",
199 ActiveSpaceSource::ConfigDefault => "default",
200 };
201 format!("active space: {} ({source})", active.name)
202 }
203 None => "active space: none".to_string(),
204 };
205 Ok(output)
206 }
207
208 pub fn space_list(&self) -> Result<String> {
209 let spaces = self.engine.list_spaces()?;
210 let mut lines = Vec::with_capacity(spaces.len() + 1);
211 lines.push("spaces:".to_string());
212 for space in spaces {
213 let description = space.description.unwrap_or_default();
214 let suffix = if description.is_empty() {
215 String::new()
216 } else {
217 format!(" - {description}")
218 };
219 lines.push(format!(
220 "- {} (collections: {}, documents: {}, chunks: {}){}",
221 space.name, space.collection_count, space.document_count, space.chunk_count, suffix
222 ));
223 }
224 Ok(lines.join("\n"))
225 }
226
227 pub fn space_info(&self, name: &str) -> Result<String> {
228 let space = self.engine.space_info(name)?;
229 let description = space.description.unwrap_or_default();
230 let description_line = if description.is_empty() {
231 "description:".to_string()
232 } else {
233 format!("description: {description}")
234 };
235
236 Ok(format!(
237 "name: {}\n{description_line}\ncollections: {}\ndocuments: {}\nchunks: {}\ncreated: {}",
238 space.name,
239 space.collection_count,
240 space.document_count,
241 space.chunk_count,
242 space.created
243 ))
244 }
245
246 pub fn collection_list(&self, space: Option<&str>) -> Result<String> {
247 let collections = self.engine.list_collections(space)?;
248 let mut lines = Vec::with_capacity(collections.len() + 1);
249 lines.push("collections:".to_string());
250 if collections.is_empty() {
251 lines.push("- none".to_string());
252 return Ok(lines.join("\n"));
253 }
254
255 for collection in collections {
256 lines.push(format!(
257 "- {}/{} ({})",
258 collection.space,
259 collection.name,
260 collection.path.display()
261 ));
262 }
263 Ok(lines.join("\n"))
264 }
265
266 pub fn collection_add(
267 &self,
268 space: Option<&str>,
269 path: &std::path::Path,
270 name: Option<&str>,
271 description: Option<&str>,
272 extensions: Option<&[String]>,
273 no_index: bool,
274 ) -> Result<String> {
275 let added = self.engine.add_collection(AddCollectionRequest {
276 path: path.to_path_buf(),
277 space: space.map(ToString::to_string),
278 name: name.map(ToString::to_string),
279 description: description.map(ToString::to_string),
280 extensions: extensions.map(|items| items.to_vec()),
281 no_index,
282 })?;
283
284 Ok(format_collection_add_result(&added))
285 }
286
287 pub fn collection_info(&self, space: Option<&str>, name: &str) -> Result<String> {
288 let collection = self.engine.collection_info(space, name)?;
289
290 Ok(format_collection_info(&collection))
291 }
292
293 pub fn collection_describe(
294 &self,
295 space: Option<&str>,
296 name: &str,
297 text: &str,
298 ) -> Result<String> {
299 self.engine.describe_collection(space, name, text)?;
300 Ok(format!("collection description updated: {name}"))
301 }
302
303 pub fn collection_rename(&self, space: Option<&str>, old: &str, new: &str) -> Result<String> {
304 self.engine.rename_collection(space, old, new)?;
305 Ok(format!("collection renamed: {old} -> {new}"))
306 }
307
308 pub fn collection_remove(&self, space: Option<&str>, name: &str) -> Result<String> {
309 self.engine.remove_collection(space, name)?;
310 Ok(format!("collection removed: {name}"))
311 }
312
313 pub fn ignore_show(&self, space: Option<&str>, collection: &str) -> Result<String> {
314 let (resolved_space, content) = self.engine.read_collection_ignore(space, collection)?;
315 if let Some(content) = content {
316 return Ok(format!(
317 "ignore patterns for {resolved_space}/{collection}:\n{content}"
318 ));
319 }
320 Ok(format!(
321 "no ignore patterns configured for {resolved_space}/{collection}"
322 ))
323 }
324
325 pub fn ignore_add(
326 &self,
327 space: Option<&str>,
328 collection: &str,
329 pattern: &str,
330 ) -> Result<String> {
331 let (resolved_space, normalized_pattern) = self
332 .engine
333 .add_collection_ignore_pattern(space, collection, pattern)?;
334 Ok(format!(
335 "ignore pattern added for {resolved_space}/{collection}: {normalized_pattern}"
336 ))
337 }
338
339 pub fn ignore_remove(
340 &self,
341 space: Option<&str>,
342 collection: &str,
343 pattern: &str,
344 ) -> Result<String> {
345 let (resolved_space, removed_count) = self
346 .engine
347 .remove_collection_ignore_pattern(space, collection, pattern)?;
348 if removed_count == 0 {
349 return Ok(format!(
350 "ignore pattern not found for {resolved_space}/{collection}: {pattern}"
351 ));
352 }
353
354 Ok(format!(
355 "ignore pattern removed for {resolved_space}/{collection}: {pattern} ({removed_count} match(es))"
356 ))
357 }
358
359 pub fn ignore_list(&self, space: Option<&str>) -> Result<String> {
360 let entries = self.engine.list_collection_ignores(space)?;
361 let mut lines = Vec::new();
362 lines.push("ignore patterns:".to_string());
363 if entries.is_empty() {
364 lines.push("- none".to_string());
365 return Ok(lines.join("\n"));
366 }
367
368 let mut current_space: Option<String> = None;
369 for entry in entries {
370 if current_space.as_deref() != Some(entry.space.as_str()) {
371 lines.push(format!("{}:", entry.space));
372 current_space = Some(entry.space.clone());
373 }
374 lines.push(format!(
375 "- {} (patterns: {})",
376 entry.collection, entry.pattern_count
377 ));
378 }
379
380 Ok(lines.join("\n"))
381 }
382
383 pub fn ignore_edit(&self, space: Option<&str>, collection: &str) -> Result<String> {
384 let (resolved_space, path) = self
385 .engine
386 .prepare_collection_ignore_edit(space, collection)?;
387 let editor_command = resolve_editor_command()?;
388
389 let mut process = std::process::Command::new(&editor_command[0]);
390 if editor_command.len() > 1 {
391 process.args(&editor_command[1..]);
392 }
393 process.arg(&path);
394
395 let status = process.status().map_err(|err| {
396 KboltError::Internal(format!(
397 "failed to launch editor '{}': {err}",
398 editor_command[0]
399 ))
400 })?;
401 if !status.success() {
402 return Err(
403 KboltError::Internal(format!("editor exited with status: {status}")).into(),
404 );
405 }
406
407 Ok(format!(
408 "ignore patterns updated for {resolved_space}/{collection}: {}",
409 path.display()
410 ))
411 }
412
413 pub fn models_list(&self) -> Result<String> {
414 let status = self.engine.model_status()?;
415 Ok(format_models_list(&status))
416 }
417
418 pub fn eval_run(&self, eval_file: Option<&Path>) -> Result<String> {
419 let report = self.engine.run_eval(eval_file)?;
420 Ok(format_eval_run_report(&report))
421 }
422
423 pub fn search(&self, options: CliSearchOptions<'_>) -> Result<String> {
424 let CliSearchOptions {
425 space,
426 query,
427 collections,
428 limit,
429 min_score,
430 deep,
431 keyword,
432 semantic,
433 rerank,
434 no_rerank,
435 debug,
436 } = options;
437 let mode_flags = deep as u8 + keyword as u8 + semantic as u8;
438 if mode_flags > 1 {
439 return Err(KboltError::InvalidInput(
440 "only one of --deep, --keyword, or --semantic can be set".to_string(),
441 )
442 .into());
443 }
444
445 let mode = if deep {
446 SearchMode::Deep
447 } else if keyword {
448 SearchMode::Keyword
449 } else if semantic {
450 SearchMode::Semantic
451 } else {
452 SearchMode::Auto
453 };
454 let effective_no_rerank = resolve_no_rerank_for_mode(mode.clone(), rerank, no_rerank);
455 let engine_limit = if debug {
456 limit
457 } else {
458 limit.saturating_mul(2)
459 };
460
461 let response = self.engine.search(SearchRequest {
462 query: query.to_string(),
463 mode,
464 space: space.map(ToString::to_string),
465 collections: collections.to_vec(),
466 limit: engine_limit,
467 min_score,
468 no_rerank: effective_no_rerank,
469 debug,
470 })?;
471
472 let mut lines = Vec::new();
473
474 if debug {
475 lines.push(format!("query: {}", response.query));
476 lines.push(format!(
477 "mode: {} -> {}",
478 format_search_mode(&response.requested_mode),
479 format_search_mode(&response.effective_mode)
480 ));
481 lines.push(format!(
482 "pipeline: {}",
483 format_search_pipeline(&response.pipeline)
484 ));
485 for notice in &response.pipeline.notices {
486 lines.push(format!("note: {}", format_search_pipeline_notice(notice)));
487 }
488 lines.push(String::new());
489 }
490
491 if debug {
492 lines.push(format!(
493 "{} result{}",
494 response.results.len(),
495 if response.results.len() == 1 { "" } else { "s" }
496 ));
497
498 for (index, item) in response.results.iter().enumerate() {
499 lines.push(String::new());
500 lines.push(format!(
501 "{}. {} score={:.3}",
502 index + 1,
503 item.docid,
504 item.score
505 ));
506 lines.push(format!(
507 " {}",
508 format_search_result_path(&item.space, &item.path)
509 ));
510 if let Some(heading) = &item.heading {
511 lines.push(format!(" heading: {heading}"));
512 }
513 lines.push(String::new());
514 let snippet = truncate_snippet(&item.text, 4);
515 for snippet_line in snippet.lines() {
516 lines.push(format!(" {snippet_line}"));
517 }
518 if let Some(signals) = &item.signals {
519 lines.push(String::new());
520 lines.push(format!(
521 " signals: bm25={} dense={} fusion={} reranker={}",
522 format_optional_search_signal(signals.bm25),
523 format_optional_search_signal(signals.dense),
524 format_search_signal(signals.fusion),
525 format_optional_search_signal(signals.reranker)
526 ));
527 }
528 }
529 } else {
530 let grouped_results = group_search_results(&response.results, limit);
531 lines.push(format!(
532 "{} result{}",
533 grouped_results.len(),
534 if grouped_results.len() == 1 { "" } else { "s" }
535 ));
536
537 for (index, item) in grouped_results.iter().enumerate() {
538 lines.push(String::new());
539 lines.push(format!("{}. {}", index + 1, item.primary.title));
540 lines.push(format!(
541 " {}",
542 format_search_result_path(&item.primary.space, &item.primary.path)
543 ));
544 lines.push(format!(" score: {:.2}", item.primary.score));
545 if let Some(heading) = &item.primary.heading {
546 lines.push(format!(" heading: {heading}"));
547 }
548 lines.push(String::new());
549 let snippet = truncate_snippet(&item.primary.text, 4);
550 for snippet_line in snippet.lines() {
551 lines.push(format!(" {snippet_line}"));
552 }
553 if item.additional_matches > 0 {
554 lines.push(format!(
555 " +{} more matching section{}",
556 item.additional_matches,
557 if item.additional_matches == 1 {
558 ""
559 } else {
560 "s"
561 }
562 ));
563 }
564 }
565 }
566
567 if response.results.is_empty() {
568 if let Some(hint) = self.empty_index_hint(space, collections) {
569 lines.push(String::new());
570 lines.push(hint);
571 }
572 }
573
574 if let Some(hint) = response.staleness_hint {
575 lines.push(String::new());
576 lines.push(hint);
577 }
578
579 if debug {
580 lines.push(format!("elapsed: {}ms", response.elapsed_ms));
581 }
582
583 Ok(lines.join("\n"))
584 }
585
586 fn empty_index_hint(&self, space: Option<&str>, requested: &[String]) -> Option<String> {
587 let all = self.engine.list_collections(space).ok()?;
588 let scoped: Vec<_> = if requested.is_empty() {
589 all
590 } else {
591 all.into_iter()
592 .filter(|c| requested.iter().any(|r| r == &c.name))
593 .collect()
594 };
595
596 if scoped.is_empty() {
599 let space_arg = shell_quote_arg(space.unwrap_or("default"));
600 return Some(format!(
601 "no collections registered yet\nnext:\n kbolt --space {space_arg} collection add /path/to/docs"
602 ));
603 }
604
605 if !requested.is_empty() && scoped.iter().all(|c| c.chunk_count == 0) {
610 let names = scoped
611 .iter()
612 .map(|c| c.name.as_str())
613 .collect::<Vec<_>>()
614 .join(", ");
615 let mut lines = vec![
616 format!("no indexed content in selected collection(s): {names}"),
617 "check:".to_string(),
618 ];
619 for collection in &scoped {
620 lines.push(format!(
621 " kbolt --space {} collection info {}",
622 shell_quote_arg(&collection.space),
623 shell_quote_arg(&collection.name),
624 ));
625 }
626 return Some(lines.join("\n"));
627 }
628
629 None
630 }
631
632 pub fn update(
633 &self,
634 space: Option<&str>,
635 collections: &[String],
636 no_embed: bool,
637 dry_run: bool,
638 verbose: bool,
639 ) -> Result<String> {
640 let report = self.engine.update(UpdateOptions {
641 space: space.map(ToString::to_string),
642 collections: collections.to_vec(),
643 no_embed,
644 dry_run,
645 verbose,
646 })?;
647
648 Ok(format_update_report(&report, verbose))
649 }
650
651 pub fn schedule_add(&self, req: AddScheduleRequest) -> Result<String> {
652 let response = self.engine.add_schedule(req)?;
653 Ok(format_schedule_add_response(&response))
654 }
655
656 pub fn schedule_status(&self) -> Result<String> {
657 let response = self.engine.schedule_status()?;
658 Ok(format_schedule_status_response(&response))
659 }
660
661 pub fn schedule_remove(&self, req: RemoveScheduleRequest) -> Result<String> {
662 let response = self.engine.remove_schedule(req)?;
663 Ok(format_schedule_remove_response(&response))
664 }
665
666 pub fn status(&self, space: Option<&str>) -> Result<String> {
667 let status = self.engine.status(space)?;
668 let active_space = active_space_name_for_status(&self.engine, space);
669 Ok(format_status_response(&status, active_space.as_deref()))
670 }
671
672 pub fn ls(
673 &self,
674 space: Option<&str>,
675 collection: &str,
676 prefix: Option<&str>,
677 all: bool,
678 ) -> Result<String> {
679 let mut files = self.engine.list_files(space, collection, prefix)?;
680 if !all {
681 files.retain(|file| file.active);
682 }
683
684 Ok(format_file_list(&files, all))
685 }
686
687 pub fn get(
688 &self,
689 space: Option<&str>,
690 identifier: &str,
691 offset: Option<usize>,
692 limit: Option<usize>,
693 ) -> Result<String> {
694 let locator = Locator::parse(identifier);
695
696 let document = self.engine.get_document(GetRequest {
697 locator,
698 space: space.map(ToString::to_string),
699 offset,
700 limit,
701 })?;
702
703 Ok(format_document_response(&document))
704 }
705
706 pub fn multi_get(
707 &self,
708 space: Option<&str>,
709 locators: &[String],
710 max_files: usize,
711 max_bytes: usize,
712 ) -> Result<String> {
713 let locators = locators
714 .iter()
715 .map(|item| Locator::parse(item))
716 .collect::<Vec<_>>();
717
718 let response = self.engine.multi_get(MultiGetRequest {
719 locators,
720 space: space.map(ToString::to_string),
721 max_files,
722 max_bytes,
723 })?;
724
725 Ok(format_multi_get_response(&response))
726 }
727}
728
729pub fn format_doctor_report(report: &DoctorReport) -> String {
730 let mut lines = Vec::new();
731
732 let failures: Vec<_> = report
733 .checks
734 .iter()
735 .filter(|c| c.status == DoctorCheckStatus::Fail)
736 .collect();
737 let warnings: Vec<_> = report
738 .checks
739 .iter()
740 .filter(|c| c.status == DoctorCheckStatus::Warn)
741 .collect();
742 let expected_unindexed_warnings: Vec<_> = warnings
743 .iter()
744 .filter(|check| is_expected_unindexed_storage_warning(check))
745 .collect();
746
747 match report.setup_status {
748 DoctorSetupStatus::ConfigMissing => {
749 lines.push("kbolt is not set up".to_string());
750 lines.push(String::new());
751 lines.push("get started:".to_string());
752 lines.push(" kbolt setup local".to_string());
753 return lines.join("\n");
754 }
755 DoctorSetupStatus::ConfigInvalid => {
756 lines.push("kbolt configuration is invalid".to_string());
757 for check in &failures {
758 lines.push(String::new());
759 lines.push(format!(" {}", check.message));
760 if let Some(fix) = check.fix.as_deref() {
761 lines.push(format!(" fix: {fix}"));
762 }
763 }
764 if let Some(path) = report.config_file.as_ref() {
765 lines.push(String::new());
766 lines.push(format!("config: {}", path.display()));
767 }
768 return lines.join("\n");
769 }
770 DoctorSetupStatus::NotConfigured => {
771 lines.push("kbolt is installed but no inference roles are configured".to_string());
772 lines.push(String::new());
773 lines.push("get started:".to_string());
774 lines.push(" kbolt setup local".to_string());
775 return lines.join("\n");
776 }
777 DoctorSetupStatus::Configured => {}
778 }
779
780 if report.ready && failures.is_empty() {
781 lines.push("kbolt is ready".to_string());
782 } else {
783 lines.push("kbolt has issues".to_string());
784 }
785
786 let configured_roles: Vec<_> = report
787 .checks
788 .iter()
789 .filter(|c| c.id.ends_with(".bound") && c.status == DoctorCheckStatus::Pass)
790 .collect();
791 if !configured_roles.is_empty() {
792 lines.push(String::new());
793 lines.push("configured:".to_string());
794 for check in &configured_roles {
795 let role = check.scope.strip_prefix("roles.").unwrap_or(&check.scope);
796 lines.push(format!(" {role}"));
797 }
798 }
799
800 let not_enabled: Vec<_> = report
801 .checks
802 .iter()
803 .filter(|c| c.id.ends_with(".bound") && c.status == DoctorCheckStatus::Warn)
804 .collect();
805 if !not_enabled.is_empty() {
806 lines.push(String::new());
807 lines.push("not enabled:".to_string());
808 for check in ¬_enabled {
809 let role = check.scope.strip_prefix("roles.").unwrap_or(&check.scope);
810 lines.push(format!(" {role}"));
811 }
812 }
813
814 if !failures.is_empty() {
815 lines.push(String::new());
816 lines.push("failures:".to_string());
817 for check in &failures {
818 lines.push(format!(" {}: {}", check.id, check.message));
819 if let Some(fix) = check.fix.as_deref() {
820 lines.push(format!(" fix: {fix}"));
821 }
822 }
823 }
824
825 if !warnings.is_empty() && failures.is_empty() {
826 let other_warnings: Vec<_> = warnings
828 .iter()
829 .filter(|c| {
830 !c.id.ends_with(".bound")
831 && !c.id.ends_with(".reachable")
832 && !is_expected_unindexed_storage_warning(c)
833 })
834 .collect();
835 if !other_warnings.is_empty() {
836 lines.push(String::new());
837 lines.push("warnings:".to_string());
838 for check in &other_warnings {
839 lines.push(format!(" {}: {}", check.id, check.message));
840 if let Some(fix) = check.fix.as_deref() {
841 lines.push(format!(" fix: {fix}"));
842 }
843 }
844 }
845 }
846
847 if !expected_unindexed_warnings.is_empty() && failures.is_empty() {
848 lines.push(String::new());
849 lines.push("indexing:".to_string());
850 lines.push(" no collections have been indexed yet".to_string());
851 lines.push(String::new());
852 lines.push("next:".to_string());
853 lines.push(" kbolt collection add /path/to/docs".to_string());
854 lines.push(" or, if collections are already registered: kbolt update".to_string());
855 }
856
857 lines.join("\n")
858}
859
860fn is_expected_unindexed_storage_warning(check: &DoctorCheck) -> bool {
861 check.status == DoctorCheckStatus::Warn
862 && matches!(
863 check.id.as_str(),
864 "storage.sqlite_readable" | "storage.search_indexes_readable"
865 )
866}
867
868pub fn format_local_report(report: &LocalReport) -> String {
869 let mut lines = Vec::new();
870
871 let action_label = match report.action {
872 LocalAction::Setup => "local setup complete",
873 LocalAction::Start => "local servers started",
874 LocalAction::Stop => "local servers stopped",
875 LocalAction::Status => "local server status",
876 LocalAction::EnableDeep => "deep search enabled",
877 };
878
879 if report.action == LocalAction::Stop || report.ready {
880 lines.push(action_label.to_string());
881 } else {
882 lines.push(format!("{action_label} (not ready)"));
883 }
884
885 if !report.notes.is_empty() {
886 lines.push(String::new());
887 lines.push("notes:".to_string());
888 for note in &report.notes {
889 lines.push(format!(" {note}"));
890 }
891 }
892
893 let ready_services: Vec<_> = if report.action == LocalAction::Stop {
894 Vec::new()
895 } else {
896 report.services.iter().filter(|s| s.ready).collect()
897 };
898 let issue_services: Vec<_> = if report.action == LocalAction::Stop {
899 report
900 .services
901 .iter()
902 .filter(|s| s.configured && (s.running || s.ready))
903 .collect()
904 } else {
905 report
906 .services
907 .iter()
908 .filter(|s| s.configured && !s.ready)
909 .collect()
910 };
911 let unconfigured_services: Vec<_> = report.services.iter().filter(|s| !s.configured).collect();
912
913 if !ready_services.is_empty() {
914 lines.push(String::new());
915 lines.push("ready:".to_string());
916 for service in &ready_services {
917 lines.push(format!(" {} ({})", service.name, service.model));
918 }
919 }
920
921 if !issue_services.is_empty() {
922 lines.push(String::new());
923 lines.push("issues:".to_string());
924 for service in &issue_services {
925 let issue = service.issue.as_deref().unwrap_or("not ready");
926 lines.push(format!(" {}: {issue}", service.name));
927 }
928 }
929
930 if !unconfigured_services.is_empty() && report.action != LocalAction::Stop {
931 lines.push(String::new());
932 lines.push("not configured:".to_string());
933 for service in &unconfigured_services {
934 lines.push(format!(" {}", service.name));
935 }
936 }
937
938 lines.push(String::new());
939 lines.push("config:".to_string());
940 lines.push(format!(" {}", report.config_file.display()));
941
942 if report.action == LocalAction::Setup && report.ready {
943 lines.push(String::new());
944 lines.push("next:".to_string());
945 lines.push(" kbolt collection add /path/to/docs".to_string());
946 lines.push(" kbolt doctor".to_string());
947 }
948
949 lines.join("\n")
950}
951
952fn format_search_mode(mode: &SearchMode) -> &'static str {
953 match mode {
954 SearchMode::Auto => "auto",
955 SearchMode::Deep => "deep",
956 SearchMode::Keyword => "keyword",
957 SearchMode::Semantic => "semantic",
958 }
959}
960
961fn format_search_result_path(space: &str, path: &str) -> String {
962 format!("{space}/{path}")
963}
964
965fn group_search_results<'a>(
966 results: &'a [kbolt_types::SearchResult],
967 limit: usize,
968) -> Vec<GroupedSearchResult<'a>> {
969 let mut grouped: Vec<GroupedSearchResult<'a>> = Vec::new();
970 let mut index_by_document: HashMap<(&str, &str), usize> = HashMap::new();
971
972 for result in results {
973 let document_key = (result.space.as_str(), result.path.as_str());
974 if let Some(index) = index_by_document.get(&document_key).copied() {
975 grouped[index].additional_matches += 1;
976 continue;
977 }
978
979 if grouped.len() >= limit {
980 continue;
981 }
982
983 let next_index = grouped.len();
984 index_by_document.insert(document_key, next_index);
985 grouped.push(GroupedSearchResult {
986 primary: result,
987 additional_matches: 0,
988 });
989 }
990
991 grouped
992}
993
994fn format_collection_info(collection: &CollectionInfo) -> String {
995 let mut lines = Vec::new();
996 lines.push(format!(
997 "collection: {}/{}",
998 collection.space, collection.name
999 ));
1000 lines.push(format!("path: {}", collection.path.display()));
1001
1002 if let Some(description) = collection.description.as_deref() {
1003 if !description.is_empty() {
1004 lines.push(format!("description: {description}"));
1005 }
1006 }
1007
1008 if let Some(extensions) = collection.extensions.as_ref() {
1009 if !extensions.is_empty() {
1010 lines.push(format!("extensions: {}", extensions.join(", ")));
1011 }
1012 }
1013
1014 lines.push(String::new());
1015 lines.push("documents:".to_string());
1016 lines.push(format!(
1017 " {} active / {} total",
1018 collection.active_document_count, collection.document_count
1019 ));
1020 lines.push(format!(" {} chunks", collection.chunk_count));
1021 lines.push(format!(" {} embedded", collection.embedded_chunk_count));
1022
1023 lines.push(String::new());
1024 lines.push("updated:".to_string());
1025 lines.push(format!(" created {}", collection.created));
1026 lines.push(format!(" updated {}", collection.updated));
1027
1028 lines.join("\n")
1029}
1030
1031fn format_document_response(document: &DocumentResponse) -> String {
1032 let mut lines = Vec::new();
1033 lines.push(format!(
1034 "document: {}",
1035 format_search_result_path(&document.space, &document.path)
1036 ));
1037 lines.push(format!("title: {}", document.title));
1038 lines.push(format!("docid: {}", document.docid));
1039 lines.push(format!(
1040 "lines: {}",
1041 format_returned_lines(document.returned_lines, document.total_lines)
1042 ));
1043 if document.stale {
1044 lines.push("status: stale".to_string());
1045 }
1046
1047 lines.push(String::new());
1048 lines.push("content:".to_string());
1049 lines.push(document.content.clone());
1050
1051 lines.join("\n")
1052}
1053
1054fn format_multi_get_response(response: &MultiGetResponse) -> String {
1055 let mut lines = Vec::new();
1056 lines.push(format!("documents: {} returned", response.documents.len()));
1057 if response.resolved_count > response.documents.len() {
1058 lines.push(format!("resolved: {}", response.resolved_count));
1059 }
1060
1061 if response.documents.is_empty() {
1062 lines.push("- none".to_string());
1063 } else {
1064 for (index, document) in response.documents.iter().enumerate() {
1065 lines.push(String::new());
1066 lines.push(format!(
1067 "{}. {}",
1068 index + 1,
1069 format_search_result_path(&document.space, &document.path)
1070 ));
1071 lines.push(format!(" title: {}", document.title));
1072 lines.push(format!(" docid: {}", document.docid));
1073 lines.push(format!(
1074 " lines: {}",
1075 format_returned_lines(document.returned_lines, document.total_lines)
1076 ));
1077 if document.stale {
1078 lines.push(" status: stale".to_string());
1079 }
1080 lines.push(String::new());
1081 for content_line in document.content.lines() {
1082 lines.push(content_line.to_string());
1083 }
1084 if document.content.is_empty() {
1085 lines.push(String::new());
1086 }
1087 }
1088 }
1089
1090 if !response.omitted.is_empty() {
1091 lines.push(String::new());
1092 lines.push("omitted:".to_string());
1093 for omitted in &response.omitted {
1094 lines.push(format!(
1095 "- {} ({}, {})",
1096 omitted.path,
1097 format_bytes_human(omitted.size_bytes as u64),
1098 format_omit_reason(&omitted.reason)
1099 ));
1100 }
1101 }
1102
1103 if !response.warnings.is_empty() {
1104 lines.push(String::new());
1105 lines.push("warnings:".to_string());
1106 for warning in &response.warnings {
1107 lines.push(format!("- {warning}"));
1108 }
1109 }
1110
1111 lines.join("\n")
1112}
1113
1114fn format_models_list(status: &kbolt_types::ModelStatus) -> String {
1115 let mut lines = Vec::new();
1116 lines.push("models:".to_string());
1117 append_model_status_lines(&mut lines, "embedder", &status.embedder);
1118 append_model_status_lines(&mut lines, "reranker", &status.reranker);
1119 append_model_status_lines(&mut lines, "expander", &status.expander);
1120 lines.join("\n")
1121}
1122
1123fn format_status_response(status: &StatusResponse, active_space: Option<&str>) -> String {
1124 let mut lines = Vec::new();
1125
1126 lines.push("spaces:".to_string());
1127 if status.spaces.is_empty() {
1128 lines.push("- none".to_string());
1129 } else {
1130 for space in &status.spaces {
1131 let active_suffix = if Some(space.name.as_str()) == active_space {
1132 " (active)"
1133 } else {
1134 ""
1135 };
1136 lines.push(format!("- {}{}", space.name, active_suffix));
1137 if let Some(description) = space.description.as_deref() {
1138 if !description.is_empty() {
1139 lines.push(format!(" description: {description}"));
1140 }
1141 }
1142 if let Some(last_updated) = space.last_updated.as_deref() {
1143 lines.push(format!(" updated: {last_updated}"));
1144 }
1145
1146 if space.collections.is_empty() {
1147 lines.push(" collections: none".to_string());
1148 } else {
1149 lines.push(" collections:".to_string());
1150 for collection in &space.collections {
1151 lines.push(format!(" - {}", collection.name));
1152 lines.push(format!(" path: {}", collection.path.display()));
1153 lines.push(format!(
1154 " documents: {} active / {} total",
1155 collection.active_documents, collection.documents
1156 ));
1157 lines.push(format!(" chunks: {}", collection.chunks));
1158 lines.push(format!(" embedded: {}", collection.embedded_chunks));
1159 lines.push(format!(" updated: {}", collection.last_updated));
1160 }
1161 }
1162 }
1163 }
1164
1165 lines.push(String::new());
1166 lines.push("totals:".to_string());
1167 lines.push(format!("- documents: {}", status.total_documents));
1168 lines.push(format!("- chunks: {}", status.total_chunks));
1169 lines.push(format!("- embedded: {}", status.total_embedded));
1170
1171 lines.push(String::new());
1172 lines.push("storage:".to_string());
1173 lines.push(format!(
1174 "- sqlite: {}",
1175 format_bytes_human(status.disk_usage.sqlite_bytes)
1176 ));
1177 lines.push(format!(
1178 "- tantivy: {}",
1179 format_bytes_human(status.disk_usage.tantivy_bytes)
1180 ));
1181 lines.push(format!(
1182 "- vectors: {}",
1183 format_bytes_human(status.disk_usage.usearch_bytes)
1184 ));
1185 lines.push(format!(
1186 "- models: {}",
1187 format_bytes_human(status.disk_usage.models_bytes)
1188 ));
1189 lines.push(format!(
1190 "- total: {}",
1191 format_bytes_human(status.disk_usage.total_bytes)
1192 ));
1193
1194 lines.push(String::new());
1195 lines.push("models:".to_string());
1196 append_model_status_lines(&mut lines, "embedder", &status.models.embedder);
1197 append_model_status_lines(&mut lines, "reranker", &status.models.reranker);
1198 append_model_status_lines(&mut lines, "expander", &status.models.expander);
1199
1200 lines.push(String::new());
1201 lines.push("paths:".to_string());
1202 lines.push(format!("- cache: {}", status.cache_dir.display()));
1203 lines.push(format!("- config: {}", status.config_dir.display()));
1204
1205 lines.join("\n")
1206}
1207
1208fn format_file_list(files: &[FileEntry], all: bool) -> String {
1209 let mut lines = Vec::new();
1210 lines.push("files:".to_string());
1211 if files.is_empty() {
1212 lines.push("- none".to_string());
1213 return lines.join("\n");
1214 }
1215
1216 for file in files {
1217 if all && !file.active {
1218 lines.push(format!("- {} (inactive)", file.path));
1219 } else {
1220 lines.push(format!("- {}", file.path));
1221 }
1222 }
1223
1224 lines.join("\n")
1225}
1226
1227fn append_model_status_lines(lines: &mut Vec<String>, label: &str, info: &ModelInfo) {
1228 let mut summary = format!("- {label}: {}", model_state_label(info));
1229 if let Some(model) = info.model.as_deref() {
1230 summary.push_str(&format!(" ({model})"));
1231 }
1232 lines.push(summary);
1233
1234 if info.configured && !info.ready {
1235 if let Some(issue) = info.issue.as_deref() {
1236 lines.push(format!(" issue: {issue}"));
1237 }
1238 }
1239}
1240
1241fn model_state_label(info: &ModelInfo) -> &'static str {
1242 if !info.configured {
1243 "not configured"
1244 } else if info.ready {
1245 "ready"
1246 } else {
1247 "not ready"
1248 }
1249}
1250
1251fn format_bytes_human(bytes: u64) -> String {
1252 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
1253
1254 if bytes < 1024 {
1255 return format!("{bytes} B");
1256 }
1257
1258 let mut value = bytes as f64;
1259 let mut unit_index = 0usize;
1260 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
1261 value /= 1024.0;
1262 unit_index += 1;
1263 }
1264
1265 if value >= 10.0 {
1266 format!("{value:.0} {}", UNITS[unit_index])
1267 } else {
1268 format!("{value:.1} {}", UNITS[unit_index])
1269 }
1270}
1271
1272fn format_returned_lines(returned_lines: usize, total_lines: usize) -> String {
1273 if returned_lines == total_lines {
1274 format!("{total_lines}")
1275 } else {
1276 format!("{returned_lines} of {total_lines}")
1277 }
1278}
1279
1280fn format_omit_reason(reason: &OmitReason) -> &'static str {
1281 match reason {
1282 OmitReason::MaxFiles => "max files",
1283 OmitReason::MaxBytes => "size limit",
1284 }
1285}
1286
1287fn active_space_name_for_status(engine: &Engine, explicit: Option<&str>) -> Option<String> {
1288 if let Some(space_name) = explicit {
1289 return Some(space_name.to_string());
1290 }
1291
1292 if let Ok(space_name) = std::env::var("KBOLT_SPACE") {
1293 let trimmed = space_name.trim();
1294 if !trimmed.is_empty() {
1295 return Some(trimmed.to_string());
1296 }
1297 }
1298
1299 engine.config().default_space.clone()
1300}
1301
1302fn format_optional_search_signal(value: Option<f32>) -> String {
1303 value
1304 .map(format_search_signal)
1305 .unwrap_or_else(|| "-".to_string())
1306}
1307
1308fn format_search_signal(value: f32) -> String {
1309 format!("{value:.2}")
1310}
1311
1312fn format_search_pipeline(pipeline: &SearchPipeline) -> String {
1313 let mut parts = Vec::new();
1314 if pipeline.expansion {
1315 parts.push("expansion");
1316 }
1317 if pipeline.keyword {
1318 parts.push("keyword");
1319 }
1320 if pipeline.dense {
1321 parts.push("dense");
1322 }
1323 if pipeline.rerank {
1324 parts.push("rerank");
1325 }
1326
1327 if parts.is_empty() {
1328 "none".to_string()
1329 } else {
1330 parts.join(" + ")
1331 }
1332}
1333
1334fn format_search_pipeline_notice(notice: &SearchPipelineNotice) -> String {
1335 let step = match notice.step {
1336 SearchPipelineStep::Dense => "dense retrieval",
1337 SearchPipelineStep::Rerank => "rerank",
1338 };
1339 let reason = match notice.reason {
1340 SearchPipelineUnavailableReason::NotConfigured => "not configured",
1341 SearchPipelineUnavailableReason::ModelNotAvailable => "required provider is not ready",
1342 };
1343 format!("{step} unavailable: {reason}")
1344}
1345
1346fn format_update_report(report: &UpdateReport, verbose: bool) -> String {
1347 let mut lines = Vec::new();
1348 if verbose {
1349 for decision in &report.decisions {
1350 lines.push(format_update_decision(decision));
1351 }
1352
1353 for error in unreported_update_errors(report) {
1354 lines.push(format!("error: {}: {}", error.path, error.error));
1355 }
1356
1357 if !lines.is_empty() {
1358 lines.push(String::new());
1359 }
1360 }
1361
1362 lines.push("update complete".to_string());
1363 append_update_summary_lines(&mut lines, report);
1364
1365 if !report.errors.is_empty() && !verbose {
1366 let truncated = append_update_error_lines(&mut lines, report, 3);
1367 if truncated {
1368 lines.push(" run with --verbose for the full list".to_string());
1369 }
1370 }
1371
1372 lines.join("\n")
1373}
1374
1375fn format_collection_add_result(result: &AddCollectionResult) -> String {
1376 let collection = &result.collection;
1377 let locator = format!("{}/{}", collection.space, collection.name);
1378
1379 match &result.initial_indexing {
1380 InitialIndexingOutcome::Skipped => [
1381 format!("collection added: {locator}"),
1382 "indexing skipped (--no-index)".to_string(),
1383 String::new(),
1384 "next:".to_string(),
1385 format!(
1386 " kbolt --space {} update --collection {}",
1387 shell_quote_arg(&collection.space),
1388 shell_quote_arg(&collection.name),
1389 ),
1390 ]
1391 .join("\n"),
1392 InitialIndexingOutcome::Indexed(report) => {
1393 format_collection_add_indexing_report(collection, &locator, report)
1394 }
1395 InitialIndexingOutcome::Blocked(block) => {
1396 format_collection_add_block(collection, &locator, block)
1397 }
1398 }
1399}
1400
1401fn format_collection_add_indexing_report(
1402 collection: &kbolt_types::CollectionInfo,
1403 locator: &str,
1404 report: &UpdateReport,
1405) -> String {
1406 let mut lines = Vec::new();
1407 if report.failed_docs == 0 {
1408 lines.push(format!("collection added and indexed: {locator}"));
1409 } else {
1410 lines.push(format!("collection added: {locator}"));
1411 lines.push("initial indexing incomplete".to_string());
1412 }
1413
1414 append_update_summary_lines(&mut lines, report);
1415
1416 let truncated = if !report.errors.is_empty() {
1417 append_update_error_lines(&mut lines, report, 3)
1418 } else {
1419 false
1420 };
1421
1422 if report.failed_docs > 0 || truncated {
1423 lines.push(String::new());
1424 lines.push("next:".to_string());
1425 let verbose_flag = if truncated { " --verbose" } else { "" };
1426 lines.push(format!(
1427 " kbolt --space {} update{verbose_flag} --collection {}",
1428 shell_quote_arg(&collection.space),
1429 shell_quote_arg(&collection.name),
1430 ));
1431 }
1432
1433 lines.join("\n")
1434}
1435
1436fn format_collection_add_block(
1437 collection: &kbolt_types::CollectionInfo,
1438 locator: &str,
1439 block: &InitialIndexingBlock,
1440) -> String {
1441 let mut lines = Vec::new();
1442 lines.push(format!("collection added: {locator}"));
1443
1444 match block {
1445 InitialIndexingBlock::SpaceDenseRepairRequired { space, reason } => {
1446 lines.push(format!(
1447 "indexing blocked by a dense integrity issue in space '{space}'"
1448 ));
1449 lines.push(format!("reason: {reason}"));
1450 lines.push(String::new());
1451 lines.push("next:".to_string());
1452 lines.push(format!(" kbolt --space {} update", shell_quote_arg(space)));
1453 }
1454 InitialIndexingBlock::ModelNotAvailable { name } => {
1455 lines.push(format!("indexing blocked: model '{name}' is not available"));
1456 lines.push(String::new());
1457 lines.push("next:".to_string());
1458 lines.push(" kbolt setup local".to_string());
1459 lines.push(" or configure [roles.embedder] in index.toml".to_string());
1460 lines.push(format!(
1461 " then run: kbolt --space {} update --collection {}",
1462 shell_quote_arg(&collection.space),
1463 shell_quote_arg(&collection.name),
1464 ));
1465 }
1466 }
1467
1468 lines.join("\n")
1469}
1470
1471fn append_update_error_lines(lines: &mut Vec<String>, report: &UpdateReport, limit: usize) -> bool {
1472 lines.push(String::new());
1473 lines.push("errors:".to_string());
1474 for error in report.errors.iter().take(limit) {
1475 lines.push(format!("- {}: {}", error.path, error.error));
1476 }
1477 let truncated = report.errors.len() > limit;
1478 if truncated {
1479 lines.push(format!("- {} more error(s)", report.errors.len() - limit));
1480 }
1481 truncated
1482}
1483
1484fn append_update_summary_lines(lines: &mut Vec<String>, report: &UpdateReport) {
1485 lines.push(format!("- {} document(s) scanned", report.scanned_docs));
1486
1487 let unchanged = report.skipped_mtime_docs + report.skipped_hash_docs;
1488 if unchanged > 0 {
1489 lines.push(format!("- {} unchanged", unchanged));
1490 }
1491 if report.added_docs > 0 {
1492 lines.push(format!("- {} added", report.added_docs));
1493 }
1494 if report.updated_docs > 0 {
1495 lines.push(format!("- {} updated", report.updated_docs));
1496 }
1497 if report.failed_docs > 0 {
1498 lines.push(format!("- {} failed", report.failed_docs));
1499 }
1500 if report.deactivated_docs > 0 {
1501 lines.push(format!("- {} deactivated", report.deactivated_docs));
1502 }
1503 if report.reactivated_docs > 0 {
1504 lines.push(format!("- {} reactivated", report.reactivated_docs));
1505 }
1506 if report.reaped_docs > 0 {
1507 lines.push(format!("- {} reaped", report.reaped_docs));
1508 }
1509 if report.embedded_chunks > 0 {
1510 lines.push(format!("- {} chunk(s) embedded", report.embedded_chunks));
1511 }
1512
1513 lines.push(format!(
1514 "- completed in {}",
1515 format_elapsed_ms(report.elapsed_ms)
1516 ));
1517}
1518
1519fn format_elapsed_ms(elapsed_ms: u64) -> String {
1520 if elapsed_ms < 1_000 {
1521 format!("{elapsed_ms}ms")
1522 } else if elapsed_ms < 60_000 {
1523 format!("{:.1}s", elapsed_ms as f64 / 1_000.0)
1524 } else {
1525 let total_seconds = elapsed_ms as f64 / 1_000.0;
1526 format!("{:.1}m", total_seconds / 60.0)
1527 }
1528}
1529
1530fn format_update_decision(decision: &UpdateDecision) -> String {
1531 let locator = format!(
1532 "{}/{}/{}",
1533 decision.space, decision.collection, decision.path
1534 );
1535 match decision.detail.as_deref() {
1536 Some(detail) => format!(
1537 "{locator}: {} ({detail})",
1538 format_update_decision_kind(&decision.kind)
1539 ),
1540 None => format!("{locator}: {}", format_update_decision_kind(&decision.kind)),
1541 }
1542}
1543
1544fn format_update_decision_kind(kind: &UpdateDecisionKind) -> &'static str {
1545 match kind {
1546 UpdateDecisionKind::New => "new",
1547 UpdateDecisionKind::Changed => "changed",
1548 UpdateDecisionKind::SkippedMtime => "skipped_mtime",
1549 UpdateDecisionKind::SkippedHash => "skipped_hash",
1550 UpdateDecisionKind::Ignored => "ignored",
1551 UpdateDecisionKind::Unsupported => "unsupported",
1552 UpdateDecisionKind::ReadFailed => "read_failed",
1553 UpdateDecisionKind::ExtractFailed => "extract_failed",
1554 UpdateDecisionKind::Reactivated => "reactivated",
1555 UpdateDecisionKind::Deactivated => "deactivated",
1556 }
1557}
1558
1559fn unreported_update_errors(report: &UpdateReport) -> Vec<&kbolt_types::FileError> {
1560 report
1561 .errors
1562 .iter()
1563 .filter(|error| {
1564 !report.decisions.iter().any(|decision| {
1565 matches!(
1566 decision.kind,
1567 UpdateDecisionKind::ReadFailed | UpdateDecisionKind::ExtractFailed
1568 ) && std::path::Path::new(&error.path)
1569 .ends_with(std::path::Path::new(&decision.path))
1570 })
1571 })
1572 .collect()
1573}
1574
1575pub fn resolve_no_rerank_for_mode(mode: SearchMode, rerank: bool, no_rerank: bool) -> bool {
1576 match mode {
1577 SearchMode::Auto => !rerank,
1578 SearchMode::Deep => no_rerank,
1579 SearchMode::Keyword | SearchMode::Semantic => true,
1580 }
1581}
1582
1583fn truncate_snippet(text: &str, max_lines: usize) -> String {
1584 let lines: Vec<&str> = text.lines().collect();
1585 if lines.len() <= max_lines {
1586 return text.to_string();
1587 }
1588 let truncated: Vec<&str> = lines[..max_lines].to_vec();
1589 let remaining = lines.len() - max_lines;
1590 format!(
1591 "{}\n(+{remaining} more line{})",
1592 truncated.join("\n"),
1593 if remaining == 1 { "" } else { "s" }
1594 )
1595}
1596
1597fn resolve_editor_command() -> Result<Vec<String>> {
1598 let raw = std::env::var("VISUAL")
1599 .ok()
1600 .filter(|value| !value.trim().is_empty())
1601 .or_else(|| {
1602 std::env::var("EDITOR")
1603 .ok()
1604 .filter(|value| !value.trim().is_empty())
1605 })
1606 .unwrap_or_else(|| "vi".to_string());
1607
1608 parse_editor_command(&raw)
1609}
1610
1611fn parse_editor_command(raw: &str) -> Result<Vec<String>> {
1612 let args = shell_words::split(raw).map_err(|err| {
1613 KboltError::InvalidInput(format!("invalid editor command '{raw}': {err}"))
1614 })?;
1615 if args.is_empty() {
1616 return Err(KboltError::InvalidInput("editor command cannot be empty".to_string()).into());
1617 }
1618 Ok(args)
1619}
1620
1621fn format_schedule_add_response(response: &ScheduleAddResponse) -> String {
1622 format!(
1623 "schedule added: {}\ntrigger: {}\nscope: {}\nbackend: {}",
1624 response.schedule.id,
1625 format_schedule_trigger(&response.schedule.trigger),
1626 format_schedule_scope(&response.schedule.scope),
1627 format_schedule_backend(response.backend),
1628 )
1629}
1630
1631fn format_schedule_status_response(response: &ScheduleStatusResponse) -> String {
1632 let mut lines = Vec::new();
1633 lines.push("schedules:".to_string());
1634 if response.schedules.is_empty() {
1635 lines.push("- none".to_string());
1636 } else {
1637 for entry in &response.schedules {
1638 lines.push(format!(
1639 "- {} | {} | {} | {} | {}",
1640 entry.schedule.id,
1641 format_schedule_trigger(&entry.schedule.trigger),
1642 format_schedule_scope(&entry.schedule.scope),
1643 format_schedule_backend(entry.backend),
1644 format_schedule_state(entry.state),
1645 ));
1646 lines.push(format!(
1647 " last_started: {}",
1648 entry.run_state.last_started.as_deref().unwrap_or("never")
1649 ));
1650 lines.push(format!(
1651 " last_finished: {}",
1652 entry.run_state.last_finished.as_deref().unwrap_or("never")
1653 ));
1654 lines.push(format!(
1655 " last_result: {}",
1656 format_schedule_run_result(entry.run_state.last_result)
1657 ));
1658 if let Some(error) = entry.run_state.last_error.as_deref() {
1659 lines.push(format!(" last_error: {error}"));
1660 }
1661 }
1662 }
1663
1664 lines.push("orphans:".to_string());
1665 if response.orphans.is_empty() {
1666 lines.push("- none".to_string());
1667 } else {
1668 for orphan in &response.orphans {
1669 lines.push(format!(
1670 "- {} ({})",
1671 orphan.id,
1672 format_schedule_backend(orphan.backend)
1673 ));
1674 }
1675 }
1676
1677 lines.join("\n")
1678}
1679
1680fn format_schedule_remove_response(response: &kbolt_types::ScheduleRemoveResponse) -> String {
1681 if response.removed_ids.is_empty() {
1682 return "removed schedules: none".to_string();
1683 }
1684
1685 format!("removed schedules: {}", response.removed_ids.join(", "))
1686}
1687
1688fn format_schedule_trigger(trigger: &ScheduleTrigger) -> String {
1689 match trigger {
1690 ScheduleTrigger::Every { interval } => format_schedule_interval(interval),
1691 ScheduleTrigger::Daily { time } => format!("daily at {}", format_schedule_time(time)),
1692 ScheduleTrigger::Weekly { weekdays, time } => format!(
1693 "{} at {}",
1694 format_schedule_weekdays(weekdays),
1695 format_schedule_time(time)
1696 ),
1697 }
1698}
1699
1700fn format_schedule_interval(interval: &ScheduleInterval) -> String {
1701 let suffix = match interval.unit {
1702 ScheduleIntervalUnit::Minutes => "m",
1703 ScheduleIntervalUnit::Hours => "h",
1704 };
1705 format!("every {}{suffix}", interval.value)
1706}
1707
1708fn format_schedule_scope(scope: &ScheduleScope) -> String {
1709 match scope {
1710 ScheduleScope::All => "all spaces".to_string(),
1711 ScheduleScope::Space { space } => format!("space {space}"),
1712 ScheduleScope::Collections { space, collections } => collections
1713 .iter()
1714 .map(|collection| format!("{space}/{collection}"))
1715 .collect::<Vec<_>>()
1716 .join(", "),
1717 }
1718}
1719
1720fn format_schedule_backend(backend: ScheduleBackend) -> &'static str {
1721 match backend {
1722 ScheduleBackend::Launchd => "launchd",
1723 ScheduleBackend::SystemdUser => "systemd-user",
1724 }
1725}
1726
1727fn format_schedule_state(state: ScheduleState) -> &'static str {
1728 match state {
1729 ScheduleState::Installed => "installed",
1730 ScheduleState::Drifted => "drifted",
1731 ScheduleState::TargetMissing => "target_missing",
1732 }
1733}
1734
1735fn format_schedule_run_result(result: Option<ScheduleRunResult>) -> &'static str {
1736 match result {
1737 Some(ScheduleRunResult::Success) => "success",
1738 Some(ScheduleRunResult::SkippedLock) => "skipped_lock",
1739 Some(ScheduleRunResult::Failed) => "failed",
1740 None => "never",
1741 }
1742}
1743
1744fn format_schedule_weekdays(weekdays: &[ScheduleWeekday]) -> String {
1745 weekdays
1746 .iter()
1747 .map(|weekday| match weekday {
1748 ScheduleWeekday::Mon => "mon",
1749 ScheduleWeekday::Tue => "tue",
1750 ScheduleWeekday::Wed => "wed",
1751 ScheduleWeekday::Thu => "thu",
1752 ScheduleWeekday::Fri => "fri",
1753 ScheduleWeekday::Sat => "sat",
1754 ScheduleWeekday::Sun => "sun",
1755 })
1756 .collect::<Vec<_>>()
1757 .join(",")
1758}
1759
1760fn format_schedule_time(time: &str) -> String {
1761 let Some((hour, minute)) = time.split_once(':') else {
1762 return time.to_string();
1763 };
1764 let Ok(mut hour) = hour.parse::<u32>() else {
1765 return time.to_string();
1766 };
1767 let Ok(minute) = minute.parse::<u32>() else {
1768 return time.to_string();
1769 };
1770
1771 let meridiem = if hour >= 12 { "PM" } else { "AM" };
1772 if hour == 0 {
1773 hour = 12;
1774 } else if hour > 12 {
1775 hour -= 12;
1776 }
1777
1778 format!("{hour}:{minute:02} {meridiem}")
1779}
1780
1781fn format_eval_run_report(report: &EvalRunReport) -> String {
1782 let mut lines = vec!["eval:".to_string()];
1783 for mode in &report.modes {
1784 lines.push(format!(
1785 "- {}: ndcg@10 {:.3}, recall@10 {:.3}, mrr@10 {:.3}, p50 {}ms, p95 {}ms",
1786 format_eval_mode_label(&mode.mode, mode.no_rerank),
1787 mode.ndcg_at_10,
1788 mode.recall_at_10,
1789 mode.mrr_at_10,
1790 mode.latency_p50_ms,
1791 mode.latency_p95_ms
1792 ));
1793 }
1794 for failure in &report.failed_modes {
1795 lines.push(format!(
1796 "- {}: failed ({})",
1797 format_eval_mode_label(&failure.mode, failure.no_rerank),
1798 failure.error
1799 ));
1800 }
1801
1802 let findings = report
1803 .modes
1804 .iter()
1805 .flat_map(|mode| {
1806 mode.queries.iter().filter_map(|query| {
1807 let perfect_recall = query.matched_paths.len() == relevant_judgment_count(query);
1808 let perfect_rank = query.first_relevant_rank == Some(1);
1809 if perfect_recall && perfect_rank {
1810 return None;
1811 }
1812
1813 Some(format!(
1814 "- [{}] {} | first relevant: {} | expected: {} | returned: {}",
1815 format_eval_mode_label(&mode.mode, mode.no_rerank),
1816 query.query,
1817 query
1818 .first_relevant_rank
1819 .map(|rank| rank.to_string())
1820 .unwrap_or_else(|| "none".to_string()),
1821 format_eval_judgments(&query.judgments),
1822 if query.returned_paths.is_empty() {
1823 "none".to_string()
1824 } else {
1825 query.returned_paths.join(", ")
1826 }
1827 ))
1828 })
1829 })
1830 .collect::<Vec<_>>();
1831
1832 if findings.is_empty() {
1833 lines.push("queries needing attention: none".to_string());
1834 } else {
1835 lines.push("queries needing attention:".to_string());
1836 lines.extend(findings);
1837 }
1838
1839 lines.join("\n")
1840}
1841
1842pub fn format_eval_import_report(report: &EvalImportReport) -> String {
1843 [
1844 format!("imported benchmark: {}", report.dataset),
1845 format!("source: {}", report.source),
1846 format!("output: {}", report.output_dir),
1847 format!("corpus_dir: {}", report.corpus_dir),
1848 format!("manifest: {}", report.manifest_path),
1849 format!("documents: {}", report.document_count),
1850 format!("queries: {}", report.query_count),
1851 format!("judgments: {}", report.judgment_count),
1852 "next:".to_string(),
1853 format!(
1854 "- create the benchmark space if needed: kbolt space add {}",
1855 shell_quote_arg(&report.default_space)
1856 ),
1857 format!(
1858 "- register the corpus: kbolt --space {} collection add {} --name {} --no-index",
1859 shell_quote_arg(&report.default_space),
1860 shell_quote_arg(&report.corpus_dir),
1861 shell_quote_arg(&report.collection),
1862 ),
1863 format!(
1864 "- index it: kbolt --space {} update --collection {}",
1865 shell_quote_arg(&report.default_space),
1866 shell_quote_arg(&report.collection),
1867 ),
1868 format!(
1869 "- run eval: kbolt eval run --file {}",
1870 shell_quote_arg(&report.manifest_path)
1871 ),
1872 ]
1873 .join("\n")
1874}
1875
1876fn relevant_judgment_count(query: &kbolt_types::EvalQueryReport) -> usize {
1877 query
1878 .judgments
1879 .iter()
1880 .filter(|judgment| judgment.relevance > 0)
1881 .count()
1882}
1883
1884fn format_eval_judgments(judgments: &[kbolt_types::EvalJudgment]) -> String {
1885 judgments
1886 .iter()
1887 .map(|judgment| format!("{}(rel={})", judgment.path, judgment.relevance))
1888 .collect::<Vec<_>>()
1889 .join(", ")
1890}
1891
1892fn format_eval_mode_label(mode: &SearchMode, no_rerank: bool) -> &'static str {
1893 match (mode, no_rerank) {
1894 (SearchMode::Keyword, _) => "keyword",
1895 (SearchMode::Auto, true) => "auto",
1896 (SearchMode::Auto, false) => "auto+rerank",
1897 (SearchMode::Semantic, _) => "semantic",
1898 (SearchMode::Deep, true) => "deep-norerank",
1899 (SearchMode::Deep, false) => "deep",
1900 }
1901}
1902
1903fn shell_quote_arg(arg: &str) -> String {
1907 let is_safe = !arg.is_empty()
1908 && arg.chars().all(|c| {
1909 c.is_ascii_alphanumeric()
1910 || matches!(c, '-' | '_' | '.' | '/' | ',' | ':' | '+' | '@' | '=')
1911 });
1912 if is_safe {
1913 arg.to_string()
1914 } else {
1915 format!("'{}'", arg.replace('\'', "'\\''"))
1916 }
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921 use std::ffi::OsString;
1922 use std::sync::{Mutex, OnceLock};
1923 use std::{
1924 fs,
1925 path::{Path, PathBuf},
1926 };
1927
1928 use tempfile::tempdir;
1929
1930 use super::{
1931 active_space_name_for_status, append_update_error_lines, format_collection_add_result,
1932 format_collection_info, format_doctor_report, format_document_response, format_elapsed_ms,
1933 format_eval_import_report, format_eval_run_report, format_file_list, format_local_report,
1934 format_models_list, format_multi_get_response, format_optional_search_signal,
1935 format_schedule_add_response, format_schedule_status_response, format_search_result_path,
1936 format_status_response, format_update_report, group_search_results,
1937 is_expected_unindexed_storage_warning, parse_editor_command, resolve_editor_command,
1938 resolve_no_rerank_for_mode, shell_quote_arg, truncate_snippet, CliAdapter,
1939 CliSearchOptions,
1940 };
1941 use kbolt_core::engine::Engine;
1942 use kbolt_types::{
1943 AddCollectionRequest, AddCollectionResult, CollectionInfo, CollectionStatus, DiskUsage,
1944 DoctorCheck, DoctorCheckStatus, DoctorReport, DoctorSetupStatus, DocumentResponse,
1945 EvalImportReport, EvalJudgment, EvalModeReport, EvalQueryReport, EvalRunReport, FileEntry,
1946 FileError, InitialIndexingBlock, InitialIndexingOutcome, LocalAction, LocalReport,
1947 ModelInfo, MultiGetResponse, OmitReason, OmittedFile, ScheduleAddResponse, ScheduleBackend,
1948 ScheduleDefinition, ScheduleInterval, ScheduleIntervalUnit, ScheduleOrphan,
1949 ScheduleRunResult, ScheduleRunState, ScheduleScope, ScheduleState, ScheduleStatusEntry,
1950 ScheduleStatusResponse, ScheduleTrigger, ScheduleWeekday, SearchMode, SearchResult,
1951 SpaceStatus, StatusResponse, UpdateReport,
1952 };
1953
1954 struct EnvRestore {
1955 home: Option<OsString>,
1956 config_home: Option<OsString>,
1957 cache_home: Option<OsString>,
1958 visual: Option<OsString>,
1959 editor: Option<OsString>,
1960 }
1961
1962 impl EnvRestore {
1963 fn capture() -> Self {
1964 Self {
1965 home: std::env::var_os("HOME"),
1966 config_home: std::env::var_os("XDG_CONFIG_HOME"),
1967 cache_home: std::env::var_os("XDG_CACHE_HOME"),
1968 visual: std::env::var_os("VISUAL"),
1969 editor: std::env::var_os("EDITOR"),
1970 }
1971 }
1972 }
1973
1974 impl Drop for EnvRestore {
1975 fn drop(&mut self) {
1976 match &self.home {
1977 Some(path) => std::env::set_var("HOME", path),
1978 None => std::env::remove_var("HOME"),
1979 }
1980 match &self.config_home {
1981 Some(path) => std::env::set_var("XDG_CONFIG_HOME", path),
1982 None => std::env::remove_var("XDG_CONFIG_HOME"),
1983 }
1984 match &self.cache_home {
1985 Some(path) => std::env::set_var("XDG_CACHE_HOME", path),
1986 None => std::env::remove_var("XDG_CACHE_HOME"),
1987 }
1988 match &self.visual {
1989 Some(value) => std::env::set_var("VISUAL", value),
1990 None => std::env::remove_var("VISUAL"),
1991 }
1992 match &self.editor {
1993 Some(value) => std::env::set_var("EDITOR", value),
1994 None => std::env::remove_var("EDITOR"),
1995 }
1996 }
1997 }
1998
1999 fn with_isolated_xdg_dirs<T>(run: impl FnOnce() -> T) -> T {
2000 static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2001 let lock = ENV_LOCK.get_or_init(|| Mutex::new(()));
2002 let _guard = lock.lock().expect("lock env mutex");
2003 let _restore = EnvRestore::capture();
2004
2005 let root = tempdir().expect("create temp root");
2006 std::env::set_var("HOME", root.path());
2007 std::env::set_var("XDG_CONFIG_HOME", root.path().join("config-home"));
2008 std::env::set_var("XDG_CACHE_HOME", root.path().join("cache-home"));
2009
2010 run()
2011 }
2012
2013 fn new_collection_dir(root: &Path, name: &str) -> PathBuf {
2014 let path = root.join(name);
2015 fs::create_dir_all(&path).expect("create collection directory");
2016 path
2017 }
2018
2019 #[test]
2020 fn editor_command_resolution_prefers_visual_then_editor_then_vi() {
2021 with_isolated_xdg_dirs(|| {
2022 std::env::set_var("VISUAL", "nvim -f");
2023 std::env::set_var("EDITOR", "vim");
2024 let from_visual = resolve_editor_command().expect("resolve visual");
2025 assert_eq!(from_visual, vec!["nvim".to_string(), "-f".to_string()]);
2026
2027 std::env::remove_var("VISUAL");
2028 let from_editor = resolve_editor_command().expect("resolve editor");
2029 assert_eq!(from_editor, vec!["vim".to_string()]);
2030
2031 std::env::remove_var("EDITOR");
2032 let fallback = resolve_editor_command().expect("resolve fallback");
2033 assert_eq!(fallback, vec!["vi".to_string()]);
2034 });
2035 }
2036
2037 #[test]
2038 fn parse_editor_command_rejects_invalid_shell_words() {
2039 let err = parse_editor_command("'").expect_err("invalid shell words should fail");
2040 assert!(
2041 err.to_string().contains("invalid editor command"),
2042 "unexpected error: {err}"
2043 );
2044 }
2045
2046 #[test]
2047 fn eval_run_report_formats_summary_and_attention_queries() {
2048 let output = format_eval_run_report(&EvalRunReport {
2049 total_cases: 1,
2050 modes: vec![
2051 EvalModeReport {
2052 mode: SearchMode::Keyword,
2053 no_rerank: true,
2054 ndcg_at_10: 1.0,
2055 recall_at_10: 1.0,
2056 mrr_at_10: 1.0,
2057 latency_p50_ms: 2,
2058 latency_p95_ms: 3,
2059 queries: vec![EvalQueryReport {
2060 query: "trait object generic".to_string(),
2061 space: Some("default".to_string()),
2062 collections: vec!["rust".to_string()],
2063 judgments: vec![EvalJudgment {
2064 path: "rust/guides/traits.md".to_string(),
2065 relevance: 1,
2066 }],
2067 returned_paths: vec!["rust/guides/traits.md".to_string()],
2068 matched_paths: vec!["rust/guides/traits.md".to_string()],
2069 first_relevant_rank: Some(1),
2070 elapsed_ms: 2,
2071 }],
2072 },
2073 EvalModeReport {
2074 mode: SearchMode::Deep,
2075 no_rerank: false,
2076 ndcg_at_10: 0.0,
2077 recall_at_10: 0.0,
2078 mrr_at_10: 0.0,
2079 latency_p50_ms: 8,
2080 latency_p95_ms: 12,
2081 queries: vec![EvalQueryReport {
2082 query: "trait object generic".to_string(),
2083 space: Some("default".to_string()),
2084 collections: vec!["rust".to_string()],
2085 judgments: vec![EvalJudgment {
2086 path: "rust/guides/traits.md".to_string(),
2087 relevance: 1,
2088 }],
2089 returned_paths: vec!["rust/overview.md".to_string()],
2090 matched_paths: vec![],
2091 first_relevant_rank: None,
2092 elapsed_ms: 8,
2093 }],
2094 },
2095 ],
2096 failed_modes: vec![kbolt_types::EvalModeFailure {
2097 mode: SearchMode::Semantic,
2098 no_rerank: true,
2099 error: "model not available".to_string(),
2100 }],
2101 });
2102
2103 assert!(output
2104 .contains("- keyword: ndcg@10 1.000, recall@10 1.000, mrr@10 1.000, p50 2ms, p95 3ms"));
2105 assert!(output
2106 .contains("- deep: ndcg@10 0.000, recall@10 0.000, mrr@10 0.000, p50 8ms, p95 12ms"));
2107 assert!(output.contains("- semantic: failed (model not available)"));
2108 assert!(output.contains("queries needing attention:"));
2109 assert!(output.contains("[deep] trait object generic | first relevant: none"));
2110 }
2111
2112 #[test]
2113 fn eval_import_report_formats_next_steps() {
2114 let output = format_eval_import_report(&EvalImportReport {
2115 dataset: "scifact".to_string(),
2116 source: "/tmp/scifact-source".to_string(),
2117 output_dir: "/tmp/scifact-bench".to_string(),
2118 corpus_dir: "/tmp/scifact-bench/corpus".to_string(),
2119 manifest_path: "/tmp/scifact-bench/eval.toml".to_string(),
2120 default_space: "bench".to_string(),
2121 collection: "scifact".to_string(),
2122 document_count: 2,
2123 query_count: 2,
2124 judgment_count: 3,
2125 });
2126
2127 assert!(output.contains("imported benchmark: scifact"));
2128 assert!(output.contains("documents: 2"));
2129 assert!(output.contains("queries: 2"));
2130 assert!(output.contains("judgments: 3"));
2131 assert!(output.contains("kbolt space add bench"));
2132 assert!(output.contains("kbolt eval run --file /tmp/scifact-bench/eval.toml"));
2133 }
2134
2135 #[test]
2136 fn models_list_reports_role_binding_readiness() {
2137 with_isolated_xdg_dirs(|| {
2138 let engine = Engine::new(None).expect("create engine");
2139 let adapter = CliAdapter::new(engine);
2140
2141 let output = adapter.models_list().expect("list models");
2142 assert!(output.contains("models:"), "unexpected output: {output}");
2143 assert!(
2144 output.contains("- embedder: not configured"),
2145 "unexpected output: {output}"
2146 );
2147 assert!(
2148 output.contains("- reranker: not configured"),
2149 "unexpected output: {output}"
2150 );
2151 assert!(
2152 output.contains("- expander: not configured"),
2153 "unexpected output: {output}"
2154 );
2155 });
2156 }
2157
2158 #[test]
2159 fn models_list_surfaces_not_ready_issue_without_provider_dump() {
2160 let output = format_models_list(&kbolt_types::ModelStatus {
2161 embedder: ModelInfo {
2162 configured: true,
2163 ready: false,
2164 profile: Some("kbolt_local_embed".to_string()),
2165 kind: Some("llama_cpp_server".to_string()),
2166 operation: Some("embedding".to_string()),
2167 model: Some("embeddinggemma".to_string()),
2168 endpoint: Some("http://127.0.0.1:8101".to_string()),
2169 issue: Some("endpoint is unreachable".to_string()),
2170 },
2171 reranker: ModelInfo {
2172 configured: true,
2173 ready: true,
2174 profile: Some("kbolt_local_rerank".to_string()),
2175 kind: Some("llama_cpp_server".to_string()),
2176 operation: Some("reranking".to_string()),
2177 model: Some("qwen3-reranker".to_string()),
2178 endpoint: Some("http://127.0.0.1:8102".to_string()),
2179 issue: None,
2180 },
2181 expander: ModelInfo {
2182 configured: false,
2183 ready: false,
2184 profile: None,
2185 kind: None,
2186 operation: None,
2187 model: None,
2188 endpoint: None,
2189 issue: None,
2190 },
2191 });
2192
2193 assert!(output.contains("- embedder: not ready (embeddinggemma)"));
2194 assert!(output.contains(" issue: endpoint is unreachable"));
2195 assert!(output.contains("- reranker: ready (qwen3-reranker)"));
2196 assert!(output.contains("- expander: not configured"));
2197 assert!(!output.contains("profile="), "unexpected output:\n{output}");
2198 assert!(
2199 !output.contains("endpoint=http"),
2200 "unexpected output:\n{output}"
2201 );
2202 }
2203
2204 #[test]
2205 fn doctor_report_success_is_concise() {
2206 let output = format_doctor_report(&DoctorReport {
2207 setup_status: DoctorSetupStatus::Configured,
2208 config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
2209 config_dir: Some(PathBuf::from("/tmp/kbolt")),
2210 cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
2211 ready: true,
2212 checks: vec![
2213 DoctorCheck {
2214 id: "roles.embedder.bound".to_string(),
2215 scope: "roles.embedder".to_string(),
2216 status: DoctorCheckStatus::Pass,
2217 elapsed_ms: 0,
2218 message: "bound".to_string(),
2219 fix: None,
2220 },
2221 DoctorCheck {
2222 id: "roles.expander.bound".to_string(),
2223 scope: "roles.expander".to_string(),
2224 status: DoctorCheckStatus::Warn,
2225 elapsed_ms: 0,
2226 message: "role is not configured".to_string(),
2227 fix: Some("configure expander".to_string()),
2228 },
2229 ],
2230 });
2231
2232 assert!(
2233 output.contains("kbolt is ready"),
2234 "unexpected output:\n{output}"
2235 );
2236 assert!(output.contains("configured:"));
2237 assert!(output.contains(" embedder"));
2238 assert!(output.contains("not enabled:"));
2239 assert!(output.contains(" expander"));
2240 assert!(
2241 !output.contains("PASS"),
2242 "should not show raw check status in success case"
2243 );
2244 }
2245
2246 #[test]
2247 fn doctor_report_shows_failures_with_fixes() {
2248 let output = format_doctor_report(&DoctorReport {
2249 setup_status: DoctorSetupStatus::Configured,
2250 config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
2251 config_dir: Some(PathBuf::from("/tmp/kbolt")),
2252 cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
2253 ready: false,
2254 checks: vec![DoctorCheck {
2255 id: "roles.embedder.reachable".to_string(),
2256 scope: "roles.embedder".to_string(),
2257 status: DoctorCheckStatus::Fail,
2258 elapsed_ms: 17,
2259 message: "endpoint is unreachable".to_string(),
2260 fix: Some("Start the embedding server.".to_string()),
2261 }],
2262 });
2263
2264 assert!(
2265 output.contains("kbolt has issues"),
2266 "unexpected output:\n{output}"
2267 );
2268 assert!(output.contains("failures:"));
2269 assert!(output.contains("endpoint is unreachable"));
2270 assert!(output.contains("fix: Start the embedding server."));
2271 }
2272
2273 #[test]
2274 fn doctor_report_missing_config_guides_to_setup() {
2275 let output = format_doctor_report(&DoctorReport {
2276 setup_status: DoctorSetupStatus::ConfigMissing,
2277 config_file: None,
2278 config_dir: None,
2279 cache_dir: None,
2280 ready: false,
2281 checks: vec![],
2282 });
2283
2284 assert!(
2285 output.contains("kbolt is not set up"),
2286 "unexpected output:\n{output}"
2287 );
2288 assert!(output.contains("kbolt setup local"));
2289 }
2290
2291 #[test]
2292 fn doctor_report_treats_unindexed_storage_as_expected_next_step() {
2293 let output = format_doctor_report(&DoctorReport {
2294 setup_status: DoctorSetupStatus::Configured,
2295 config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
2296 config_dir: Some(PathBuf::from("/tmp/kbolt")),
2297 cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
2298 ready: true,
2299 checks: vec![
2300 DoctorCheck {
2301 id: "roles.embedder.bound".to_string(),
2302 scope: "roles.embedder".to_string(),
2303 status: DoctorCheckStatus::Pass,
2304 elapsed_ms: 0,
2305 message: "bound".to_string(),
2306 fix: None,
2307 },
2308 DoctorCheck {
2309 id: "storage.sqlite_readable".to_string(),
2310 scope: "storage".to_string(),
2311 status: DoctorCheckStatus::Warn,
2312 elapsed_ms: 0,
2313 message: "index database does not exist yet: /tmp/cache/kbolt/meta.sqlite"
2314 .to_string(),
2315 fix: Some(
2316 "Run `kbolt update` after adding a collection to build the index."
2317 .to_string(),
2318 ),
2319 },
2320 DoctorCheck {
2321 id: "storage.search_indexes_readable".to_string(),
2322 scope: "storage".to_string(),
2323 status: DoctorCheckStatus::Warn,
2324 elapsed_ms: 0,
2325 message: "search index directory does not exist yet: /tmp/cache/kbolt/spaces"
2326 .to_string(),
2327 fix: Some(
2328 "Run `kbolt update` after adding a collection to build search indexes."
2329 .to_string(),
2330 ),
2331 },
2332 ],
2333 });
2334
2335 assert!(
2336 output.contains("kbolt is ready"),
2337 "unexpected output:\n{output}"
2338 );
2339 assert!(output.contains("indexing:"), "unexpected output:\n{output}");
2340 assert!(
2341 output.contains("no collections have been indexed yet"),
2342 "unexpected output:\n{output}"
2343 );
2344 assert!(output.contains("next:"), "unexpected output:\n{output}");
2345 assert!(
2346 output.contains("kbolt collection add /path/to/docs"),
2347 "unexpected output:\n{output}"
2348 );
2349 assert!(
2350 output.contains("or, if collections are already registered: kbolt update"),
2351 "unexpected output:\n{output}"
2352 );
2353 assert!(
2354 !output.contains("warnings:"),
2355 "unexpected output:\n{output}"
2356 );
2357 }
2358
2359 #[test]
2360 fn expected_unindexed_storage_warning_matches_only_storage_warns() {
2361 let warn = DoctorCheck {
2362 id: "storage.sqlite_readable".to_string(),
2363 scope: "storage".to_string(),
2364 status: DoctorCheckStatus::Warn,
2365 elapsed_ms: 0,
2366 message: "missing".to_string(),
2367 fix: None,
2368 };
2369 assert!(is_expected_unindexed_storage_warning(&warn));
2370
2371 let fail = DoctorCheck {
2372 status: DoctorCheckStatus::Fail,
2373 ..warn.clone()
2374 };
2375 assert!(!is_expected_unindexed_storage_warning(&fail));
2376
2377 let other = DoctorCheck {
2378 id: "roles.expander.bound".to_string(),
2379 ..warn
2380 };
2381 assert!(!is_expected_unindexed_storage_warning(&other));
2382 }
2383
2384 #[test]
2385 fn local_report_shows_ready_services_and_hides_internals() {
2386 let output = format_local_report(&LocalReport {
2387 action: LocalAction::Setup,
2388 config_file: PathBuf::from("/tmp/kbolt/index.toml"),
2389 cache_dir: PathBuf::from("/tmp/cache/kbolt"),
2390 llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
2391 ready: true,
2392 notes: vec![],
2393 services: vec![
2394 kbolt_types::LocalServiceReport {
2395 name: "embedder".to_string(),
2396 provider: "kbolt_local_embed".to_string(),
2397 enabled: true,
2398 configured: true,
2399 managed: true,
2400 running: true,
2401 ready: true,
2402 model: "embeddinggemma".to_string(),
2403 model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
2404 endpoint: "http://127.0.0.1:8101".to_string(),
2405 port: 8101,
2406 pid: Some(42),
2407 pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
2408 log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
2409 issue: None,
2410 },
2411 kbolt_types::LocalServiceReport {
2412 name: "expander".to_string(),
2413 provider: "kbolt_local_expand".to_string(),
2414 enabled: false,
2415 configured: false,
2416 managed: false,
2417 running: false,
2418 ready: false,
2419 model: "qmd".to_string(),
2420 model_path: PathBuf::from("/tmp/cache/kbolt/models/expander/model.gguf"),
2421 endpoint: "http://127.0.0.1:8103".to_string(),
2422 port: 8103,
2423 pid: None,
2424 pid_file: PathBuf::from("/tmp/cache/kbolt/run/expander.pid"),
2425 log_file: PathBuf::from("/tmp/cache/kbolt/logs/expander.log"),
2426 issue: Some("not configured".to_string()),
2427 },
2428 ],
2429 });
2430
2431 assert!(
2432 output.contains("local setup complete"),
2433 "unexpected output:\n{output}"
2434 );
2435 assert!(output.contains(" embedder (embeddinggemma)"));
2436 assert!(output.contains("not configured:"));
2437 assert!(output.contains(" expander"));
2438 assert!(output.contains("/tmp/kbolt/index.toml"));
2439 assert!(output.contains("kbolt collection add"));
2440 assert!(
2441 !output.contains("pid"),
2442 "should not expose pid in default output"
2443 );
2444 assert!(
2445 !output.contains("log_file"),
2446 "should not expose log_file in default output"
2447 );
2448 assert!(
2449 !output.contains("model_path"),
2450 "should not expose model_path in default output"
2451 );
2452 }
2453
2454 #[test]
2455 fn local_report_surfaces_notes() {
2456 let output = format_local_report(&LocalReport {
2457 action: LocalAction::Setup,
2458 config_file: PathBuf::from("/tmp/kbolt/index.toml"),
2459 cache_dir: PathBuf::from("/tmp/cache/kbolt"),
2460 llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
2461 ready: true,
2462 notes: vec![
2463 "moved incompatible old config to /tmp/index.toml.invalid.bak".to_string(),
2464 "started embedder on http://127.0.0.1:8101".to_string(),
2465 ],
2466 services: vec![],
2467 });
2468
2469 assert!(output.contains("notes:"), "unexpected output:\n{output}");
2470 assert!(
2471 output.contains("moved incompatible old config"),
2472 "unexpected output:\n{output}"
2473 );
2474 assert!(
2475 output.contains("started embedder on http://127.0.0.1:8101"),
2476 "unexpected output:\n{output}"
2477 );
2478 }
2479
2480 #[test]
2481 fn local_report_shows_issues_when_not_ready() {
2482 let output = format_local_report(&LocalReport {
2483 action: LocalAction::Setup,
2484 config_file: PathBuf::from("/tmp/kbolt/index.toml"),
2485 cache_dir: PathBuf::from("/tmp/cache/kbolt"),
2486 llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
2487 ready: false,
2488 notes: vec![],
2489 services: vec![kbolt_types::LocalServiceReport {
2490 name: "embedder".to_string(),
2491 provider: "kbolt_local_embed".to_string(),
2492 enabled: true,
2493 configured: true,
2494 managed: true,
2495 running: true,
2496 ready: false,
2497 model: "embeddinggemma".to_string(),
2498 model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
2499 endpoint: "http://127.0.0.1:8101".to_string(),
2500 port: 8101,
2501 pid: Some(42),
2502 pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
2503 log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
2504 issue: Some("service is not ready".to_string()),
2505 }],
2506 });
2507
2508 assert!(
2509 output.contains("(not ready)"),
2510 "unexpected output:\n{output}"
2511 );
2512 assert!(output.contains("issues:"));
2513 assert!(output.contains("embedder: service is not ready"));
2514 }
2515
2516 #[test]
2517 fn local_stop_report_treats_stopped_services_as_expected() {
2518 let output = format_local_report(&LocalReport {
2519 action: LocalAction::Stop,
2520 config_file: PathBuf::from("/tmp/kbolt/index.toml"),
2521 cache_dir: PathBuf::from("/tmp/cache/kbolt"),
2522 llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
2523 ready: false,
2524 notes: vec![
2525 "stopped embedder".to_string(),
2526 "stopped reranker".to_string(),
2527 ],
2528 services: vec![
2529 kbolt_types::LocalServiceReport {
2530 name: "embedder".to_string(),
2531 provider: "kbolt_local_embed".to_string(),
2532 enabled: true,
2533 configured: true,
2534 managed: false,
2535 running: false,
2536 ready: false,
2537 model: "embeddinggemma".to_string(),
2538 model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
2539 endpoint: "http://127.0.0.1:8101".to_string(),
2540 port: 8101,
2541 pid: None,
2542 pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
2543 log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
2544 issue: Some("service is not ready".to_string()),
2545 },
2546 kbolt_types::LocalServiceReport {
2547 name: "reranker".to_string(),
2548 provider: "kbolt_local_rerank".to_string(),
2549 enabled: true,
2550 configured: true,
2551 managed: false,
2552 running: false,
2553 ready: false,
2554 model: "qwen3-reranker".to_string(),
2555 model_path: PathBuf::from("/tmp/cache/kbolt/models/reranker/model.gguf"),
2556 endpoint: "http://127.0.0.1:8102".to_string(),
2557 port: 8102,
2558 pid: None,
2559 pid_file: PathBuf::from("/tmp/cache/kbolt/run/reranker.pid"),
2560 log_file: PathBuf::from("/tmp/cache/kbolt/logs/reranker.log"),
2561 issue: Some("service is not ready".to_string()),
2562 },
2563 kbolt_types::LocalServiceReport {
2564 name: "expander".to_string(),
2565 provider: "kbolt_local_expand".to_string(),
2566 enabled: false,
2567 configured: false,
2568 managed: false,
2569 running: false,
2570 ready: false,
2571 model: "qmd".to_string(),
2572 model_path: PathBuf::from("/tmp/cache/kbolt/models/expander/model.gguf"),
2573 endpoint: "http://127.0.0.1:8103".to_string(),
2574 port: 8103,
2575 pid: None,
2576 pid_file: PathBuf::from("/tmp/cache/kbolt/run/expander.pid"),
2577 log_file: PathBuf::from("/tmp/cache/kbolt/logs/expander.log"),
2578 issue: Some("service is not configured".to_string()),
2579 },
2580 ],
2581 });
2582
2583 assert!(
2584 output.starts_with("local servers stopped\n"),
2585 "unexpected output:\n{output}"
2586 );
2587 assert!(
2588 !output.contains("(not ready)"),
2589 "unexpected output:\n{output}"
2590 );
2591 assert!(!output.contains("issues:"), "unexpected output:\n{output}");
2592 assert!(
2593 !output.contains("not configured:"),
2594 "unexpected output:\n{output}"
2595 );
2596 assert!(
2597 output.contains("stopped embedder"),
2598 "unexpected output:\n{output}"
2599 );
2600 assert!(
2601 output.contains("stopped reranker"),
2602 "unexpected output:\n{output}"
2603 );
2604 }
2605
2606 #[test]
2607 fn truncate_snippet_preserves_short_text() {
2608 assert_eq!(
2609 truncate_snippet("line one\nline two", 4),
2610 "line one\nline two"
2611 );
2612 }
2613
2614 #[test]
2615 fn truncate_snippet_truncates_long_text() {
2616 let text = "one\ntwo\nthree\nfour\nfive\nsix";
2617 let result = truncate_snippet(text, 3);
2618 assert!(result.contains("one\ntwo\nthree\n"), "unexpected: {result}");
2619 assert!(result.contains("(+3 more lines)"), "unexpected: {result}");
2620 }
2621
2622 #[test]
2623 fn search_rejects_conflicting_mode_flags() {
2624 with_isolated_xdg_dirs(|| {
2625 let adapter = CliAdapter::new(Engine::new(None).expect("create engine"));
2626
2627 let err = adapter
2628 .search(CliSearchOptions {
2629 space: None,
2630 query: "alpha",
2631 collections: &[],
2632 limit: 10,
2633 min_score: 0.0,
2634 deep: true,
2635 keyword: true,
2636 semantic: false,
2637 rerank: false,
2638 no_rerank: false,
2639 debug: false,
2640 })
2641 .expect_err("conflicting search flags should fail");
2642 assert!(
2643 err.to_string()
2644 .contains("only one of --deep, --keyword, or --semantic"),
2645 "unexpected error: {err}"
2646 );
2647 });
2648 }
2649
2650 #[test]
2651 fn empty_index_hint_fresh_install_suggests_scoped_add_collection() {
2652 with_isolated_xdg_dirs(|| {
2653 let adapter = CliAdapter::new(Engine::new(None).expect("create engine"));
2654 let hint = adapter
2655 .empty_index_hint(None, &[])
2656 .expect("hint should fire on a fresh home");
2657 assert!(
2658 hint.contains("no collections registered yet"),
2659 "unexpected hint: {hint}"
2660 );
2661 assert!(
2662 hint.contains("kbolt --space default collection add /path/to/docs"),
2663 "should suggest --space default on fresh install: {hint}"
2664 );
2665 });
2666 }
2667
2668 #[test]
2669 fn empty_index_hint_is_silent_when_any_collection_has_content() {
2670 with_isolated_xdg_dirs(|| {
2671 let root = tempdir().expect("create temp root");
2672 let engine = Engine::new(None).expect("create engine");
2673 let coll_path = new_collection_dir(root.path(), "notes");
2674 fs::write(coll_path.join("a.md"), "hello world\n").expect("write file");
2675 engine
2676 .add_collection(AddCollectionRequest {
2677 path: coll_path,
2678 space: Some("default".to_string()),
2679 name: Some("notes".to_string()),
2680 description: None,
2681 extensions: None,
2682 no_index: true,
2683 })
2684 .expect("add collection");
2685
2686 let adapter = CliAdapter::new(engine);
2687 adapter
2688 .update(Some("default"), &[], true, false, false)
2689 .expect("run update");
2690
2691 let hint = adapter.empty_index_hint(None, &[]);
2692 assert!(
2693 hint.is_none(),
2694 "expected silent when content exists, got: {hint:?}"
2695 );
2696 });
2697 }
2698
2699 #[test]
2700 fn empty_index_hint_is_silent_on_mixed_unscoped_search() {
2701 with_isolated_xdg_dirs(|| {
2702 let root = tempdir().expect("create temp root");
2703 let engine = Engine::new(None).expect("create engine");
2704
2705 let hot = new_collection_dir(root.path(), "hot");
2706 fs::write(hot.join("a.md"), "content\n").expect("write file");
2707 engine
2708 .add_collection(AddCollectionRequest {
2709 path: hot,
2710 space: Some("default".to_string()),
2711 name: Some("hot".to_string()),
2712 description: None,
2713 extensions: None,
2714 no_index: true,
2715 })
2716 .expect("add hot");
2717
2718 let cold = new_collection_dir(root.path(), "cold");
2719 engine
2720 .add_collection(AddCollectionRequest {
2721 path: cold,
2722 space: Some("default".to_string()),
2723 name: Some("cold".to_string()),
2724 description: None,
2725 extensions: None,
2726 no_index: true,
2727 })
2728 .expect("add cold");
2729
2730 let adapter = CliAdapter::new(engine);
2731 adapter
2732 .update(Some("default"), &[], true, false, false)
2733 .expect("run update");
2734
2735 let hint = adapter.empty_index_hint(None, &[]);
2736 assert!(
2737 hint.is_none(),
2738 "expected silent on mixed unscoped, got: {hint:?}"
2739 );
2740 });
2741 }
2742
2743 #[test]
2744 fn empty_index_hint_scoped_to_empty_collection_points_at_collection_info() {
2745 with_isolated_xdg_dirs(|| {
2746 let root = tempdir().expect("create temp root");
2747 let engine = Engine::new(None).expect("create engine");
2748
2749 let hot = new_collection_dir(root.path(), "hot");
2750 fs::write(hot.join("a.md"), "content\n").expect("write file");
2751 engine
2752 .add_collection(AddCollectionRequest {
2753 path: hot,
2754 space: Some("default".to_string()),
2755 name: Some("hot".to_string()),
2756 description: None,
2757 extensions: None,
2758 no_index: true,
2759 })
2760 .expect("add hot");
2761
2762 let cold = new_collection_dir(root.path(), "cold");
2763 engine
2764 .add_collection(AddCollectionRequest {
2765 path: cold,
2766 space: Some("default".to_string()),
2767 name: Some("cold".to_string()),
2768 description: None,
2769 extensions: None,
2770 no_index: true,
2771 })
2772 .expect("add cold");
2773
2774 let adapter = CliAdapter::new(engine);
2775 adapter
2776 .update(Some("default"), &[], true, false, false)
2777 .expect("run update");
2778
2779 let hint = adapter
2780 .empty_index_hint(None, &["cold".to_string()])
2781 .expect("hint should fire when scoped to an empty collection");
2782
2783 assert!(
2784 hint.contains("no indexed content in selected collection(s): cold"),
2785 "unexpected hint: {hint}"
2786 );
2787 assert!(
2788 hint.contains("kbolt --space default collection info cold"),
2789 "should point at collection info with the actual space: {hint}"
2790 );
2791 assert!(
2792 !hint.to_lowercase().contains("update"),
2793 "should not recommend update, got: {hint}"
2794 );
2795 });
2796 }
2797
2798 #[test]
2799 fn empty_index_hint_shell_quotes_names_with_whitespace() {
2800 with_isolated_xdg_dirs(|| {
2801 let root = tempdir().expect("create temp root");
2802 let engine = Engine::new(None).expect("create engine");
2803
2804 let cold = new_collection_dir(root.path(), "cold");
2805 engine
2806 .add_collection(AddCollectionRequest {
2807 path: cold,
2808 space: Some("default".to_string()),
2809 name: Some("cold docs".to_string()),
2810 description: None,
2811 extensions: None,
2812 no_index: true,
2813 })
2814 .expect("add cold docs");
2815
2816 let adapter = CliAdapter::new(engine);
2817
2818 let hint = adapter
2819 .empty_index_hint(None, &["cold docs".to_string()])
2820 .expect("hint should fire");
2821
2822 assert!(
2823 hint.contains("kbolt --space default collection info 'cold docs'"),
2824 "collection name with whitespace must be single-quoted in the hint: {hint}"
2825 );
2826 });
2827 }
2828
2829 #[test]
2830 fn shell_quote_arg_leaves_safe_tokens_alone_and_escapes_risky_ones() {
2831 assert_eq!(shell_quote_arg("default"), "default");
2832 assert_eq!(shell_quote_arg("work-notes"), "work-notes");
2833 assert_eq!(shell_quote_arg("./path/to/docs"), "./path/to/docs");
2834 assert_eq!(shell_quote_arg("cold docs"), "'cold docs'");
2835 assert_eq!(shell_quote_arg(""), "''");
2836 assert_eq!(shell_quote_arg("it's fine"), "'it'\\''s fine'");
2837 }
2838
2839 #[test]
2840 fn resolve_no_rerank_for_mode_matches_cli_contract() {
2841 assert!(resolve_no_rerank_for_mode(SearchMode::Auto, false, false));
2842 assert!(!resolve_no_rerank_for_mode(SearchMode::Auto, true, false));
2843 assert!(!resolve_no_rerank_for_mode(SearchMode::Deep, false, false));
2844 assert!(resolve_no_rerank_for_mode(SearchMode::Deep, false, true));
2845 assert!(resolve_no_rerank_for_mode(SearchMode::Keyword, true, false));
2846 assert!(resolve_no_rerank_for_mode(
2847 SearchMode::Semantic,
2848 true,
2849 false
2850 ));
2851 }
2852
2853 #[test]
2854 fn search_reports_requested_and_effective_mode_for_auto_keyword_fallback() {
2855 with_isolated_xdg_dirs(|| {
2856 let root = tempdir().expect("create collection root");
2857 let engine = Engine::new(None).expect("create engine");
2858 engine.add_space("work", None).expect("add work");
2859
2860 let work_path = new_collection_dir(root.path(), "work-api");
2861 engine
2862 .add_collection(AddCollectionRequest {
2863 path: work_path.clone(),
2864 space: Some("work".to_string()),
2865 name: Some("api".to_string()),
2866 description: None,
2867 extensions: None,
2868 no_index: true,
2869 })
2870 .expect("add collection");
2871 fs::write(work_path.join("a.md"), "fallback token\n").expect("write file");
2872
2873 let adapter = CliAdapter::new(engine);
2874 adapter
2875 .update(Some("work"), &["api".to_string()], true, false, false)
2876 .expect("run update");
2877
2878 let output = adapter
2879 .search(CliSearchOptions {
2880 space: Some("work"),
2881 query: "fallback",
2882 collections: &["api".to_string()],
2883 limit: 5,
2884 min_score: 0.0,
2885 deep: false,
2886 keyword: false,
2887 semantic: false,
2888 rerank: false,
2889 no_rerank: false,
2890 debug: true,
2891 })
2892 .expect("run auto search");
2893
2894 assert!(
2895 output.contains("mode: auto -> keyword"),
2896 "unexpected output: {output}"
2897 );
2898 assert!(
2899 output.contains("pipeline: keyword"),
2900 "unexpected output: {output}"
2901 );
2902 assert!(
2903 output.contains("note: dense retrieval unavailable: not configured"),
2904 "unexpected output: {output}"
2905 );
2906 });
2907 }
2908
2909 #[test]
2910 fn search_result_paths_include_space_context() {
2911 assert_eq!(
2912 format_search_result_path("work", "api/guide.md"),
2913 "work/api/guide.md"
2914 );
2915 }
2916
2917 fn make_search_result(docid: &str, path: &str, score: f32, text: &str) -> SearchResult {
2918 SearchResult {
2919 docid: docid.to_string(),
2920 path: path.to_string(),
2921 title: format!("title for {path}"),
2922 space: "work".to_string(),
2923 collection: "docs".to_string(),
2924 heading: None,
2925 text: text.to_string(),
2926 score,
2927 signals: None,
2928 }
2929 }
2930
2931 fn make_search_result_in_space(
2932 docid: &str,
2933 space: &str,
2934 path: &str,
2935 score: f32,
2936 text: &str,
2937 ) -> SearchResult {
2938 SearchResult {
2939 docid: docid.to_string(),
2940 path: path.to_string(),
2941 title: format!("title for {path}"),
2942 space: space.to_string(),
2943 collection: "docs".to_string(),
2944 heading: None,
2945 text: text.to_string(),
2946 score,
2947 signals: None,
2948 }
2949 }
2950
2951 #[test]
2952 fn group_search_results_uses_first_chunk_per_document_and_counts_duplicates() {
2953 let results = vec![
2954 make_search_result("doc-a", "docs/a.md", 0.95, "alpha first"),
2955 make_search_result("doc-a", "docs/a.md", 0.91, "alpha second"),
2956 make_search_result("doc-b", "docs/b.md", 0.88, "beta first"),
2957 make_search_result("doc-c", "docs/c.md", 0.83, "gamma first"),
2958 make_search_result("doc-b", "docs/b.md", 0.81, "beta second"),
2959 ];
2960
2961 let grouped = group_search_results(&results, 10);
2962
2963 assert_eq!(grouped.len(), 3);
2964 assert_eq!(grouped[0].primary.docid, "doc-a");
2965 assert_eq!(grouped[0].primary.text, "alpha first");
2966 assert_eq!(grouped[0].additional_matches, 1);
2967 assert_eq!(grouped[1].primary.docid, "doc-b");
2968 assert_eq!(grouped[1].primary.text, "beta first");
2969 assert_eq!(grouped[1].additional_matches, 1);
2970 assert_eq!(grouped[2].primary.docid, "doc-c");
2971 assert_eq!(grouped[2].additional_matches, 0);
2972 }
2973
2974 #[test]
2975 fn group_search_results_respects_group_limit_but_counts_visible_duplicates() {
2976 let results = vec![
2977 make_search_result("doc-a", "docs/a.md", 0.95, "alpha first"),
2978 make_search_result("doc-b", "docs/b.md", 0.90, "beta first"),
2979 make_search_result("doc-c", "docs/c.md", 0.85, "gamma first"),
2980 make_search_result("doc-b", "docs/b.md", 0.82, "beta second"),
2981 make_search_result("doc-a", "docs/a.md", 0.80, "alpha second"),
2982 ];
2983
2984 let grouped = group_search_results(&results, 2);
2985
2986 assert_eq!(grouped.len(), 2);
2987 assert_eq!(grouped[0].primary.docid, "doc-a");
2988 assert_eq!(grouped[0].additional_matches, 1);
2989 assert_eq!(grouped[1].primary.docid, "doc-b");
2990 assert_eq!(grouped[1].additional_matches, 1);
2991 assert!(
2992 grouped.iter().all(|item| item.primary.docid != "doc-c"),
2993 "unexpected grouped results: {:?}",
2994 grouped
2995 .iter()
2996 .map(|item| item.primary.docid.as_str())
2997 .collect::<Vec<_>>()
2998 );
2999 }
3000
3001 #[test]
3002 fn group_search_results_does_not_merge_short_docid_collisions_across_spaces() {
3003 let results = vec![
3004 make_search_result_in_space(
3005 "#abc123",
3006 "default",
3007 "docs/guide.md",
3008 0.95,
3009 "default guide",
3010 ),
3011 make_search_result_in_space("#abc123", "work", "docs/guide.md", 0.90, "work guide"),
3012 ];
3013
3014 let grouped = group_search_results(&results, 10);
3015
3016 assert_eq!(grouped.len(), 2);
3017 assert_eq!(grouped[0].primary.space, "default");
3018 assert_eq!(grouped[0].primary.path, "docs/guide.md");
3019 assert_eq!(grouped[0].additional_matches, 0);
3020 assert_eq!(grouped[1].primary.space, "work");
3021 assert_eq!(grouped[1].primary.path, "docs/guide.md");
3022 assert_eq!(grouped[1].additional_matches, 0);
3023 }
3024
3025 #[test]
3026 fn search_groups_chunk_results_in_default_output() {
3027 with_isolated_xdg_dirs(|| {
3028 let root = tempdir().expect("create collection root");
3029 let engine = Engine::new(None).expect("create engine");
3030 engine.add_space("work", None).expect("add work");
3031
3032 let docs_path = new_collection_dir(root.path(), "work-docs");
3033 engine
3034 .add_collection(AddCollectionRequest {
3035 path: docs_path.clone(),
3036 space: Some("work".to_string()),
3037 name: Some("docs".to_string()),
3038 description: None,
3039 extensions: None,
3040 no_index: true,
3041 })
3042 .expect("add collection");
3043
3044 fs::write(
3045 docs_path.join("big.md"),
3046 "# Big Guide\n\n## Part 1\nGuide systems depend on guide ranking.\n\
3047\n## Part 2\nGuide tuning changes guide retrieval quality.\n\
3048\n## Part 3\nGuide evaluation catches guide regressions.\n",
3049 )
3050 .expect("write big doc");
3051 fs::write(
3052 docs_path.join("small.md"),
3053 "# Small Guide\n\nguide guide guide guide\n",
3054 )
3055 .expect("write small doc");
3056
3057 let adapter = CliAdapter::new(engine);
3058 adapter
3059 .update(Some("work"), &["docs".to_string()], true, false, false)
3060 .expect("run update");
3061
3062 let output = adapter
3063 .search(CliSearchOptions {
3064 space: Some("work"),
3065 query: "guide",
3066 collections: &["docs".to_string()],
3067 limit: 2,
3068 min_score: 0.0,
3069 deep: false,
3070 keyword: true,
3071 semantic: false,
3072 rerank: false,
3073 no_rerank: false,
3074 debug: false,
3075 })
3076 .expect("run grouped search");
3077
3078 assert!(output.contains("2 results"), "unexpected output:\n{output}");
3079 assert!(
3080 output.matches("work/docs/big.md").count() == 1,
3081 "unexpected output:\n{output}"
3082 );
3083 assert!(
3084 output.contains("more matching section"),
3085 "unexpected output:\n{output}"
3086 );
3087 });
3088 }
3089
3090 #[test]
3091 fn search_debug_keeps_chunk_level_results() {
3092 with_isolated_xdg_dirs(|| {
3093 let root = tempdir().expect("create collection root");
3094 let engine = Engine::new(None).expect("create engine");
3095 engine.add_space("work", None).expect("add work");
3096
3097 let docs_path = new_collection_dir(root.path(), "work-docs");
3098 engine
3099 .add_collection(AddCollectionRequest {
3100 path: docs_path.clone(),
3101 space: Some("work".to_string()),
3102 name: Some("docs".to_string()),
3103 description: None,
3104 extensions: None,
3105 no_index: true,
3106 })
3107 .expect("add collection");
3108
3109 fs::write(
3110 docs_path.join("big.md"),
3111 "# Big Guide\n\n## Part 1\nGuide systems depend on guide ranking.\n\
3112\n## Part 2\nGuide tuning changes guide retrieval quality.\n\
3113\n## Part 3\nGuide evaluation catches guide regressions.\n",
3114 )
3115 .expect("write big doc");
3116
3117 let adapter = CliAdapter::new(engine);
3118 adapter
3119 .update(Some("work"), &["docs".to_string()], true, false, false)
3120 .expect("run update");
3121
3122 let output = adapter
3123 .search(CliSearchOptions {
3124 space: Some("work"),
3125 query: "guide",
3126 collections: &["docs".to_string()],
3127 limit: 3,
3128 min_score: 0.0,
3129 deep: false,
3130 keyword: true,
3131 semantic: false,
3132 rerank: false,
3133 no_rerank: false,
3134 debug: true,
3135 })
3136 .expect("run debug search");
3137
3138 assert!(
3139 output.matches("work/docs/big.md").count() > 1,
3140 "unexpected output:\n{output}"
3141 );
3142 assert!(
3143 !output.contains("more matching section"),
3144 "unexpected output:\n{output}"
3145 );
3146 });
3147 }
3148
3149 #[test]
3150 fn status_response_formats_human_storage_and_model_summary() {
3151 let output = format_status_response(
3152 &StatusResponse {
3153 spaces: vec![SpaceStatus {
3154 name: "default".to_string(),
3155 description: Some("main workspace".to_string()),
3156 last_updated: Some("2026-04-11T16:49:07Z".to_string()),
3157 collections: vec![CollectionStatus {
3158 name: "kbolt".to_string(),
3159 path: PathBuf::from("/Users/macbook/kbolt"),
3160 documents: 98,
3161 active_documents: 98,
3162 chunks: 1218,
3163 embedded_chunks: 1218,
3164 last_updated: "2026-04-11T16:49:07Z".to_string(),
3165 }],
3166 }],
3167 models: kbolt_types::ModelStatus {
3168 embedder: ModelInfo {
3169 configured: true,
3170 ready: false,
3171 profile: Some("kbolt_local_embed".to_string()),
3172 kind: Some("llama_cpp_server".to_string()),
3173 operation: Some("embedding".to_string()),
3174 model: Some("embeddinggemma".to_string()),
3175 endpoint: Some("http://127.0.0.1:8103".to_string()),
3176 issue: Some("endpoint is unreachable".to_string()),
3177 },
3178 reranker: ModelInfo {
3179 configured: true,
3180 ready: true,
3181 profile: Some("kbolt_local_rerank".to_string()),
3182 kind: Some("llama_cpp_server".to_string()),
3183 operation: Some("reranking".to_string()),
3184 model: Some("qwen3-reranker".to_string()),
3185 endpoint: Some("http://127.0.0.1:8104".to_string()),
3186 issue: None,
3187 },
3188 expander: ModelInfo {
3189 configured: false,
3190 ready: false,
3191 profile: None,
3192 kind: None,
3193 operation: None,
3194 model: None,
3195 endpoint: None,
3196 issue: None,
3197 },
3198 },
3199 cache_dir: PathBuf::from("/Users/macbook/Library/Caches/kbolt"),
3200 config_dir: PathBuf::from("/Users/macbook/Library/Application Support/kbolt"),
3201 total_documents: 98,
3202 total_chunks: 1218,
3203 total_embedded: 1218,
3204 disk_usage: DiskUsage {
3205 sqlite_bytes: 348_160,
3206 tantivy_bytes: 520_111,
3207 usearch_bytes: 4_581_056,
3208 models_bytes: 1_935_460_432,
3209 total_bytes: 1_940_909_759,
3210 },
3211 },
3212 Some("default"),
3213 );
3214
3215 assert!(output.contains("spaces:"));
3216 assert!(output.contains("- default (active)"));
3217 assert!(output.contains(" description: main workspace"));
3218 assert!(output.contains(" collections:"));
3219 assert!(output.contains(" - kbolt"));
3220 assert!(output.contains("storage:"));
3221 assert!(
3222 output.contains("- sqlite: 340 KB"),
3223 "unexpected output:\n{output}"
3224 );
3225 assert!(
3226 output.contains("- tantivy: 508 KB"),
3227 "unexpected output:\n{output}"
3228 );
3229 assert!(
3230 output.contains("- vectors: 4.4 MB"),
3231 "unexpected output:\n{output}"
3232 );
3233 assert!(
3234 output.contains("- models: 1.8 GB"),
3235 "unexpected output:\n{output}"
3236 );
3237 assert!(
3238 output.contains("- total: 1.8 GB"),
3239 "unexpected output:\n{output}"
3240 );
3241 assert!(output.contains("- embedder: not ready (embeddinggemma)"));
3242 assert!(output.contains(" issue: endpoint is unreachable"));
3243 assert!(!output.contains("profile="), "unexpected output:\n{output}");
3244 }
3245
3246 #[test]
3247 fn active_space_name_for_status_follows_cli_precedence_without_validation() {
3248 with_isolated_xdg_dirs(|| {
3249 let root = tempdir().expect("create config root");
3250 std::fs::write(
3251 root.path().join("index.toml"),
3252 "default_space = \"default\"\n",
3253 )
3254 .expect("write config");
3255 let engine = Engine::new(Some(root.path())).expect("create engine");
3256
3257 std::env::remove_var("KBOLT_SPACE");
3258 assert_eq!(
3259 active_space_name_for_status(&engine, Some("work")).as_deref(),
3260 Some("work")
3261 );
3262
3263 std::env::set_var("KBOLT_SPACE", "ops");
3264 assert_eq!(
3265 active_space_name_for_status(&engine, None).as_deref(),
3266 Some("ops")
3267 );
3268 std::env::remove_var("KBOLT_SPACE");
3269
3270 assert_eq!(
3271 active_space_name_for_status(&engine, None).as_deref(),
3272 Some("default")
3273 );
3274 });
3275 }
3276
3277 #[test]
3278 fn optional_search_signal_uses_human_values() {
3279 assert_eq!(format_optional_search_signal(None), "-");
3280 assert_eq!(format_optional_search_signal(Some(0.824)), "0.82");
3281 }
3282
3283 #[test]
3284 fn file_list_hides_docids_by_default() {
3285 let output = format_file_list(
3286 &[
3287 FileEntry {
3288 path: "docs/keep.md".to_string(),
3289 title: "keep.md".to_string(),
3290 docid: "#3c96dd".to_string(),
3291 active: true,
3292 chunk_count: 1,
3293 embedded: false,
3294 },
3295 FileEntry {
3296 path: "docs/old.md".to_string(),
3297 title: "old.md".to_string(),
3298 docid: "#deadbe".to_string(),
3299 active: false,
3300 chunk_count: 1,
3301 embedded: false,
3302 },
3303 ],
3304 false,
3305 );
3306
3307 assert!(
3308 output.contains("- docs/keep.md"),
3309 "unexpected output:\n{output}"
3310 );
3311 assert!(!output.contains("#3c96dd"), "unexpected output:\n{output}");
3312 assert!(
3313 !output.contains("keep.md |"),
3314 "unexpected output:\n{output}"
3315 );
3316 }
3317
3318 #[test]
3319 fn file_list_marks_inactive_files_with_all() {
3320 let output = format_file_list(
3321 &[FileEntry {
3322 path: "docs/old.md".to_string(),
3323 title: "old.md".to_string(),
3324 docid: "#deadbe".to_string(),
3325 active: false,
3326 chunk_count: 1,
3327 embedded: false,
3328 }],
3329 true,
3330 );
3331
3332 assert!(output.contains("- docs/old.md (inactive)"));
3333 assert!(!output.contains("#deadbe"));
3334 }
3335
3336 #[test]
3337 fn collection_info_is_human_readable() {
3338 let output = format_collection_info(&CollectionInfo {
3339 name: "api".to_string(),
3340 space: "work".to_string(),
3341 path: PathBuf::from("/tmp/work-api"),
3342 description: Some("API reference".to_string()),
3343 extensions: Some(vec!["md".to_string(), "txt".to_string()]),
3344 document_count: 97,
3345 active_document_count: 96,
3346 chunk_count: 1183,
3347 embedded_chunk_count: 1180,
3348 created: "2026-04-13T12:00:00Z".to_string(),
3349 updated: "2026-04-14T09:30:00Z".to_string(),
3350 });
3351
3352 assert!(output.contains("collection: work/api"));
3353 assert!(output.contains("path: /tmp/work-api"));
3354 assert!(output.contains("description: API reference"));
3355 assert!(output.contains("extensions: md, txt"));
3356 assert!(output.contains("documents:"));
3357 assert!(output.contains(" 96 active / 97 total"));
3358 assert!(output.contains(" 1183 chunks"));
3359 assert!(output.contains(" 1180 embedded"));
3360 assert!(output.contains("updated:"));
3361 assert!(output.contains(" created 2026-04-13T12:00:00Z"));
3362 assert!(output.contains(" updated 2026-04-14T09:30:00Z"));
3363 assert!(
3364 !output.contains("active_documents:"),
3365 "unexpected output:\n{output}"
3366 );
3367 }
3368
3369 #[test]
3370 fn document_response_is_human_readable() {
3371 let output = format_document_response(&DocumentResponse {
3372 docid: "#409380".to_string(),
3373 path: "kbolt/README.md".to_string(),
3374 title: "README".to_string(),
3375 space: "default".to_string(),
3376 collection: "kbolt".to_string(),
3377 content: "line one\nline two".to_string(),
3378 stale: false,
3379 total_lines: 68,
3380 returned_lines: 5,
3381 });
3382
3383 assert!(output.contains("document: default/kbolt/README.md"));
3384 assert!(output.contains("title: README"));
3385 assert!(output.contains("docid: #409380"));
3386 assert!(output.contains("lines: 5 of 68"));
3387 assert!(output.contains("content:\nline one\nline two"));
3388 assert!(
3389 !output.contains("stale: false"),
3390 "unexpected output:\n{output}"
3391 );
3392 assert!(
3393 !output.contains("collection: kbolt"),
3394 "unexpected output:\n{output}"
3395 );
3396 }
3397
3398 #[test]
3399 fn multi_get_response_is_human_readable() {
3400 let output = format_multi_get_response(&MultiGetResponse {
3401 documents: vec![
3402 DocumentResponse {
3403 docid: "#409380".to_string(),
3404 path: "kbolt/README.md".to_string(),
3405 title: "README".to_string(),
3406 space: "default".to_string(),
3407 collection: "kbolt".to_string(),
3408 content: "line one\nline two".to_string(),
3409 stale: false,
3410 total_lines: 68,
3411 returned_lines: 68,
3412 },
3413 DocumentResponse {
3414 docid: "#abcd12".to_string(),
3415 path: "api/guide.md".to_string(),
3416 title: "Guide".to_string(),
3417 space: "work".to_string(),
3418 collection: "api".to_string(),
3419 content: "guide body".to_string(),
3420 stale: true,
3421 total_lines: 12,
3422 returned_lines: 4,
3423 },
3424 ],
3425 omitted: vec![OmittedFile {
3426 path: "api/large.md".to_string(),
3427 docid: "#large1".to_string(),
3428 size_bytes: 8192,
3429 reason: OmitReason::MaxBytes,
3430 }],
3431 resolved_count: 3,
3432 warnings: vec!["document not found: api/missing.md".to_string()],
3433 });
3434
3435 assert!(output.contains("documents: 2 returned"));
3436 assert!(output.contains("resolved: 3"));
3437 assert!(output.contains("1. default/kbolt/README.md"));
3438 assert!(output.contains(" title: README"));
3439 assert!(output.contains(" docid: #409380"));
3440 assert!(output.contains(" lines: 68"));
3441 assert!(output.contains("2. work/api/guide.md"));
3442 assert!(output.contains(" status: stale"));
3443 assert!(output.contains(" lines: 4 of 12"));
3444 assert!(output.contains("omitted:"));
3445 assert!(output.contains("- api/large.md (8.0 KB, size limit)"));
3446 assert!(output.contains("warnings:"));
3447 assert!(output.contains("- document not found: api/missing.md"));
3448 assert!(
3449 !output.contains("resolved_count:"),
3450 "unexpected output:\n{output}"
3451 );
3452 assert!(
3453 !output.contains("--- #409380"),
3454 "unexpected output:\n{output}"
3455 );
3456 }
3457
3458 #[test]
3459 fn update_verbose_reports_buffered_decisions_before_summary() {
3460 with_isolated_xdg_dirs(|| {
3461 let root = tempdir().expect("create collection root");
3462 let engine = Engine::new(None).expect("create engine");
3463 engine.add_space("work", None).expect("add work");
3464
3465 let collection_path = new_collection_dir(root.path(), "work-api");
3466 engine
3467 .add_collection(AddCollectionRequest {
3468 path: collection_path.clone(),
3469 space: Some("work".to_string()),
3470 name: Some("api".to_string()),
3471 description: None,
3472 extensions: Some(vec!["rs".to_string()]),
3473 no_index: true,
3474 })
3475 .expect("add collection");
3476 let adapter = CliAdapter::new(engine);
3477
3478 fs::create_dir_all(collection_path.join("src")).expect("create src dir");
3479 fs::write(collection_path.join("src/lib.rs"), "fn alpha() {}\n")
3480 .expect("write valid file");
3481 fs::write(collection_path.join("src/bad.rs"), [0xff, 0xfe, 0xfd])
3482 .expect("write invalid file");
3483
3484 let output = adapter
3485 .update(Some("work"), &["api".to_string()], true, false, true)
3486 .expect("run verbose update");
3487
3488 let summary_index = output
3489 .lines()
3490 .position(|line| line == "update complete")
3491 .expect("expected summary output");
3492 assert!(summary_index > 0, "unexpected output: {output}");
3493 assert!(
3494 output
3495 .lines()
3496 .next()
3497 .unwrap_or_default()
3498 .starts_with("work/api/"),
3499 "unexpected output: {output}"
3500 );
3501 assert!(
3502 output.contains("work/api/src/lib.rs: new"),
3503 "unexpected output: {output}"
3504 );
3505 assert!(
3506 output.contains("work/api/src/bad.rs: extract_failed (extract failed:"),
3507 "unexpected output: {output}"
3508 );
3509 assert!(
3510 output.contains("- 2 document(s) scanned"),
3511 "unexpected output: {output}"
3512 );
3513 });
3514 }
3515
3516 #[test]
3517 fn collection_add_result_formats_no_index_message() {
3518 let output = format_collection_add_result(&AddCollectionResult {
3519 collection: CollectionInfo {
3520 name: "api".to_string(),
3521 space: "work".to_string(),
3522 path: PathBuf::from("/tmp/work-api"),
3523 description: None,
3524 extensions: None,
3525 document_count: 0,
3526 active_document_count: 0,
3527 chunk_count: 0,
3528 embedded_chunk_count: 0,
3529 created: "2026-03-31T00:00:00Z".to_string(),
3530 updated: "2026-03-31T00:00:00Z".to_string(),
3531 },
3532 initial_indexing: InitialIndexingOutcome::Skipped,
3533 });
3534
3535 assert!(output.contains("collection added: work/api"));
3536 assert!(output.contains("indexing skipped (--no-index)"));
3537 assert!(output.contains("next:"));
3538 assert!(output.contains(" kbolt --space work update --collection api"));
3539 }
3540
3541 #[test]
3542 fn collection_add_result_formats_incomplete_initial_indexing() {
3543 let output = format_collection_add_result(&AddCollectionResult {
3544 collection: CollectionInfo {
3545 name: "api".to_string(),
3546 space: "work".to_string(),
3547 path: PathBuf::from("/tmp/work-api"),
3548 description: None,
3549 extensions: None,
3550 document_count: 3,
3551 active_document_count: 3,
3552 chunk_count: 3,
3553 embedded_chunk_count: 2,
3554 created: "2026-03-31T00:00:00Z".to_string(),
3555 updated: "2026-03-31T00:00:00Z".to_string(),
3556 },
3557 initial_indexing: InitialIndexingOutcome::Indexed(UpdateReport {
3558 scanned_docs: 3,
3559 skipped_mtime_docs: 0,
3560 skipped_hash_docs: 0,
3561 added_docs: 2,
3562 updated_docs: 0,
3563 failed_docs: 1,
3564 deactivated_docs: 0,
3565 reactivated_docs: 0,
3566 reaped_docs: 0,
3567 embedded_chunks: 2,
3568 decisions: Vec::new(),
3569 errors: vec![kbolt_types::FileError {
3570 path: "work/api/bad.md".to_string(),
3571 error: "extract failed".to_string(),
3572 }],
3573 elapsed_ms: 5,
3574 }),
3575 });
3576
3577 assert!(output.contains("collection added: work/api"));
3578 assert!(output.contains("initial indexing incomplete"));
3579 assert!(output.contains("- 3 document(s) scanned"));
3580 assert!(output.contains("- 2 added"));
3581 assert!(output.contains("- 1 failed"));
3582 assert!(output.contains("- 2 chunk(s) embedded"));
3583 assert!(output.contains("- completed in 5ms"));
3584 assert!(output.contains("errors:"));
3585 assert!(output.contains("- work/api/bad.md: extract failed"));
3586 assert!(output.contains("next:"));
3587 assert!(output.contains(" kbolt --space work update --collection api"));
3588 }
3589
3590 #[test]
3591 fn collection_add_result_formats_model_block_with_resume_steps() {
3592 let output = format_collection_add_result(&AddCollectionResult {
3593 collection: CollectionInfo {
3594 name: "api".to_string(),
3595 space: "work".to_string(),
3596 path: PathBuf::from("/tmp/work-api"),
3597 description: None,
3598 extensions: None,
3599 document_count: 0,
3600 active_document_count: 0,
3601 chunk_count: 0,
3602 embedded_chunk_count: 0,
3603 created: "2026-03-31T00:00:00Z".to_string(),
3604 updated: "2026-03-31T00:00:00Z".to_string(),
3605 },
3606 initial_indexing: InitialIndexingOutcome::Blocked(
3607 InitialIndexingBlock::ModelNotAvailable {
3608 name: "embed-model".to_string(),
3609 },
3610 ),
3611 });
3612
3613 assert!(output.contains("collection added: work/api"));
3614 assert!(output.contains("indexing blocked: model 'embed-model' is not available"));
3615 assert!(output.contains("next:"));
3616 assert!(output.contains(" kbolt setup local"));
3617 assert!(output.contains(" or configure [roles.embedder] in index.toml"));
3618 assert!(output.contains(" then run: kbolt --space work update --collection api"));
3619 }
3620
3621 #[test]
3622 fn update_report_is_human_readable() {
3623 let output = format_update_report(
3624 &UpdateReport {
3625 scanned_docs: 12,
3626 skipped_mtime_docs: 5,
3627 skipped_hash_docs: 1,
3628 added_docs: 3,
3629 updated_docs: 2,
3630 failed_docs: 1,
3631 deactivated_docs: 0,
3632 reactivated_docs: 1,
3633 reaped_docs: 0,
3634 embedded_chunks: 8,
3635 decisions: Vec::new(),
3636 errors: vec![kbolt_types::FileError {
3637 path: "work/api/src/bad.rs".to_string(),
3638 error: "extract failed".to_string(),
3639 }],
3640 elapsed_ms: 1_250,
3641 },
3642 false,
3643 );
3644
3645 assert!(output.starts_with("update complete"));
3646 assert!(output.contains("- 12 document(s) scanned"));
3647 assert!(output.contains("- 6 unchanged"));
3648 assert!(output.contains("- 3 added"));
3649 assert!(output.contains("- 2 updated"));
3650 assert!(output.contains("- 1 failed"));
3651 assert!(output.contains("- 1 reactivated"));
3652 assert!(output.contains("- 8 chunk(s) embedded"));
3653 assert!(output.contains("- completed in 1.2s"));
3654 assert!(output.contains("errors:"));
3655 assert!(output.contains("- work/api/src/bad.rs: extract failed"));
3656 assert!(
3657 !output.contains("scanned_docs:"),
3658 "unexpected output:\n{output}"
3659 );
3660 }
3661
3662 #[test]
3663 fn format_elapsed_ms_uses_human_units() {
3664 assert_eq!(format_elapsed_ms(8), "8ms");
3665 assert_eq!(format_elapsed_ms(1_250), "1.2s");
3666 assert_eq!(format_elapsed_ms(125_000), "2.1m");
3667 }
3668
3669 #[test]
3670 fn space_add_with_directories_reports_registration_without_indexing() {
3671 with_isolated_xdg_dirs(|| {
3672 let root = tempdir().expect("create collection root");
3673 let engine = Engine::new(None).expect("create engine");
3674 let mut adapter = CliAdapter::new(engine);
3675
3676 let work_path = new_collection_dir(root.path(), "work-api");
3677 let notes_path = new_collection_dir(root.path(), "work-notes");
3678
3679 let output = adapter
3680 .space_add("work", Some("work docs"), false, &[work_path, notes_path])
3681 .expect("add space with directories");
3682
3683 assert!(output.contains("space added: work - work docs"));
3684 assert!(output.contains("collections registered: 2"));
3685 assert!(output.contains("run `kbolt --space work update` to index them"));
3686 });
3687 }
3688
3689 #[test]
3690 fn format_schedule_add_response_renders_trigger_scope_and_backend() {
3691 let output = format_schedule_add_response(&ScheduleAddResponse {
3692 schedule: ScheduleDefinition {
3693 id: "s1".to_string(),
3694 trigger: ScheduleTrigger::Every {
3695 interval: ScheduleInterval {
3696 value: 30,
3697 unit: ScheduleIntervalUnit::Minutes,
3698 },
3699 },
3700 scope: ScheduleScope::All,
3701 },
3702 backend: ScheduleBackend::Launchd,
3703 });
3704
3705 assert_eq!(
3706 output,
3707 "schedule added: s1\ntrigger: every 30m\nscope: all spaces\nbackend: launchd"
3708 );
3709 }
3710
3711 #[test]
3712 fn format_schedule_status_response_renders_entries_and_orphans() {
3713 let output = format_schedule_status_response(&ScheduleStatusResponse {
3714 schedules: vec![ScheduleStatusEntry {
3715 schedule: ScheduleDefinition {
3716 id: "s2".to_string(),
3717 trigger: ScheduleTrigger::Weekly {
3718 weekdays: vec![ScheduleWeekday::Mon, ScheduleWeekday::Fri],
3719 time: "15:00".to_string(),
3720 },
3721 scope: ScheduleScope::Collections {
3722 space: "work".to_string(),
3723 collections: vec!["api".to_string(), "docs".to_string()],
3724 },
3725 },
3726 backend: ScheduleBackend::Launchd,
3727 state: ScheduleState::Drifted,
3728 run_state: ScheduleRunState {
3729 last_started: Some("2026-03-07T20:00:00Z".to_string()),
3730 last_finished: Some("2026-03-07T20:00:05Z".to_string()),
3731 last_result: Some(ScheduleRunResult::SkippedLock),
3732 last_error: None,
3733 },
3734 }],
3735 orphans: vec![ScheduleOrphan {
3736 id: "s9".to_string(),
3737 backend: ScheduleBackend::Launchd,
3738 }],
3739 });
3740
3741 assert!(output.contains(
3742 "schedules:\n- s2 | mon,fri at 3:00 PM | work/api, work/docs | launchd | drifted"
3743 ));
3744 assert!(output.contains("last_result: skipped_lock"));
3745 assert!(output.contains("orphans:\n- s9 (launchd)"));
3746 }
3747
3748 fn make_update_report(errors: Vec<FileError>, failed_docs: usize) -> UpdateReport {
3749 UpdateReport {
3750 scanned_docs: 0,
3751 skipped_mtime_docs: 0,
3752 skipped_hash_docs: 0,
3753 added_docs: 0,
3754 updated_docs: 0,
3755 failed_docs,
3756 deactivated_docs: 0,
3757 reactivated_docs: 0,
3758 reaped_docs: 0,
3759 embedded_chunks: 0,
3760 decisions: Vec::new(),
3761 errors,
3762 elapsed_ms: 0,
3763 }
3764 }
3765
3766 fn make_file_error(path: &str) -> FileError {
3767 FileError {
3768 path: path.to_string(),
3769 error: "test failure".to_string(),
3770 }
3771 }
3772
3773 fn make_collection_info(space: &str, name: &str) -> CollectionInfo {
3774 CollectionInfo {
3775 name: name.to_string(),
3776 space: space.to_string(),
3777 path: PathBuf::from("/tmp/x"),
3778 description: None,
3779 extensions: None,
3780 document_count: 0,
3781 active_document_count: 0,
3782 chunk_count: 0,
3783 embedded_chunk_count: 0,
3784 created: "2026-04-18T00:00:00Z".to_string(),
3785 updated: "2026-04-18T00:00:00Z".to_string(),
3786 }
3787 }
3788
3789 #[test]
3790 fn append_update_error_lines_returns_true_when_truncated() {
3791 let report = make_update_report(
3792 vec![
3793 make_file_error("a.md"),
3794 make_file_error("b.md"),
3795 make_file_error("c.md"),
3796 make_file_error("d.md"),
3797 ],
3798 4,
3799 );
3800 let mut lines = Vec::new();
3801 let truncated = append_update_error_lines(&mut lines, &report, 3);
3802 assert!(truncated);
3803 assert!(lines.iter().any(|l| l == "- 1 more error(s)"));
3804 }
3805
3806 #[test]
3807 fn append_update_error_lines_returns_false_within_limit() {
3808 let report = make_update_report(vec![make_file_error("a.md"), make_file_error("b.md")], 2);
3809 let mut lines = Vec::new();
3810 let truncated = append_update_error_lines(&mut lines, &report, 3);
3811 assert!(!truncated);
3812 assert!(!lines.iter().any(|l| l.contains("more error(s)")));
3813 }
3814
3815 #[test]
3816 fn format_update_report_emits_verbose_hint_on_truncation() {
3817 let report = make_update_report(
3818 vec![
3819 make_file_error("a.md"),
3820 make_file_error("b.md"),
3821 make_file_error("c.md"),
3822 make_file_error("d.md"),
3823 ],
3824 4,
3825 );
3826 let output = format_update_report(&report, false);
3827 assert!(
3828 output.contains("- 1 more error(s)"),
3829 "expected truncation summary: {output}"
3830 );
3831 assert!(
3832 output.contains(" run with --verbose for the full list"),
3833 "expected verbose hint: {output}"
3834 );
3835 }
3836
3837 #[test]
3838 fn format_update_report_omits_verbose_hint_without_truncation() {
3839 let report = make_update_report(vec![make_file_error("a.md"), make_file_error("b.md")], 2);
3840 let output = format_update_report(&report, false);
3841 assert!(
3842 !output.contains("more error(s)"),
3843 "expected no truncation: {output}"
3844 );
3845 assert!(
3846 !output.contains("--verbose"),
3847 "expected no verbose hint: {output}"
3848 );
3849 }
3850
3851 #[test]
3852 fn format_collection_add_indexing_upgrades_next_to_verbose_when_truncated() {
3853 let collection = make_collection_info("default", "notes");
3854 let report = make_update_report(
3855 vec![
3856 make_file_error("a.md"),
3857 make_file_error("b.md"),
3858 make_file_error("c.md"),
3859 make_file_error("d.md"),
3860 ],
3861 4,
3862 );
3863 let result = AddCollectionResult {
3864 collection,
3865 initial_indexing: InitialIndexingOutcome::Indexed(report),
3866 };
3867 let output = format_collection_add_result(&result);
3868 assert!(
3869 output.contains("kbolt --space default update --verbose --collection notes"),
3870 "expected --verbose in next command: {output}"
3871 );
3872 }
3873
3874 #[test]
3875 fn format_collection_add_indexing_omits_verbose_when_not_truncated() {
3876 let collection = make_collection_info("default", "notes");
3877 let report = make_update_report(vec![make_file_error("a.md"), make_file_error("b.md")], 2);
3878 let result = AddCollectionResult {
3879 collection,
3880 initial_indexing: InitialIndexingOutcome::Indexed(report),
3881 };
3882 let output = format_collection_add_result(&result);
3883 assert!(
3884 output.contains("kbolt --space default update --collection notes"),
3885 "expected plain update command: {output}"
3886 );
3887 assert!(
3888 !output.contains("--verbose"),
3889 "expected no verbose flag: {output}"
3890 );
3891 }
3892
3893 #[test]
3894 fn format_collection_add_indexing_emits_next_block_when_truncated_without_failed_docs() {
3895 let collection = make_collection_info("default", "notes");
3896 let report = make_update_report(
3897 vec![
3898 make_file_error("a.md"),
3899 make_file_error("b.md"),
3900 make_file_error("c.md"),
3901 make_file_error("d.md"),
3902 ],
3903 0, );
3905 let result = AddCollectionResult {
3906 collection,
3907 initial_indexing: InitialIndexingOutcome::Indexed(report),
3908 };
3909 let output = format_collection_add_result(&result);
3910 assert!(
3911 output.contains("next:"),
3912 "expected a next block despite failed_docs=0: {output}"
3913 );
3914 assert!(
3915 output.contains("kbolt --space default update --verbose --collection notes"),
3916 "expected --verbose update in next: {output}"
3917 );
3918 }
3919
3920 #[test]
3921 fn format_collection_add_indexing_shell_quotes_space_and_name_in_next() {
3922 let collection = make_collection_info("team notes", "cold docs");
3923 let report = make_update_report(
3924 vec![
3925 make_file_error("a.md"),
3926 make_file_error("b.md"),
3927 make_file_error("c.md"),
3928 make_file_error("d.md"),
3929 ],
3930 4,
3931 );
3932 let result = AddCollectionResult {
3933 collection,
3934 initial_indexing: InitialIndexingOutcome::Indexed(report),
3935 };
3936 let output = format_collection_add_result(&result);
3937 assert!(
3938 output.contains("kbolt --space 'team notes' update --verbose --collection 'cold docs'"),
3939 "expected space and name single-quoted: {output}"
3940 );
3941 }
3942
3943 #[test]
3944 fn space_add_note_shell_quotes_space_name_with_whitespace() {
3945 with_isolated_xdg_dirs(|| {
3946 let root = tempdir().expect("create collection root");
3947 let engine = Engine::new(None).expect("create engine");
3948 let mut adapter = CliAdapter::new(engine);
3949
3950 let dir = new_collection_dir(root.path(), "some-collection");
3951 let output = adapter
3952 .space_add("team notes", None, false, &[dir])
3953 .expect("add space with directories");
3954
3955 assert!(
3956 output.contains("run `kbolt --space 'team notes' update` to index them"),
3957 "expected quoted space in registration note: {output}"
3958 );
3959 });
3960 }
3961
3962 #[test]
3963 fn format_collection_add_result_skipped_shell_quotes_space_and_name() {
3964 let collection = make_collection_info("team notes", "cold docs");
3965 let result = AddCollectionResult {
3966 collection,
3967 initial_indexing: InitialIndexingOutcome::Skipped,
3968 };
3969 let output = format_collection_add_result(&result);
3970 assert!(
3971 output.contains("kbolt --space 'team notes' update --collection 'cold docs'"),
3972 "expected quoted space and name in next block: {output}"
3973 );
3974 }
3975
3976 #[test]
3977 fn format_collection_add_block_space_dense_repair_quotes_space_name() {
3978 let collection = make_collection_info("default", "notes");
3979 let result = AddCollectionResult {
3980 collection,
3981 initial_indexing: InitialIndexingOutcome::Blocked(
3982 InitialIndexingBlock::SpaceDenseRepairRequired {
3983 space: "team notes".to_string(),
3984 reason: "dimension mismatch".to_string(),
3985 },
3986 ),
3987 };
3988 let output = format_collection_add_result(&result);
3989 assert!(
3990 output.contains("kbolt --space 'team notes' update"),
3991 "expected quoted space in repair-required command: {output}"
3992 );
3993 }
3994
3995 #[test]
3996 fn format_collection_add_block_model_not_available_quotes_space_and_name() {
3997 let collection = make_collection_info("team notes", "cold docs");
3998 let result = AddCollectionResult {
3999 collection,
4000 initial_indexing: InitialIndexingOutcome::Blocked(
4001 InitialIndexingBlock::ModelNotAvailable {
4002 name: "embedder".to_string(),
4003 },
4004 ),
4005 };
4006 let output = format_collection_add_result(&result);
4007 assert!(
4008 output.contains("then run: kbolt --space 'team notes' update --collection 'cold docs'"),
4009 "expected quoted space and name in model-unavailable command: {output}"
4010 );
4011 }
4012
4013 #[test]
4014 fn format_eval_import_report_shell_quotes_space_collection_and_paths() {
4015 let report = EvalImportReport {
4016 dataset: "fiqa".to_string(),
4017 source: "/tmp/fiqa".to_string(),
4018 output_dir: "/tmp/out".to_string(),
4019 corpus_dir: "/Users/me/My Data/corpus".to_string(),
4020 manifest_path: "/Users/me/My Data/eval.toml".to_string(),
4021 default_space: "bench space".to_string(),
4022 collection: "fiqa docs".to_string(),
4023 document_count: 0,
4024 query_count: 0,
4025 judgment_count: 0,
4026 };
4027 let output = format_eval_import_report(&report);
4028 assert!(
4029 output.contains("kbolt space add 'bench space'"),
4030 "expected quoted space in create command: {output}"
4031 );
4032 assert!(
4033 output.contains("kbolt --space 'bench space' collection add '/Users/me/My Data/corpus' --name 'fiqa docs' --no-index"),
4034 "expected quoted args in register command: {output}"
4035 );
4036 assert!(
4037 output.contains("kbolt --space 'bench space' update --collection 'fiqa docs'"),
4038 "expected quoted args in index command: {output}"
4039 );
4040 assert!(
4041 output.contains("kbolt eval run --file '/Users/me/My Data/eval.toml'"),
4042 "expected quoted manifest path in eval run command: {output}"
4043 );
4044 }
4045}