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